diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md index 8f5d3e2e..184570ea 100644 --- a/Documentation/NamingTemplates.md +++ b/Documentation/NamingTemplates.md @@ -46,9 +46,12 @@ These tags will be replaced in the template with the audiobook's values. |\|All series to which the book belongs (if any)|[Series List](#series-list-formatters)| |\|First series|[Series](#series-formatters)| |\|Number order in series (alias for \|[Number](#number-formatters)| -|\|File's original bitrate (Kbps)|[Number](#number-formatters)| -|\|File's original audio sample rate|[Number](#number-formatters)| -|\|Number of audio channels|[Number](#number-formatters)| +|\|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)| +|\|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)| +|\|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)| +|\|Audio codec of the last downloaded audiobook|[Text](#text-formatters)| +|\|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)| +|\|Libation version used during last download of the audiobook|[Text](#text-formatters)| |\|Audible account of this book|[Text](#text-formatters)| |\|Audible account nickname of this book|[Text](#text-formatters)| |\|Region/country|[Text](#text-formatters)| diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index d585820a..4ad8889d 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -13,7 +13,7 @@ - + diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 7c373d0b..f2e2a7cc 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -521,8 +521,8 @@ namespace ApplicationServices udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating); }); - public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion) - => lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); }); + public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion) + => lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); }); public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus) => libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus); diff --git a/Source/ApplicationServices/LibraryExporter.cs b/Source/ApplicationServices/LibraryExporter.cs index f0a62193..1f16052a 100644 --- a/Source/ApplicationServices/LibraryExporter.cs +++ b/Source/ApplicationServices/LibraryExporter.cs @@ -4,8 +4,8 @@ using System.Linq; using CsvHelper; using CsvHelper.Configuration.Attributes; using DataLayer; +using Newtonsoft.Json; using NPOI.XSSF.UserModel; -using Serilog; namespace ApplicationServices { @@ -115,7 +115,29 @@ namespace ApplicationServices [Name("IsFinished")] public bool IsFinished { get; set; } - } + + [Name("IsSpatial")] + public bool IsSpatial { get; set; } + + [Name("Last Downloaded File Version")] + public string LastDownloadedFileVersion { get; set; } + + [Ignore /* csv ignore */] + public AudioFormat LastDownloadedFormat { get; set; } + + [Name("Last Downloaded Codec"), JsonIgnore] + public string CodecString => LastDownloadedFormat?.CodecString ?? ""; + + [Name("Last Downloaded Sample rate"), JsonIgnore] + public int? SampleRate => LastDownloadedFormat?.SampleRate; + + [Name("Last Downloaded Audio Channels"), JsonIgnore] + public int? ChannelCount => LastDownloadedFormat?.ChannelCount; + + [Name("Last Downloaded Bitrate"), JsonIgnore] + public int? BitRate => LastDownloadedFormat?.BitRate; + } + public static class LibToDtos { public static List ToDtos(this IEnumerable library) @@ -135,16 +157,16 @@ namespace ApplicationServices HasPdf = a.Book.HasPdf(), SeriesNames = a.Book.SeriesNames(), SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "", - CommunityRatingOverall = a.Book.Rating?.OverallRating, - CommunityRatingPerformance = a.Book.Rating?.PerformanceRating, - CommunityRatingStory = a.Book.Rating?.StoryRating, + CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(), + CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(), + CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(), PictureId = a.Book.PictureId, IsAbridged = a.Book.IsAbridged, DatePublished = a.Book.DatePublished, CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()), - MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating, - MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating, - MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating, + MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(), + MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(), + MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(), MyLibationTags = a.Book.UserDefinedItem.Tags, BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(), PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(), @@ -152,8 +174,13 @@ namespace ApplicationServices Language = a.Book.Language, LastDownloaded = a.Book.UserDefinedItem.LastDownloaded, LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "", - IsFinished = a.Book.UserDefinedItem.IsFinished - }).ToList(); + IsFinished = a.Book.UserDefinedItem.IsFinished, + IsSpatial = a.Book.IsSpatial, + LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "", + LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat + }).ToList(); + + private static float? ZeroIsNull(this float value) => value is 0 ? null : value; } public static class LibraryExporter { @@ -162,7 +189,6 @@ namespace ApplicationServices var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); if (!dtos.Any()) return; - using var writer = new System.IO.StreamWriter(saveFilePath); using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture); @@ -174,7 +200,7 @@ namespace ApplicationServices public static void ToJson(string saveFilePath) { var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); - var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented); + var json = JsonConvert.SerializeObject(dtos, Formatting.Indented); System.IO.File.WriteAllText(saveFilePath, json); } @@ -227,7 +253,13 @@ namespace ApplicationServices nameof(ExportDto.Language), nameof(ExportDto.LastDownloaded), nameof(ExportDto.LastDownloadedVersion), - nameof(ExportDto.IsFinished) + nameof(ExportDto.IsFinished), + nameof(ExportDto.IsSpatial), + nameof(ExportDto.LastDownloadedFileVersion), + nameof(ExportDto.CodecString), + nameof(ExportDto.SampleRate), + nameof(ExportDto.ChannelCount), + nameof(ExportDto.BitRate) }; var col = 0; foreach (var c in columns) @@ -248,15 +280,10 @@ namespace ApplicationServices foreach (var dto in dtos) { col = 0; - - row = sheet.CreateRow(rowIndex); + row = sheet.CreateRow(rowIndex++); row.CreateCell(col++).SetCellValue(dto.Account); - - var dateCell = row.CreateCell(col++); - dateCell.CellStyle = dateStyle; - dateCell.SetCellValue(dto.DateAdded); - + row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle; row.CreateCell(col++).SetCellValue(dto.AudibleProductId); row.CreateCell(col++).SetCellValue(dto.Locale); row.CreateCell(col++).SetCellValue(dto.Title); @@ -269,56 +296,46 @@ namespace ApplicationServices row.CreateCell(col++).SetCellValue(dto.HasPdf); row.CreateCell(col++).SetCellValue(dto.SeriesNames); row.CreateCell(col++).SetCellValue(dto.SeriesOrder); - - col = createCell(row, col, dto.CommunityRatingOverall); - col = createCell(row, col, dto.CommunityRatingPerformance); - col = createCell(row, col, dto.CommunityRatingStory); - + row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall); + row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance); + row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory); row.CreateCell(col++).SetCellValue(dto.PictureId); row.CreateCell(col++).SetCellValue(dto.IsAbridged); - - var datePubCell = row.CreateCell(col++); - datePubCell.CellStyle = dateStyle; - if (dto.DatePublished.HasValue) - datePubCell.SetCellValue(dto.DatePublished.Value); - else - datePubCell.SetCellValue(""); - + row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle; row.CreateCell(col++).SetCellValue(dto.CategoriesNames); - - col = createCell(row, col, dto.MyRatingOverall); - col = createCell(row, col, dto.MyRatingPerformance); - col = createCell(row, col, dto.MyRatingStory); - + row.CreateCell(col++).SetCellValue(dto.MyRatingOverall); + row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance); + row.CreateCell(col++).SetCellValue(dto.MyRatingStory); row.CreateCell(col++).SetCellValue(dto.MyLibationTags); row.CreateCell(col++).SetCellValue(dto.BookStatus); row.CreateCell(col++).SetCellValue(dto.PdfStatus); row.CreateCell(col++).SetCellValue(dto.ContentType); - row.CreateCell(col++).SetCellValue(dto.Language); - - if (dto.LastDownloaded.HasValue) - { - dateCell = row.CreateCell(col); - dateCell.CellStyle = dateStyle; - dateCell.SetCellValue(dto.LastDownloaded.Value); - } - - row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion); - row.CreateCell(++col).SetCellValue(dto.IsFinished); - - rowIndex++; + row.CreateCell(col++).SetCellValue(dto.Language); + row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle; + row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion); + row.CreateCell(col++).SetCellValue(dto.IsFinished); + row.CreateCell(col++).SetCellValue(dto.IsSpatial); + row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion); + row.CreateCell(col++).SetCellValue(dto.CodecString); + row.CreateCell(col++).SetCellValue(dto.SampleRate); + row.CreateCell(col++).SetCellValue(dto.ChannelCount); + row.CreateCell(col++).SetCellValue(dto.BitRate); } using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create); workbook.Write(fileData); } - private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat) - { - if (nullableFloat.HasValue) - row.CreateCell(col++).SetCellValue(nullableFloat.Value); - else - row.CreateCell(col++).SetCellValue(""); - return col; - } + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate) + => nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt) + => nullableInt.HasValue ? cell.SetCellValue(nullableInt.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, float? nullableFloat) + => nullableFloat.HasValue ? cell.SetCellValue(nullableFloat.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); } } diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 8007d316..d9916eaa 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -5,7 +5,7 @@ - + diff --git a/Source/DataLayer/AudioFormat.cs b/Source/DataLayer/AudioFormat.cs new file mode 100644 index 00000000..2a517677 --- /dev/null +++ b/Source/DataLayer/AudioFormat.cs @@ -0,0 +1,70 @@ +#nullable enable +using Newtonsoft.Json; + +namespace DataLayer; + +public enum Codec : byte +{ + Unknown, + Mp3, + AAC_LC, + xHE_AAC, + EC_3, + AC_4 +} + +public class AudioFormat +{ + public static AudioFormat Default => new(Codec.Unknown, 0, 0, 0); + [JsonIgnore] + public bool IsDefault => Codec is Codec.Unknown && BitRate == 0 && SampleRate == 0 && ChannelCount == 0; + [JsonIgnore] + public Codec Codec { get; set; } + public int SampleRate { get; set; } + public int ChannelCount { get; set; } + public int BitRate { get; set; } + + public AudioFormat(Codec codec, int bitRate, int sampleRate, int channelCount) + { + Codec = codec; + BitRate = bitRate; + SampleRate = sampleRate; + ChannelCount = channelCount; + } + + public string CodecString => Codec switch + { + Codec.Mp3 => "mp3", + Codec.AAC_LC => "AAC-LC", + Codec.xHE_AAC => "xHE-AAC", + Codec.EC_3 => "EC-3", + Codec.AC_4 => "AC-4", + Codec.Unknown or _ => "[Unknown]", + }; + + //Property | Start | Num | Max | Current Max | + // | Bit | Bits | Value | Value Used | + //----------------------------------------------------- + //Codec | 35 | 4 | 15 | 5 | + //BitRate | 23 | 12 | 4_095 | 768 | + //SampleRate | 5 | 18 | 262_143 | 48_000 | + //ChannelCount | 0 | 5 | 31 | 6 | + public long Serialize() => + ((long)Codec << 35) | + ((long)BitRate << 23) | + ((long)SampleRate << 5) | + (long)ChannelCount; + + public static AudioFormat Deserialize(long value) + { + var codec = (Codec)((value >> 35) & 15); + var bitRate = (int)((value >> 23) & 4_095); + var sampleRate = (int)((value >> 5) & 262_143); + var channelCount = (int)(value & 31); + return new AudioFormat(codec, bitRate, sampleRate, channelCount); + } + + public override string ToString() + => IsDefault ? "[Unknown Audio Format]" + : $"{CodecString} ({ChannelCount}ch | {SampleRate:N0}Hz | {BitRate}kbps)"; +} diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index bafa27e6..b0d802ce 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -13,7 +13,6 @@ namespace DataLayer.Configurations entity.OwnsOne(b => b.Rating); - entity.Property(nameof(Book._audioFormat)); // // CRUCIAL: ignore unmapped collections, even get-only // @@ -50,6 +49,11 @@ namespace DataLayer.Configurations b_udi .Property(udi => udi.LastDownloadedVersion) .HasConversion(ver => ver.ToString(), str => Version.Parse(str)); + b_udi + .Property(udi => udi.LastDownloadedFormat) + .HasConversion(af => af.Serialize(), str => AudioFormat.Deserialize(str)); + + b_udi.Property(udi => udi.LastDownloadedFileVersion); // owns it 1:1, store in same table b_udi.OwnsOne(udi => udi.Rating); diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 33cbbaf5..11aedf1d 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -43,18 +43,13 @@ namespace DataLayer public ContentType ContentType { get; private set; } public string Locale { get; private set; } - //This field is now unused, however, there is little sense in adding a - //database migration to remove an unused field. Leave it for compatibility. -#pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0 - internal long _audioFormat; -#pragma warning restore CS0649 - // mutable public string PictureId { get; set; } public string PictureLarge { get; set; } // book details public bool IsAbridged { get; private set; } + public bool IsSpatial { get; private set; } public DateTime? DatePublished { get; private set; } public string Language { get; private set; } @@ -242,10 +237,11 @@ namespace DataLayer public void UpdateProductRating(float overallRating, float performanceRating, float storyRating) => Rating.Update(overallRating, performanceRating, storyRating); - public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language) + public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string language) { // don't overwrite with default values IsAbridged |= isAbridged; + IsSpatial |= isSpatial ?? false; DatePublished = datePublished ?? DatePublished; Language = language?.FirstCharToUpper() ?? Language; } diff --git a/Source/DataLayer/EfClasses/UserDefinedItem.cs b/Source/DataLayer/EfClasses/UserDefinedItem.cs index dce1dfbe..7aa977f0 100644 --- a/Source/DataLayer/EfClasses/UserDefinedItem.cs +++ b/Source/DataLayer/EfClasses/UserDefinedItem.cs @@ -24,24 +24,52 @@ namespace DataLayer { internal int BookId { get; private set; } public Book Book { get; private set; } - public DateTime? LastDownloaded { get; private set; } - public Version LastDownloadedVersion { get; private set; } + /// + /// Date the audio file was last downloaded. + /// + public DateTime? LastDownloaded { get; private set; } + /// + /// Version of Libation used the last time the audio file was downloaded. + /// + public Version LastDownloadedVersion { get; private set; } + /// + /// Audio format of the last downloaded audio file. + /// + public AudioFormat LastDownloadedFormat { get; private set; } + /// + /// Version of the audio file that was last downloaded. + /// + public string LastDownloadedFileVersion { get; private set; } - public void SetLastDownloaded(Version version) + public void SetLastDownloaded(Version libationVersion, AudioFormat audioFormat, string audioVersion) { - if (LastDownloadedVersion != version) + if (LastDownloadedVersion != libationVersion) { - LastDownloadedVersion = version; + LastDownloadedVersion = libationVersion; OnItemChanged(nameof(LastDownloadedVersion)); } + if (LastDownloadedFormat != audioFormat) + { + LastDownloadedFormat = audioFormat; + OnItemChanged(nameof(LastDownloadedFormat)); + } + if (LastDownloadedFileVersion != audioVersion) + { + LastDownloadedFileVersion = audioVersion; + OnItemChanged(nameof(LastDownloadedFileVersion)); + } - if (version is null) + if (libationVersion is null) + { LastDownloaded = null; + LastDownloadedFormat = null; + LastDownloadedFileVersion = null; + } else { - LastDownloaded = DateTime.Now; + LastDownloaded = DateTime.Now; OnItemChanged(nameof(LastDownloaded)); - } + } } private UserDefinedItem() { } diff --git a/Source/DataLayer/LibationContextFactory.cs b/Source/DataLayer/LibationContextFactory.cs index 92f1f7d1..8718f636 100644 --- a/Source/DataLayer/LibationContextFactory.cs +++ b/Source/DataLayer/LibationContextFactory.cs @@ -1,5 +1,6 @@ using Dinah.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace DataLayer { @@ -7,6 +8,7 @@ namespace DataLayer { protected override LibationContext CreateNewInstance(DbContextOptions options) => new LibationContext(options); protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) - => optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); + => optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); } } diff --git a/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs new file mode 100644 index 00000000..a39c89db --- /dev/null +++ b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs @@ -0,0 +1,474 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20250725074123_AddAudioFormatData")] + partial class AddAudioFormatData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.Property("_categoriesCategoryId") + .HasColumnType("INTEGER"); + + b.Property("_categoryLaddersCategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId"); + + b.HasIndex("_categoryLaddersCategoryLadderId"); + + b.ToTable("CategoryCategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property("ContentType") + .HasColumnType("INTEGER"); + + b.Property("DatePublished") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAbridged") + .HasColumnType("INTEGER"); + + b.Property("IsSpatial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LengthInMinutes") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasColumnType("TEXT"); + + b.Property("PictureId") + .HasColumnType("TEXT"); + + b.Property("PictureLarge") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("CategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "CategoryLadderId"); + + b.HasIndex("BookId"); + + b.HasIndex("CategoryLadderId"); + + b.ToTable("BookCategory"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("ContributorId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleCategoryId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Property("CategoryLadderId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleContributorId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("AbsentFromLastScan") + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.HasKey("BookId"); + + b.ToTable("LibraryBooks"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleSeriesId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.HasOne("DataLayer.Category", null) + .WithMany() + .HasForeignKey("_categoriesCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", null) + .WithMany() + .HasForeignKey("_categoryLaddersCategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("OverallRating") + .HasColumnType("REAL"); + + b1.Property("PerformanceRating") + .HasColumnType("REAL"); + + b1.Property("StoryRating") + .HasColumnType("REAL"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.WithOwner() + .HasForeignKey("BookId"); + }); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("Url") + .HasColumnType("TEXT"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.Navigation("Book"); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("BookStatus") + .HasColumnType("INTEGER"); + + b1.Property("IsFinished") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloaded") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFileVersion") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("TEXT"); + + b1.Property("PdfStatus") + .HasColumnType("INTEGER"); + + b1.Property("Tags") + .HasColumnType("TEXT"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem", (string)null); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId") + .HasColumnType("INTEGER"); + + b2.Property("OverallRating") + .HasColumnType("REAL"); + + b2.Property("PerformanceRating") + .HasColumnType("REAL"); + + b2.Property("StoryRating") + .HasColumnType("REAL"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.WithOwner() + .HasForeignKey("UserDefinedItemBookId"); + }); + + b1.Navigation("Book"); + + b1.Navigation("Rating"); + }); + + b.Navigation("Rating"); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("CategoriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", "CategoryLadder") + .WithMany("BooksLink") + .HasForeignKey("CategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("CategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Contributor"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Navigation("CategoriesLink"); + + b.Navigation("ContributorsLink"); + + b.Navigation("SeriesLink"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Navigation("BooksLink"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs new file mode 100644 index 00000000..f653c00f --- /dev/null +++ b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// + public partial class AddAudioFormatData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "_audioFormat", + table: "Books", + newName: "IsSpatial"); + + migrationBuilder.AddColumn( + name: "LastDownloadedFileVersion", + table: "UserDefinedItem", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastDownloadedFormat", + table: "UserDefinedItem", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastDownloadedFileVersion", + table: "UserDefinedItem"); + + migrationBuilder.DropColumn( + name: "LastDownloadedFormat", + table: "UserDefinedItem"); + + migrationBuilder.RenameColumn( + name: "IsSpatial", + table: "Books", + newName: "_audioFormat"); + } + } +} diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index 2a17687e..99f70af0 100644 --- a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace DataLayer.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); modelBuilder.Entity("CategoryCategoryLadder", b => { @@ -53,6 +53,9 @@ namespace DataLayer.Migrations b.Property("IsAbridged") .HasColumnType("INTEGER"); + b.Property("IsSpatial") + .HasColumnType("INTEGER"); + b.Property("Language") .HasColumnType("TEXT"); @@ -74,9 +77,6 @@ namespace DataLayer.Migrations b.Property("Title") .HasColumnType("TEXT"); - b.Property("_audioFormat") - .HasColumnType("INTEGER"); - b.HasKey("BookId"); b.HasIndex("AudibleProductId"); @@ -318,6 +318,12 @@ namespace DataLayer.Migrations b1.Property("LastDownloaded") .HasColumnType("TEXT"); + b1.Property("LastDownloadedFileVersion") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("INTEGER"); + b1.Property("LastDownloadedVersion") .HasColumnType("TEXT"); diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index 95c86a64..e6596d09 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -137,8 +137,6 @@ namespace DtoImporterService book.ReplacePublisher(publisher); } - book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language); - if (item.PdfUrl is not null) book.AddSupplementDownloadUrl(item.PdfUrl.ToString()); @@ -166,8 +164,9 @@ namespace DtoImporterService // 2023-02-01 // updateBook must update language on books which were imported before the migration which added language. - // Can eventually delete this - book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language); + // 2025-07-30 + // updateBook must update isSpatial on books which were imported before the migration which added isSpatial. + book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language); book.UpdateProductRating( (float)(item.Rating?.OverallDistribution?.AverageRating ?? 0), diff --git a/Source/FileLiberator/AudioFormatDecoder.cs b/Source/FileLiberator/AudioFormatDecoder.cs new file mode 100644 index 00000000..1894298d --- /dev/null +++ b/Source/FileLiberator/AudioFormatDecoder.cs @@ -0,0 +1,242 @@ +using AAXClean; +using DataLayer; +using FileManager; +using Mpeg4Lib.Boxes; +using Mpeg4Lib.Util; +using NAudio.Lame.ID3; +using System; +using System.Collections.Generic; +using System.IO; + +#nullable enable +namespace AaxDecrypter; + +/// Read audio codec, bitrate, sample rate, and channel count from MP4 and MP3 audio files. +internal static class AudioFormatDecoder +{ + public static AudioFormat FromMpeg4(string filename) + { + using var fileStream = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.Read); + return FromMpeg4(new Mp4File(fileStream)); + } + + public static AudioFormat FromMpeg4(Mp4File mp4File) + { + Codec codec; + if (mp4File.AudioSampleEntry.Dac4 is not null) + { + codec = Codec.AC_4; + } + else if (mp4File.AudioSampleEntry.Dec3 is not null) + { + codec = Codec.EC_3; + } + else if (mp4File.AudioSampleEntry.Esds is EsdsBox esds) + { + var objectType = esds.ES_Descriptor.DecoderConfig.AudioSpecificConfig.AudioObjectType; + codec + = objectType == 2 ? Codec.AAC_LC + : objectType == 42 ? Codec.xHE_AAC + : Codec.Unknown; + } + else + return AudioFormat.Default; + + var bitrate = (int)Math.Round(mp4File.AverageBitrate / 1024d); + + return new AudioFormat(codec, bitrate, mp4File.TimeScale, mp4File.AudioChannels); + } + + public static AudioFormat FromMpeg3(LongPath mp3Filename) + { + using var mp3File = File.Open(mp3Filename, FileMode.Open, FileAccess.Read, FileShare.Read); + if (Id3Header.Create(mp3File) is Id3Header id3header) + id3header.SeekForwardToPosition(mp3File, mp3File.Position + id3header.Size); + else + { + Serilog.Log.Logger.Debug("File appears not to have ID3 tags."); + mp3File.Position = 0; + } + + if (!SeekToFirstKeyFrame(mp3File)) + { + Serilog.Log.Logger.Warning("Invalid frame sync read from file at end of ID3 tag."); + return AudioFormat.Default; + } + + var mpegSize = mp3File.Length - mp3File.Position; + if (mpegSize < 64) + { + Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {@File}", mp3Filename); + return AudioFormat.Default; + } + + #region read first mp3 frame header + //https://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header#VBRIHeader + var reader = new BitReader(mp3File.ReadBlock(4)); + reader.Position = 11; //Skip frame header magic bits + var versionId = (Version)reader.Read(2); + var layerDesc = (Layer)reader.Read(2); + + if (layerDesc is not Layer.Layer_3) + { + Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString()); + return AudioFormat.Default; + } + + if (versionId is Version.Reserved) + { + Serilog.Log.Logger.Warning("Mp3 data data cannot be read from a file with version = 'Reserved'"); + return AudioFormat.Default; + } + + var protectionBit = reader.ReadBool(); + var bitrateIndex = reader.Read(4); + var freqIndex = reader.Read(2); + _ = reader.ReadBool(); //Padding bit + _ = reader.ReadBool(); //Private bit + var channelMode = reader.Read(2); + _ = reader.Read(2); //Mode extension + _ = reader.ReadBool(); //Copyright + _ = reader.ReadBool(); //Original + _ = reader.Read(2); //Emphasis + #endregion + + //Read the sample rate,and channels from the first frame's header. + var sampleRate = Mp3SampleRateIndex[versionId][freqIndex]; + var channelCount = channelMode == 3 ? 1 : 2; + + //Try to read variable bitrate info from the first frame. + //Revert to fixed bitrate from frame header if not found. + var bitrate + = TryReadXingBitrate(out var br) ? br + : TryReadVbriBitrate(out br) ? br + : Mp3BitrateIndex[versionId][bitrateIndex]; + + return new AudioFormat(Codec.Mp3, bitrate, sampleRate, channelCount); + + #region Variable bitrate header readers + bool TryReadXingBitrate(out int bitrate) + { + const int XingHeader = 0x58696e67; + const int InfoHeader = 0x496e666f; + + var sideInfoSize = GetSideInfo(channelCount == 2, versionId) + (protectionBit ? 0 : 2); + mp3File.Position += sideInfoSize; + + if (mp3File.ReadUInt32BE() is XingHeader or InfoHeader) + { + //Xing or Info header (common) + var flags = mp3File.ReadUInt32BE(); + bool hasFramesField = (flags & 1) == 1; + bool hasBytesField = (flags & 2) == 2; + + if (hasFramesField) + { + var numFrames = mp3File.ReadUInt32BE(); + if (hasBytesField) + { + mpegSize = mp3File.ReadUInt32BE(); + } + + var samplesPerFrame = GetSamplesPerFrame(sampleRate); + var duration = samplesPerFrame * numFrames / sampleRate; + bitrate = (short)(mpegSize / duration / 1024 * 8); + return true; + } + } + else + mp3File.Position -= sideInfoSize + 4; + + bitrate = 0; + return false; + } + + bool TryReadVbriBitrate(out int bitrate) + { + const int VBRIHeader = 0x56425249; + + mp3File.Position += 32; + + if (mp3File.ReadUInt32BE() is VBRIHeader) + { + //VBRI header (rare) + _ = mp3File.ReadBlock(6); + mpegSize = mp3File.ReadUInt32BE(); + var numFrames = mp3File.ReadUInt32BE(); + + var samplesPerFrame = GetSamplesPerFrame(sampleRate); + var duration = samplesPerFrame * numFrames / sampleRate; + bitrate = (short)(mpegSize / duration / 1024 * 8); + return true; + } + bitrate = 0; + return false; + } + #endregion + } + + #region MP3 frame decoding helpers + private static bool SeekToFirstKeyFrame(Stream file) + { + //Frame headers begin with first 11 bits set. + const int MaxSeekBytes = 4096; + var maxPosition = Math.Min(file.Length, file.Position + MaxSeekBytes) - 2; + + while (file.Position < maxPosition) + { + if (file.ReadByte() == 0xff) + { + if ((file.ReadByte() & 0xe0) == 0xe0) + { + file.Position -= 2; + return true; + } + file.Position--; + } + } + return false; + } + + private enum Version + { + Version_2_5, + Reserved, + Version_2, + Version_1 + } + + private enum Layer + { + Reserved, + Layer_3, + Layer_2, + Layer_1 + } + + private static double GetSamplesPerFrame(int sampleRate) => sampleRate >= 32000 ? 1152 : 576; + + private static byte GetSideInfo(bool stereo, Version version) => (stereo, version) switch + { + (true, Version.Version_1) => 32, + (true, Version.Version_2 or Version.Version_2_5) => 17, + (false, Version.Version_1) => 17, + (false, Version.Version_2 or Version.Version_2_5) => 9, + _ => 0, + }; + + private static readonly Dictionary Mp3SampleRateIndex = new() + { + { Version.Version_2_5, [11025, 12000, 8000] }, + { Version.Version_2, [22050, 24000, 16000] }, + { Version.Version_1, [44100, 48000, 32000] }, + }; + + private static readonly Dictionary Mp3BitrateIndex = new() + { + { Version.Version_2_5, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]}, + { Version.Version_2, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]}, + { Version.Version_1, [-1,32,40,48,56,64,80,96,112,128,160,192,224,256,320,-1]} + }; + #endregion +} diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index 8bdae9d0..d04c904d 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -47,7 +47,7 @@ namespace FileLiberator using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken); var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken); - if (!result.Success || getFirstAudioFile(result.ResultFiles) == default) + if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile) { // decrypt failed. Delete all output entries but leave the cache files. result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath)); @@ -61,6 +61,12 @@ namespace FileLiberator result.ResultFiles.AddRange(getAaxcFiles(result.CacheFiles)); } + //Set the last downloaded information on the book so that it can be used in the naming templates, + //but don't persist it until everything completes successfully (in the finally block) + var audioFormat = GetFileFormatInfo(downloadOptions, audioFile); + var audioVersion = downloadOptions.ContentMetadata.ContentReference.Version; + libraryBook.Book.UserDefinedItem.SetLastDownloaded(Configuration.LibationVersion, audioFormat, audioVersion); + var finalStorageDir = getDestinationDirectory(libraryBook); //post-download tasks done in parallel. @@ -80,14 +86,14 @@ namespace FileLiberator } catch when (!moveFilesTask.IsFaulted) { - //Swallow DownloadCoverArt, SetCoverAsFolderIcon, and SaveMetadataAsync exceptions. + //Swallow DownloadCoverArt, DownloadRecordsAsync, DownloadMetadataAsync, and SetCoverAsFolderIcon exceptions. //Only fail if the downloaded audio files failed to move to Books directory } finally { if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested) { - libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion!); + libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion); SetDirectoryTime(libraryBook, finalStorageDir); foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath))) { @@ -275,6 +281,31 @@ namespace FileLiberator #endregion #region Post-success routines + /// Read the audio format from the audio file's metadata. + public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile) + { + try + { + return firstAudioFile.Extension.ToLowerInvariant() switch + { + ".m4b" or ".m4a" or ".mp4" => GetMp4AudioFormat(), + ".mp3" => AudioFormatDecoder.FromMpeg3(firstAudioFile.FilePath), + _ => AudioFormat.Default + }; + } + catch (Exception ex) + { + //Failure to determine output audio format should not be considered a failure to download the book + Serilog.Log.Logger.Error(ex, "Error determining output audio format for {@Book}. File = '{@audioFile}'", options.LibraryBook, firstAudioFile); + return AudioFormat.Default; + } + + AudioFormat GetMp4AudioFormat() + => abDownloader is AaxcDownloadConvertBase converter && converter.AaxFile is AAXClean.Mp4File mp4File + ? AudioFormatDecoder.FromMpeg4(mp4File) + : AudioFormatDecoder.FromMpeg4(firstAudioFile.FilePath); + } + /// Move new files to 'Books' directory /// Return directory if audiobook file(s) were successfully created and can be located on disk. Else null. private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List entries, CancellationToken cancellationToken) diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 95fdacb7..af58d360 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -112,7 +111,6 @@ public partial class DownloadOptions } } - private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) { long chapterStartMs @@ -126,13 +124,6 @@ public partial class DownloadOptions RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs), }; - if (TryGetAudioInfo(licInfo.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(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat) @@ -159,43 +150,6 @@ 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() @@ -350,12 +304,4 @@ public partial class DownloadOptions chapters.Remove(chapters[^1]); } } - - 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/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 514ee2bd..29e7b286 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -82,7 +82,6 @@ namespace FileLiberator // no null/empty check for key/iv. unencrypted files do not have them LibraryBookDto = LibraryBook.ToDto(); - LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec; cancellation = config diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 4a2f6de5..4459f09a 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -61,7 +61,13 @@ namespace FileLiberator IsPodcastParent = libraryBook.Book.IsEpisodeParent(), IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), - Language = libraryBook.Book.Language + Language = libraryBook.Book.Language, + Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, + BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate, + SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate, + Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount, + LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString(3), + FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion }; } diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index dc32fa9c..1c689845 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -34,6 +34,8 @@ public class BookDto public DateTime FileDate { get; set; } = DateTime.Now; public DateTime? DatePublished { get; set; } public string? Language { get; set; } + public string? LibationVersion { get; set; } + public string? FileVersion { get; set; } } public class LibraryBookDto : BookDto diff --git a/Source/LibationFileManager/Templates/TemplateEditor[T].cs b/Source/LibationFileManager/Templates/TemplateEditor[T].cs index e718b7f4..a92b0fe6 100644 --- a/Source/LibationFileManager/Templates/TemplateEditor[T].cs +++ b/Source/LibationFileManager/Templates/TemplateEditor[T].cs @@ -69,6 +69,9 @@ namespace LibationFileManager.Templates Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], Narrators = [new("Stephen Fry", null)], Series = [new("Sherlock Holmes", 1, "B08376S3R2"), new("Some Other Series", 1, "B000000000")], + Codec = "AAC-LC", + LibationVersion = Configuration.LibationVersion?.ToString(3), + FileVersion = "36217811", BitRate = 128, SampleRate = 44100, Channels = 2, diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index e8cdb1df..9c370983 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -36,10 +36,12 @@ 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", "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 Bitrate { get; } = new TemplateTags("bitrate", "Bitrate (kbps) of the last downloaded audiobook"); + public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Sample rate (Hz) of the last downloaded audiobook"); + public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels in the last downloaded audiobook"); + public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audio codec of the last downloaded audiobook"); + public static TemplateTags FileVersion { get; } = new TemplateTags("file version", "Audible's file version number of the last downloaded audiobook"); + public static TemplateTags LibationVersion { get; } = new TemplateTags("libation version", "Libation version used during last download of the audiobook"); 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 bfba1468..ac0af0fa 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -287,6 +287,8 @@ namespace LibationFileManager.Templates { TemplateTags.SampleRate, lb => lb.SampleRate }, { TemplateTags.Channels, lb => lb.Channels }, { TemplateTags.Codec, lb => lb.Codec }, + { TemplateTags.FileVersion, lb => lb.FileVersion }, + { TemplateTags.LibationVersion, lb => lb.LibationVersion }, }; private static readonly List chapterPropertyTags = new() @@ -382,7 +384,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 { get; } = [filePropertyTags, conditionalTags, folderConditionalTags]; + public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags]; public override IEnumerable<string> Errors => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors; diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index 79284bf2..7d62c849 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -50,6 +50,7 @@ namespace LibationSearchEngine { FieldType.Bool, lb => (lb.Book.UserDefinedItem.Rating.OverallRating > 0f).ToString(), "IsRated", "Rated" }, { FieldType.Bool, lb => isAuthorNarrated(lb.Book).ToString(), "IsAuthorNarrated", "AuthorNarrated" }, { FieldType.Bool, lb => lb.Book.IsAbridged.ToString(), nameof(Book.IsAbridged), "Abridged" }, + { FieldType.Bool, lb => lb.Book.IsSpatial.ToString(), nameof(Book.IsSpatial), "Spatial" }, { FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated).ToString(), "IsLiberated", "Liberated" }, { FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Error).ToString(), "LiberatedError" }, { FieldType.Bool, lb => lb.Book.IsEpisodeChild().ToString(), "Podcast", "Podcasts", "IsPodcast", "Episode", "Episodes", "IsEpisode" }, diff --git a/Source/LibationUiBase/GridView/LastDownloadStatus.cs b/Source/LibationUiBase/GridView/LastDownloadStatus.cs index 4eda37bf..87972d42 100644 --- a/Source/LibationUiBase/GridView/LastDownloadStatus.cs +++ b/Source/LibationUiBase/GridView/LastDownloadStatus.cs @@ -6,6 +6,8 @@ namespace LibationUiBase.GridView public class LastDownloadStatus : IComparable { public bool IsValid => LastDownloadedVersion is not null && LastDownloaded.HasValue; + public AudioFormat LastDownloadedFormat { get; } + public string LastDownloadedFileVersion { get; } public Version LastDownloadedVersion { get; } public DateTime? LastDownloaded { get; } public string ToolTipText => IsValid ? $"Double click to open v{LastDownloadedVersion.ToString(3)} release notes" : ""; @@ -14,6 +16,8 @@ namespace LibationUiBase.GridView public LastDownloadStatus(UserDefinedItem udi) { LastDownloadedVersion = udi.LastDownloadedVersion; + LastDownloadedFormat = udi.LastDownloadedFormat; + LastDownloadedFileVersion = udi.LastDownloadedFileVersion; LastDownloaded = udi.LastDownloaded; } @@ -24,7 +28,12 @@ namespace LibationUiBase.GridView } public override string ToString() - => IsValid ? $"{dateString()}\n\nLibation v{LastDownloadedVersion.ToString(3)}" : ""; + => IsValid ? $""" + {dateString()} (File v.{LastDownloadedFileVersion}) + {LastDownloadedFormat} + Libation v{LastDownloadedVersion.ToString(3)} + """ : ""; + //Call ToShortDateString to use current culture's date format. private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}";