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:
parent
a62a9ffc5b
commit
9b217a4e18
@ -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)|
|
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|
||||||
|\<first series\>|First series|[Series](#series-formatters)|
|
|\<first series\>|First series|[Series](#series-formatters)|
|
||||||
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
||||||
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||||
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||||
|\<channels\>|Number of audio channels|[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\>|Audible account of this book|[Text](#text-formatters)|
|
||||||
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
||||||
|\<locale\>|Region/country|[Text](#text-formatters)|
|
|\<locale\>|Region/country|[Text](#text-formatters)|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AAXClean.Codecs" Version="2.0.1.3" />
|
<PackageReference Include="AAXClean.Codecs" Version="2.0.2.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -521,8 +521,8 @@ namespace ApplicationServices
|
|||||||
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||||
});
|
});
|
||||||
|
|
||||||
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version 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); });
|
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
|
||||||
|
|
||||||
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
|||||||
@ -4,8 +4,8 @@ using System.Linq;
|
|||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration.Attributes;
|
using CsvHelper.Configuration.Attributes;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using NPOI.XSSF.UserModel;
|
using NPOI.XSSF.UserModel;
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ApplicationServices
|
namespace ApplicationServices
|
||||||
{
|
{
|
||||||
@ -115,7 +115,29 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
[Name("IsFinished")]
|
[Name("IsFinished")]
|
||||||
public bool IsFinished { get; set; }
|
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 class LibToDtos
|
||||||
{
|
{
|
||||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||||
@ -135,16 +157,16 @@ namespace ApplicationServices
|
|||||||
HasPdf = a.Book.HasPdf(),
|
HasPdf = a.Book.HasPdf(),
|
||||||
SeriesNames = a.Book.SeriesNames(),
|
SeriesNames = a.Book.SeriesNames(),
|
||||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
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,
|
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
|
||||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(),
|
||||||
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(),
|
||||||
PictureId = a.Book.PictureId,
|
PictureId = a.Book.PictureId,
|
||||||
IsAbridged = a.Book.IsAbridged,
|
IsAbridged = a.Book.IsAbridged,
|
||||||
DatePublished = a.Book.DatePublished,
|
DatePublished = a.Book.DatePublished,
|
||||||
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
|
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
|
||||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(),
|
||||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(),
|
||||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(),
|
||||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||||
@ -152,8 +174,13 @@ namespace ApplicationServices
|
|||||||
Language = a.Book.Language,
|
Language = a.Book.Language,
|
||||||
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||||
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||||
IsFinished = a.Book.UserDefinedItem.IsFinished
|
IsFinished = a.Book.UserDefinedItem.IsFinished,
|
||||||
|
IsSpatial = a.Book.IsSpatial,
|
||||||
|
LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "",
|
||||||
|
LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
private static float? ZeroIsNull(this float value) => value is 0 ? null : value;
|
||||||
}
|
}
|
||||||
public static class LibraryExporter
|
public static class LibraryExporter
|
||||||
{
|
{
|
||||||
@ -162,7 +189,6 @@ namespace ApplicationServices
|
|||||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||||
if (!dtos.Any())
|
if (!dtos.Any())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
using var writer = new System.IO.StreamWriter(saveFilePath);
|
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||||
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
@ -174,7 +200,7 @@ namespace ApplicationServices
|
|||||||
public static void ToJson(string saveFilePath)
|
public static void ToJson(string saveFilePath)
|
||||||
{
|
{
|
||||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
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);
|
System.IO.File.WriteAllText(saveFilePath, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,7 +253,13 @@ namespace ApplicationServices
|
|||||||
nameof(ExportDto.Language),
|
nameof(ExportDto.Language),
|
||||||
nameof(ExportDto.LastDownloaded),
|
nameof(ExportDto.LastDownloaded),
|
||||||
nameof(ExportDto.LastDownloadedVersion),
|
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;
|
var col = 0;
|
||||||
foreach (var c in columns)
|
foreach (var c in columns)
|
||||||
@ -248,15 +280,10 @@ namespace ApplicationServices
|
|||||||
foreach (var dto in dtos)
|
foreach (var dto in dtos)
|
||||||
{
|
{
|
||||||
col = 0;
|
col = 0;
|
||||||
|
row = sheet.CreateRow(rowIndex++);
|
||||||
row = sheet.CreateRow(rowIndex);
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle;
|
||||||
var dateCell = row.CreateCell(col++);
|
|
||||||
dateCell.CellStyle = dateStyle;
|
|
||||||
dateCell.SetCellValue(dto.DateAdded);
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||||
@ -269,56 +296,46 @@ namespace ApplicationServices
|
|||||||
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
||||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall);
|
||||||
col = createCell(row, col, dto.CommunityRatingOverall);
|
row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance);
|
||||||
col = createCell(row, col, dto.CommunityRatingPerformance);
|
row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory);
|
||||||
col = createCell(row, col, dto.CommunityRatingStory);
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
||||||
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle;
|
||||||
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.CategoriesNames);
|
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.MyRatingOverall);
|
||||||
col = createCell(row, col, dto.MyRatingOverall);
|
row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance);
|
||||||
col = createCell(row, col, dto.MyRatingPerformance);
|
row.CreateCell(col++).SetCellValue(dto.MyRatingStory);
|
||||||
col = createCell(row, col, dto.MyRatingStory);
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle;
|
||||||
if (dto.LastDownloaded.HasValue)
|
row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion);
|
||||||
{
|
row.CreateCell(col++).SetCellValue(dto.IsFinished);
|
||||||
dateCell = row.CreateCell(col);
|
row.CreateCell(col++).SetCellValue(dto.IsSpatial);
|
||||||
dateCell.CellStyle = dateStyle;
|
row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion);
|
||||||
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
row.CreateCell(col++).SetCellValue(dto.CodecString);
|
||||||
}
|
row.CreateCell(col++).SetCellValue(dto.SampleRate);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.ChannelCount);
|
||||||
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
row.CreateCell(col++).SetCellValue(dto.BitRate);
|
||||||
row.CreateCell(++col).SetCellValue(dto.IsFinished);
|
|
||||||
|
|
||||||
rowIndex++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||||
workbook.Write(fileData);
|
workbook.Write(fileData);
|
||||||
}
|
}
|
||||||
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
|
|
||||||
{
|
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate)
|
||||||
if (nullableFloat.HasValue)
|
=> nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value)
|
||||||
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
|
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
|
||||||
else
|
|
||||||
row.CreateCell(col++).SetCellValue("");
|
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt)
|
||||||
return col;
|
=> 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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" />
|
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
70
Source/DataLayer/AudioFormat.cs
Normal file
70
Source/DataLayer/AudioFormat.cs
Normal 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)";
|
||||||
|
}
|
||||||
@ -13,7 +13,6 @@ namespace DataLayer.Configurations
|
|||||||
|
|
||||||
entity.OwnsOne(b => b.Rating);
|
entity.OwnsOne(b => b.Rating);
|
||||||
|
|
||||||
entity.Property(nameof(Book._audioFormat));
|
|
||||||
//
|
//
|
||||||
// CRUCIAL: ignore unmapped collections, even get-only
|
// CRUCIAL: ignore unmapped collections, even get-only
|
||||||
//
|
//
|
||||||
@ -50,6 +49,11 @@ namespace DataLayer.Configurations
|
|||||||
b_udi
|
b_udi
|
||||||
.Property(udi => udi.LastDownloadedVersion)
|
.Property(udi => udi.LastDownloadedVersion)
|
||||||
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
.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
|
// owns it 1:1, store in same table
|
||||||
b_udi.OwnsOne(udi => udi.Rating);
|
b_udi.OwnsOne(udi => udi.Rating);
|
||||||
|
|||||||
@ -43,18 +43,13 @@ namespace DataLayer
|
|||||||
public ContentType ContentType { get; private set; }
|
public ContentType ContentType { get; private set; }
|
||||||
public string Locale { 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
|
// mutable
|
||||||
public string PictureId { get; set; }
|
public string PictureId { get; set; }
|
||||||
public string PictureLarge { get; set; }
|
public string PictureLarge { get; set; }
|
||||||
|
|
||||||
// book details
|
// book details
|
||||||
public bool IsAbridged { get; private set; }
|
public bool IsAbridged { get; private set; }
|
||||||
|
public bool IsSpatial { get; private set; }
|
||||||
public DateTime? DatePublished { get; private set; }
|
public DateTime? DatePublished { get; private set; }
|
||||||
public string Language { get; private set; }
|
public string Language { get; private set; }
|
||||||
|
|
||||||
@ -242,10 +237,11 @@ namespace DataLayer
|
|||||||
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
||||||
=> Rating.Update(overallRating, performanceRating, 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
|
// don't overwrite with default values
|
||||||
IsAbridged |= isAbridged;
|
IsAbridged |= isAbridged;
|
||||||
|
IsSpatial |= isSpatial ?? false;
|
||||||
DatePublished = datePublished ?? DatePublished;
|
DatePublished = datePublished ?? DatePublished;
|
||||||
Language = language?.FirstCharToUpper() ?? Language;
|
Language = language?.FirstCharToUpper() ?? Language;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,19 +24,47 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
internal int BookId { get; private set; }
|
internal int BookId { get; private set; }
|
||||||
public Book Book { get; private set; }
|
public Book Book { get; private set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Date the audio file was last downloaded.
|
||||||
|
/// </summary>
|
||||||
public DateTime? LastDownloaded { get; private set; }
|
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; }
|
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));
|
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;
|
LastDownloaded = null;
|
||||||
|
LastDownloadedFormat = null;
|
||||||
|
LastDownloadedFileVersion = null;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LastDownloaded = DateTime.Now;
|
LastDownloaded = DateTime.Now;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using Dinah.EntityFrameworkCore;
|
using Dinah.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
@ -7,6 +8,7 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
||||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
474
Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs
generated
Normal file
474
Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
|
||||||
|
|
||||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
{
|
{
|
||||||
@ -53,6 +53,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<bool>("IsAbridged")
|
b.Property<bool>("IsAbridged")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSpatial")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@ -74,9 +77,6 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<long>("_audioFormat")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.HasKey("BookId");
|
b.HasKey("BookId");
|
||||||
|
|
||||||
b.HasIndex("AudibleProductId");
|
b.HasIndex("AudibleProductId");
|
||||||
@ -318,6 +318,12 @@ namespace DataLayer.Migrations
|
|||||||
b1.Property<DateTime?>("LastDownloaded")
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedFileVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<long?>("LastDownloadedFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b1.Property<string>("LastDownloadedVersion")
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@ -137,8 +137,6 @@ namespace DtoImporterService
|
|||||||
book.ReplacePublisher(publisher);
|
book.ReplacePublisher(publisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
|
||||||
|
|
||||||
if (item.PdfUrl is not null)
|
if (item.PdfUrl is not null)
|
||||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||||
|
|
||||||
@ -166,8 +164,9 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
// 2023-02-01
|
// 2023-02-01
|
||||||
// updateBook must update language on books which were imported before the migration which added language.
|
// updateBook must update language on books which were imported before the migration which added language.
|
||||||
// Can eventually delete this
|
// 2025-07-30
|
||||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
// 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(
|
book.UpdateProductRating(
|
||||||
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||||
|
|||||||
242
Source/FileLiberator/AudioFormatDecoder.cs
Normal file
242
Source/FileLiberator/AudioFormatDecoder.cs
Normal 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
|
||||||
|
}
|
||||||
@ -47,7 +47,7 @@ namespace FileLiberator
|
|||||||
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken);
|
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken);
|
||||||
var result = await DownloadAudiobookAsync(api, downloadOptions, 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.
|
// decrypt failed. Delete all output entries but leave the cache files.
|
||||||
result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath));
|
result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath));
|
||||||
@ -61,6 +61,12 @@ namespace FileLiberator
|
|||||||
result.ResultFiles.AddRange(getAaxcFiles(result.CacheFiles));
|
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);
|
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||||
|
|
||||||
//post-download tasks done in parallel.
|
//post-download tasks done in parallel.
|
||||||
@ -80,14 +86,14 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
catch when (!moveFilesTask.IsFaulted)
|
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
|
//Only fail if the downloaded audio files failed to move to Books directory
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion!);
|
libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
|
||||||
SetDirectoryTime(libraryBook, finalStorageDir);
|
SetDirectoryTime(libraryBook, finalStorageDir);
|
||||||
foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath)))
|
foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath)))
|
||||||
{
|
{
|
||||||
@ -275,6 +281,31 @@ namespace FileLiberator
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Post-success routines
|
#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>
|
/// <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>
|
/// <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)
|
private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List<TempFile> entries, CancellationToken cancellationToken)
|
||||||
|
|||||||
@ -10,7 +10,6 @@ using System.Collections.Generic;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@ -112,7 +111,6 @@ public partial class DownloadOptions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
||||||
{
|
{
|
||||||
long chapterStartMs
|
long chapterStartMs
|
||||||
@ -126,13 +124,6 @@ public partial class DownloadOptions
|
|||||||
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
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 titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||||
var chapters
|
var chapters
|
||||||
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||||
@ -159,43 +150,6 @@ public partial class DownloadOptions
|
|||||||
return dlOptions;
|
return dlOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The most reliable way to get these audio file properties is from the filename itself.
|
|
||||||
/// Using AAXClean to read the metadata works well for everything except AC-4 bitrate.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels)
|
|
||||||
{
|
|
||||||
bitrate = sampleRate = channels = null;
|
|
||||||
|
|
||||||
if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var file = Path.GetFileName(uri.LocalPath);
|
|
||||||
|
|
||||||
var match = AdrmAudioProperties().Match(file);
|
|
||||||
if (match.Success)
|
|
||||||
{
|
|
||||||
bitrate = int.Parse(match.Groups[1].Value);
|
|
||||||
sampleRate = int.Parse(match.Groups[2].Value);
|
|
||||||
channels = int.Parse(match.Groups[3].Value);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else if ((match = WidevineAudioProperties().Match(file)).Success)
|
|
||||||
{
|
|
||||||
bitrate = int.Parse(match.Groups[2].Value);
|
|
||||||
sampleRate = int.Parse(match.Groups[1].Value) * 1000;
|
|
||||||
channels = match.Groups[3].Value switch
|
|
||||||
{
|
|
||||||
"ec3" => 6,
|
|
||||||
"ac4" => 3,
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static LameConfig GetLameOptions(Configuration config)
|
public static LameConfig GetLameOptions(Configuration config)
|
||||||
{
|
{
|
||||||
LameConfig lameConfig = new()
|
LameConfig lameConfig = new()
|
||||||
@ -350,12 +304,4 @@ public partial class DownloadOptions
|
|||||||
chapters.Remove(chapters[^1]);
|
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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,7 +82,6 @@ namespace FileLiberator
|
|||||||
|
|
||||||
// no null/empty check for key/iv. unencrypted files do not have them
|
// no null/empty check for key/iv. unencrypted files do not have them
|
||||||
LibraryBookDto = LibraryBook.ToDto();
|
LibraryBookDto = LibraryBook.ToDto();
|
||||||
LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec;
|
|
||||||
|
|
||||||
cancellation =
|
cancellation =
|
||||||
config
|
config
|
||||||
|
|||||||
@ -61,7 +61,13 @@ namespace FileLiberator
|
|||||||
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||||
IsPodcast = libraryBook.Book.IsEpisodeChild() || 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,8 @@ public class BookDto
|
|||||||
public DateTime FileDate { get; set; } = DateTime.Now;
|
public DateTime FileDate { get; set; } = DateTime.Now;
|
||||||
public DateTime? DatePublished { get; set; }
|
public DateTime? DatePublished { get; set; }
|
||||||
public string? Language { get; set; }
|
public string? Language { get; set; }
|
||||||
|
public string? LibationVersion { get; set; }
|
||||||
|
public string? FileVersion { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LibraryBookDto : BookDto
|
public class LibraryBookDto : BookDto
|
||||||
|
|||||||
@ -69,6 +69,9 @@ namespace LibationFileManager.Templates
|
|||||||
Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")],
|
Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")],
|
||||||
Narrators = [new("Stephen Fry", null)],
|
Narrators = [new("Stephen Fry", null)],
|
||||||
Series = [new("Sherlock Holmes", 1, "B08376S3R2"), new("Some Other Series", 1, "B000000000")],
|
Series = [new("Sherlock Holmes", 1, "B08376S3R2"), new("Some Other Series", 1, "B000000000")],
|
||||||
|
Codec = "AAC-LC",
|
||||||
|
LibationVersion = Configuration.LibationVersion?.ToString(3),
|
||||||
|
FileVersion = "36217811",
|
||||||
BitRate = 128,
|
BitRate = 128,
|
||||||
SampleRate = 44100,
|
SampleRate = 44100,
|
||||||
Channels = 2,
|
Channels = 2,
|
||||||
|
|||||||
@ -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 Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)");
|
||||||
public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series");
|
public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series");
|
||||||
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for <first series[{#}]>");
|
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for <first series[{#}]>");
|
||||||
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Audiobook's source bitrate");
|
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Bitrate (kbps) of the last downloaded audiobook");
|
||||||
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Audiobook's source sample rate");
|
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Sample rate (Hz) of the last downloaded audiobook");
|
||||||
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Audiobook's source audio channel count");
|
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", "Audiobook's source codec");
|
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 Account { get; } = new TemplateTags("account", "Audible account of this book");
|
||||||
public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book");
|
public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book");
|
||||||
public static TemplateTags Locale { get; } = new("locale", "Region/country");
|
public static TemplateTags Locale { get; } = new("locale", "Region/country");
|
||||||
|
|||||||
@ -287,6 +287,8 @@ namespace LibationFileManager.Templates
|
|||||||
{ TemplateTags.SampleRate, lb => lb.SampleRate },
|
{ TemplateTags.SampleRate, lb => lb.SampleRate },
|
||||||
{ TemplateTags.Channels, lb => lb.Channels },
|
{ TemplateTags.Channels, lb => lb.Channels },
|
||||||
{ TemplateTags.Codec, lb => lb.Codec },
|
{ TemplateTags.Codec, lb => lb.Codec },
|
||||||
|
{ TemplateTags.FileVersion, lb => lb.FileVersion },
|
||||||
|
{ TemplateTags.LibationVersion, lb => lb.LibationVersion },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly List<TagCollection> chapterPropertyTags = new()
|
private static readonly List<TagCollection> chapterPropertyTags = new()
|
||||||
@ -382,7 +384,7 @@ namespace LibationFileManager.Templates
|
|||||||
public static string Name { get; } = "Folder Template";
|
public static string Name { get; } = "Folder Template";
|
||||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
|
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
|
||||||
public static string DefaultTemplate { get; } = "<title short> [<id>]";
|
public static string DefaultTemplate { get; } = "<title short> [<id>]";
|
||||||
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, conditionalTags, folderConditionalTags];
|
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags];
|
||||||
|
|
||||||
public override IEnumerable<string> Errors
|
public override IEnumerable<string> Errors
|
||||||
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
|
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
|
||||||
|
|||||||
@ -50,6 +50,7 @@ namespace LibationSearchEngine
|
|||||||
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.Rating.OverallRating > 0f).ToString(), "IsRated", "Rated" },
|
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.Rating.OverallRating > 0f).ToString(), "IsRated", "Rated" },
|
||||||
{ FieldType.Bool, lb => isAuthorNarrated(lb.Book).ToString(), "IsAuthorNarrated", "AuthorNarrated" },
|
{ 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.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.Liberated).ToString(), "IsLiberated", "Liberated" },
|
||||||
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Error).ToString(), "LiberatedError" },
|
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Error).ToString(), "LiberatedError" },
|
||||||
{ FieldType.Bool, lb => lb.Book.IsEpisodeChild().ToString(), "Podcast", "Podcasts", "IsPodcast", "Episode", "Episodes", "IsEpisode" },
|
{ FieldType.Bool, lb => lb.Book.IsEpisodeChild().ToString(), "Podcast", "Podcasts", "IsPodcast", "Episode", "Episodes", "IsEpisode" },
|
||||||
|
|||||||
@ -6,6 +6,8 @@ namespace LibationUiBase.GridView
|
|||||||
public class LastDownloadStatus : IComparable
|
public class LastDownloadStatus : IComparable
|
||||||
{
|
{
|
||||||
public bool IsValid => LastDownloadedVersion is not null && LastDownloaded.HasValue;
|
public bool IsValid => LastDownloadedVersion is not null && LastDownloaded.HasValue;
|
||||||
|
public AudioFormat LastDownloadedFormat { get; }
|
||||||
|
public string LastDownloadedFileVersion { get; }
|
||||||
public Version LastDownloadedVersion { get; }
|
public Version LastDownloadedVersion { get; }
|
||||||
public DateTime? LastDownloaded { get; }
|
public DateTime? LastDownloaded { get; }
|
||||||
public string ToolTipText => IsValid ? $"Double click to open v{LastDownloadedVersion.ToString(3)} release notes" : "";
|
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)
|
public LastDownloadStatus(UserDefinedItem udi)
|
||||||
{
|
{
|
||||||
LastDownloadedVersion = udi.LastDownloadedVersion;
|
LastDownloadedVersion = udi.LastDownloadedVersion;
|
||||||
|
LastDownloadedFormat = udi.LastDownloadedFormat;
|
||||||
|
LastDownloadedFileVersion = udi.LastDownloadedFileVersion;
|
||||||
LastDownloaded = udi.LastDownloaded;
|
LastDownloaded = udi.LastDownloaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,7 +28,12 @@ namespace LibationUiBase.GridView
|
|||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
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.
|
//Call ToShortDateString to use current culture's date format.
|
||||||
private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}";
|
private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user