Add audio format data

- Add Book.IsSpatial property and add it to search index
- Read audio format of actual output files and store it in UserDefinedItem. Now works with MP3s.
- Store last downloaded audio file version
- Add IsSpatial, file version, and Audio Format to library exports and to template tags. Updated docs.
- Add last downloaded audio file version and format info to the Last Downloaded tab
- Migrated the DB
- Update AAXClean with some bug fixes
  - Fixed error converting xHE-AAC audio files to mp3 when splitting by chapter (or trimming the audible branding from the beginning of the file)
  - Improve mp3 ID# tags support. Chapter titles are now preserved.
  - Add support for reading EC-3 and AC-4 audio format metadata
This commit is contained in:
MBucari 2025-07-25 03:38:37 -06:00
parent a62a9ffc5b
commit 9b217a4e18
25 changed files with 1046 additions and 156 deletions

View File

@ -46,9 +46,12 @@ These tags will be replaced in the template with the audiobook's values.
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|\<first series\>|First series|[Series](#series-formatters)|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|\<channels\>|Number of audio channels|[Number](#number-formatters)|
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)|
|\<channels\>|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)|
|\<codec\>|Audio codec of the last downloaded audiobook|[Text](#text-formatters)|
|\<file version\>|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)|
|\<libation version\>|Libation version used during last download of the audiobook|[Text](#text-formatters)|
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|\<locale\>|Region/country|[Text](#text-formatters)|

View File

@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="2.0.1.3" />
<PackageReference Include="AAXClean.Codecs" Version="2.0.2.1" />
</ItemGroup>
<ItemGroup>

View File

@ -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);

View File

@ -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<ExportDto> ToDtos(this IEnumerable<LibraryBook> 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);
}
}

View File

@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.1.1" />
<PackageReference Include="AudibleApi" Version="9.4.2.1" />
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
</ItemGroup>

View File

@ -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)";
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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; }
/// <summary>
/// Date the audio file was last downloaded.
/// </summary>
public DateTime? LastDownloaded { get; private set; }
/// <summary>
/// Version of Libation used the last time the audio file was downloaded.
/// </summary>
public Version LastDownloadedVersion { get; private set; }
/// <summary>
/// Audio format of the last downloaded audio file.
/// </summary>
public AudioFormat LastDownloadedFormat { get; private set; }
/// <summary>
/// Version of the audio file that was last downloaded.
/// </summary>
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() { }

View File

@ -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<LibationContext> 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));
}
}

View File

@ -0,0 +1,474 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<bool>("IsSpatial")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("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<int>("BookId")
.HasColumnType("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("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<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<bool>("IsFinished")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("TEXT");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("INTEGER");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("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
}
}
}

View File

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddAudioFormatData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "_audioFormat",
table: "Books",
newName: "IsSpatial");
migrationBuilder.AddColumn<string>(
name: "LastDownloadedFileVersion",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "LastDownloadedFormat",
table: "UserDefinedItem",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
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");
}
}
}

View File

@ -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<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<bool>("IsSpatial")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
@ -74,9 +77,6 @@ namespace DataLayer.Migrations
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
@ -318,6 +318,12 @@ namespace DataLayer.Migrations
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("TEXT");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("INTEGER");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");

View File

@ -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),

View File

@ -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;
/// <summary> Read audio codec, bitrate, sample rate, and channel count from MP4 and MP3 audio files. </summary>
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<Version, ushort[]> 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<Version, short[]> 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
}

View File

@ -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
/// <summary>Read the audio format from the audio file's metadata.</summary>
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);
}
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List<TempFile> entries, CancellationToken cancellationToken)

View File

@ -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;
}
/// <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)
{
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();
}

View File

@ -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

View File

@ -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
};
}

View File

@ -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

View File

@ -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,

View File

@ -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 <first series[{#}]>");
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");

View File

@ -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<TagCollection> 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; } = "<title short> [<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;

View File

@ -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" },

View File

@ -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}";