diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index 88a92a46..17054ea7 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -12,11 +12,13 @@ namespace DataLayer.Configurations entity.OwnsOne(b => b.Rating); + entity.Property(nameof(Book._audioFormat)); // // CRUCIAL: ignore unmapped collections, even get-only // entity.Ignore(nameof(Book.Authors)); entity.Ignore(nameof(Book.Narrators)); + entity.Ignore(nameof(Book.AudioFormat)); //// these don't seem to matter //entity.Ignore(nameof(Book.AuthorNames)); //entity.Ignore(nameof(Book.NarratorNames)); diff --git a/Source/DataLayer/EfClasses/AudioFormat.cs b/Source/DataLayer/EfClasses/AudioFormat.cs new file mode 100644 index 00000000..2d2f9d19 --- /dev/null +++ b/Source/DataLayer/EfClasses/AudioFormat.cs @@ -0,0 +1,62 @@ +using System; + +namespace DataLayer +{ + internal enum AudioFormatEnum : long + { + //Defining the enum this way ensures that when comparing: + //LC_128_44100_stereo > LC_64_44100_stereo > LC_64_22050_stereo > LC_64_22050_stereo + //This matches how audible interprets these codecs when specifying quality using AudibleApi.DownloadQuality + //I've never seen mono formats. + Unknown = 0, + LC_32_22050_stereo = (32L << 18) | (22050 << 2) | 2, + LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2, + LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2, + LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2, + } + + public class AudioFormat : IComparable, IComparable + { + + internal int AudioFormatID { get; private set; } + public int Bitrate { get; private init; } + public int SampleRate { get; private init; } + public int Channels { get; private init; } + public bool IsValid => Bitrate != 0 && SampleRate != 0 && Channels != 0; + + public static AudioFormat FromString(string formatStr) + { + if (Enum.TryParse(formatStr, ignoreCase: true, out AudioFormatEnum enumVal)) + return FromEnum(enumVal); + return FromEnum(AudioFormatEnum.Unknown); + } + + internal static AudioFormat FromEnum(AudioFormatEnum enumVal) + { + var val = (long)enumVal; + + return new() + { + Bitrate = (int)(val >> 18), + SampleRate = (int)(val >> 2) & ushort.MaxValue, + Channels = (int)(val & 3) + }; + } + internal AudioFormatEnum ToEnum() + { + var val = (AudioFormatEnum)(((long)Bitrate << 18) | ((long)SampleRate << 2) | (long)Channels); + + return Enum.IsDefined(val) ? + val : AudioFormatEnum.Unknown; + } + + public override string ToString() + => IsValid ? + $"{Bitrate} Kbps, {SampleRate / 1000d:F1} kHz, {(Channels == 2 ? "Stereo" : Channels)}" : + "Unknown"; + + public int CompareTo(AudioFormat other) => ToEnum().CompareTo(other.ToEnum()); + + public int CompareTo(object obj) => CompareTo(obj as AudioFormat); + } +} diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index ef072484..22007e13 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -25,6 +25,7 @@ namespace DataLayer Parent = 4, } + public class Book { // implementation detail. set by db only. only used by data layer @@ -38,6 +39,10 @@ namespace DataLayer public ContentType ContentType { get; private set; } public string Locale { get; private set; } + internal AudioFormatEnum _audioFormat; + + public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); } + // mutable public string PictureId { get; set; } public string PictureLarge { get; set; } diff --git a/Source/DataLayer/Migrations/20220624214932_AddAudioFormat.Designer.cs b/Source/DataLayer/Migrations/20220624214932_AddAudioFormat.Designer.cs new file mode 100644 index 00000000..fce7a0ce --- /dev/null +++ b/Source/DataLayer/Migrations/20220624214932_AddAudioFormat.Designer.cs @@ -0,0 +1,397 @@ +// +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("20220624214932_AddAudioFormat")] + partial class AddAudioFormat + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.6"); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .HasColumnType("INTEGER"); + + b.Property("DatePublished") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAbridged") + .HasColumnType("INTEGER"); + + b.Property("LengthInMinutes") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasColumnType("TEXT"); + + b.Property("PictureId") + .HasColumnType("TEXT"); + + b.Property("PictureLarge") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("_audioFormat") + .HasColumnType("INTEGER"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + 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.Property("ParentCategoryCategoryId") + .HasColumnType("INTEGER"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + + b.HasData( + new + { + CategoryId = -1, + AudibleCategoryId = "", + Name = "" + }); + }); + + 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("Account") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + 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("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("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("Category"); + + b.Navigation("Rating"); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem"); + }); + + 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.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + + b.Navigation("ParentCategory"); + }); + + 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("ContributorsLink"); + + b.Navigation("SeriesLink"); + }); + + 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/20220624214932_AddAudioFormat.cs b/Source/DataLayer/Migrations/20220624214932_AddAudioFormat.cs new file mode 100644 index 00000000..38c7880a --- /dev/null +++ b/Source/DataLayer/Migrations/20220624214932_AddAudioFormat.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + public partial class AddAudioFormat : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "_audioFormat", + table: "Books", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "_audioFormat", + table: "Books"); + } + } +} diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index c198e3c0..5579839c 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", "6.0.4"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.6"); modelBuilder.Entity("DataLayer.Book", b => { @@ -56,6 +56,9 @@ namespace DataLayer.Migrations b.Property("Title") .HasColumnType("TEXT"); + b.Property("_audioFormat") + .HasColumnType("INTEGER"); + b.HasKey("BookId"); b.HasIndex("AudibleProductId"); diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index 67d18f4e..3e05db63 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -162,6 +162,9 @@ namespace DtoImporterService { var item = importItem.DtoItem; + var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat(); + book.AudioFormat = codec; + // set/update book-specific info which may have changed if (item.PictureId is not null) book.PictureId = item.PictureId; diff --git a/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs b/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs index fdbc7ac7..838b72b7 100644 --- a/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs +++ b/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs @@ -49,6 +49,7 @@ Title: {Book.Title} Author(s): {Book.AuthorNames()} Narrator(s): {Book.NarratorNames()} Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")} +Audio Bitrate: {Book.AudioFormat} Category: {string.Join(" > ", Book.CategoriesNames())} Purchase Date: {_libraryBook.DateAdded.ToString("d")} ".Trim();