diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md index 8f5d3e2e..184570ea 100644 --- a/Documentation/NamingTemplates.md +++ b/Documentation/NamingTemplates.md @@ -46,9 +46,12 @@ These tags will be replaced in the template with the audiobook's values. |\|All series to which the book belongs (if any)|[Series List](#series-list-formatters)| |\|First series|[Series](#series-formatters)| |\|Number order in series (alias for \|[Number](#number-formatters)| -|\|File's original bitrate (Kbps)|[Number](#number-formatters)| -|\|File's original audio sample rate|[Number](#number-formatters)| -|\|Number of audio channels|[Number](#number-formatters)| +|\|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)| +|\|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)| +|\|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)| +|\|Audio codec of the last downloaded audiobook|[Text](#text-formatters)| +|\|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)| +|\|Libation version used during last download of the audiobook|[Text](#text-formatters)| |\|Audible account of this book|[Text](#text-formatters)| |\|Audible account nickname of this book|[Text](#text-formatters)| |\|Region/country|[Text](#text-formatters)| diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index d585820a..4ad8889d 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -13,7 +13,7 @@ - + diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs index fb33474f..f4ae4134 100644 --- a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs +++ b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs @@ -1,19 +1,21 @@ using AAXClean; using System; +using System.IO; using System.Linq; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase { - public event EventHandler RetrievedMetadata; + public event EventHandler? RetrievedMetadata; - public Mp4File AaxFile { get; private set; } - protected Mp4Operation AaxConversion { get; set; } + public Mp4File? AaxFile { get; private set; } + protected Mp4Operation? AaxConversion { get; set; } - protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) - : base(outFileName, cacheDirectory, dlOptions) { } + protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { } /// Setting cover art by this method will insert the art into the audiobook metadata public override void SetCoverArt(byte[] coverArt) @@ -31,11 +33,13 @@ namespace AaxDecrypter private Mp4File Open() { - if (DownloadOptions.InputType is FileType.Dash) + if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0) + throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file."); + else if (DownloadOptions.InputType is FileType.Dash) { //We may have multiple keys , so use the key whose key ID matches //the dash files default Key ID. - var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray(); + var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray(); var dash = new DashFile(InputFileStream); var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID); @@ -43,26 +47,38 @@ namespace AaxDecrypter if (kidIndex == -1) throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}"); - DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex]; - var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1; - var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2; - + keys[0] = keys[kidIndex]; + var keyId = keys[kidIndex].KeyPart1; + var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2)."); dash.SetDecryptionKey(keyId, key); + WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}"); return dash; } else if (DownloadOptions.InputType is FileType.Aax) { var aax = new AaxFile(InputFileStream); - aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1); + var key = keys[0].KeyPart1; + aax.SetDecryptionKey(keys[0].KeyPart1); + WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}"); return aax; } else if (DownloadOptions.InputType is FileType.Aaxc) { var aax = new AaxFile(InputFileStream); - aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2); + var key = keys[0].KeyPart1; + var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2)."); + aax.SetDecryptionKey(keys[0].KeyPart1, iv); + WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}"); return aax; } else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown."); + + void WriteKeyFile(string contents) + { + var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key")); + File.WriteAllText(keyFile, contents + Environment.NewLine); + OnTempFileCreated(new(keyFile)); + } } protected bool Step_GetMetadata() diff --git a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs index 1948593e..bc21b0ea 100644 --- a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs @@ -5,20 +5,20 @@ using System; using System.IO; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase { private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3); - private FileStream workingFileStream; + private FileStream? workingFileStream; - public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) - : base(outFileName, cacheDirectory, dlOptions) + public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split"; AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata); AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; - AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync; } protected override void OnInitialized() @@ -59,6 +59,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea */ protected async override Task Step_DownloadAndDecryptAudiobookAsync() { + if (AaxFile is null) return false; var chapters = DownloadOptions.ChapterInfo.Chapters; // Ensure split files are at least minChapterLength in duration. @@ -83,10 +84,10 @@ That naming may not be desirable for everyone, but it's an easy change to instea try { - await (AaxConversion = decryptMultiAsync(splitChapters)); + await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters)); if (AaxConversion.IsCompletedSuccessfully) - await moveMoovToBeginning(workingFileStream?.Name); + await moveMoovToBeginning(AaxFile, workingFileStream?.Name); return AaxConversion.IsCompletedSuccessfully; } @@ -97,17 +98,17 @@ That naming may not be desirable for everyone, but it's an easy change to instea } } - private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters) + private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters) { var chapterCount = 0; return DownloadOptions.OutputFormat == OutputFormat.M4b - ? AaxFile.ConvertToMultiMp4aAsync + ? aaxFile.ConvertToMultiMp4aAsync ( splitChapters, newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback) ) - : AaxFile.ConvertToMultiMp3Async + : aaxFile.ConvertToMultiMp3Async ( splitChapters, newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback), @@ -116,33 +117,32 @@ That naming may not be desirable for everyone, but it's an easy change to instea void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback) { + moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult(); + var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); MultiConvertFileProperties props = new() { - OutputFileName = OutputFileName, + OutputFileName = newTempFile.FilePath, PartsPosition = currentChapter, PartsTotal = splitChapters.Count, - Title = newSplitCallback?.Chapter?.Title, + Title = newSplitCallback.Chapter?.Title, }; - moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult(); - newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props); newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props); newSplitCallback.TrackNumber = currentChapter; newSplitCallback.TrackCount = splitChapters.Count; - OnFileCreated(workingFileStream.Name); + OnTempFileCreated(newTempFile with { PartProperties = props }); } FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties) { - var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties); - FileUtility.SaferDelete(fileName); - return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); + FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName); + return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); } } - private Mp4Operation moveMoovToBeginning(string filename) + private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename) { if (DownloadOptions.OutputFormat is OutputFormat.M4b && DownloadOptions.MoveMoovToBeginning @@ -151,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea { return Mp4File.RelocateMoovAsync(filename); } - else return Mp4Operation.FromCompleted(AaxFile); + else return Mp4Operation.FromCompleted(aaxFile); } } } diff --git a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs index 153a6c8d..aa957746 100644 --- a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs @@ -6,13 +6,16 @@ using System; using System.IO; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase { private readonly AverageSpeed averageSpeed = new(); - public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) - : base(outFileName, cacheDirectory, dlOptions) + private TempFile? outputTempFile; + + public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { var step = 1; @@ -21,7 +24,6 @@ namespace AaxDecrypter AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b) AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov; - AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync; AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync; } @@ -39,14 +41,16 @@ namespace AaxDecrypter protected async override Task Step_DownloadAndDecryptAudiobookAsync() { - FileUtility.SaferDelete(OutputFileName); + if (AaxFile is null) return false; + outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); + FileUtility.SaferDelete(outputTempFile.FilePath); - using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); - OnFileCreated(OutputFileName); + using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite); + OnTempFileCreated(outputTempFile); try { - await (AaxConversion = decryptAsync(outputFile)); + await (AaxConversion = decryptAsync(AaxFile, outputFile)); return AaxConversion.IsCompletedSuccessfully; } @@ -58,14 +62,15 @@ namespace AaxDecrypter private async Task Step_MoveMoov() { - AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName); + if (outputTempFile is null) return false; + AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath); AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate; await AaxConversion; AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate; return AaxConversion.IsCompletedSuccessfully; } - private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e) + private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e) { averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds); @@ -84,20 +89,20 @@ namespace AaxDecrypter }); } - private Mp4Operation decryptAsync(Stream outputFile) + private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile) => DownloadOptions.OutputFormat == OutputFormat.Mp3 - ? AaxFile.ConvertToMp3Async + ? aaxFile.ConvertToMp3Async ( outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo ) : DownloadOptions.FixupFile - ? AaxFile.ConvertToMp4aAsync + ? aaxFile.ConvertToMp4aAsync ( outputFile, DownloadOptions.ChapterInfo ) - : AaxFile.ConvertToMp4aAsync(outputFile); + : aaxFile.ConvertToMp4aAsync(outputFile); } } diff --git a/Source/AaxDecrypter/AudiobookDownloadBase.cs b/Source/AaxDecrypter/AudiobookDownloadBase.cs index c5389d29..f439955e 100644 --- a/Source/AaxDecrypter/AudiobookDownloadBase.cs +++ b/Source/AaxDecrypter/AudiobookDownloadBase.cs @@ -6,55 +6,50 @@ using System; using System.IO; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { public enum OutputFormat { M4b, Mp3 } public abstract class AudiobookDownloadBase { - public event EventHandler RetrievedTitle; - public event EventHandler RetrievedAuthors; - public event EventHandler RetrievedNarrators; - public event EventHandler RetrievedCoverArt; - public event EventHandler DecryptProgressUpdate; - public event EventHandler DecryptTimeRemaining; - public event EventHandler FileCreated; + public event EventHandler? RetrievedTitle; + public event EventHandler? RetrievedAuthors; + public event EventHandler? RetrievedNarrators; + public event EventHandler? RetrievedCoverArt; + public event EventHandler? DecryptProgressUpdate; + public event EventHandler? DecryptTimeRemaining; + public event EventHandler? TempFileCreated; public bool IsCanceled { get; protected set; } protected AsyncStepSequence AsyncSteps { get; } = new(); - protected string OutputFileName { get; } + protected string OutputDirectory { get; } public IDownloadOptions DownloadOptions { get; } - protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream; + protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream; protected virtual long InputFilePosition => InputFileStream.Position; private bool downloadFinished; - private readonly NetworkFileStreamPersister nfsPersister; + private NetworkFileStreamPersister? m_nfsPersister; + private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream(); private readonly DownloadProgress zeroProgress; private readonly string jsonDownloadState; private readonly string tempFilePath; - protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) - { - OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName)); + protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + { + OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory)); + DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions)); + DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed; - var outDir = Path.GetDirectoryName(OutputFileName); - if (!Directory.Exists(outDir)) - Directory.CreateDirectory(outDir); + if (!Directory.Exists(OutputDirectory)) + Directory.CreateDirectory(OutputDirectory); if (!Directory.Exists(cacheDirectory)) Directory.CreateDirectory(cacheDirectory); - jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json"))); + jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json"); tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc"); - DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions)); - DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed; - - // delete file after validation is complete - FileUtility.SaferDelete(OutputFileName); - - nfsPersister = OpenNetworkFileStream(); - zeroProgress = new DownloadProgress { BytesReceived = 0, @@ -65,24 +60,30 @@ namespace AaxDecrypter OnDecryptProgressUpdate(zeroProgress); } + protected TempFile GetNewTempFilePath(string extension) + { + extension = FileUtility.GetStandardizedExtension(extension); + var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension); + return new(path, extension); + } + public async Task RunAsync() { await InputFileStream.BeginDownloadingAsync(); var progressTask = Task.Run(reportProgress); - AsyncSteps[$"Cleanup"] = CleanupAsync; (bool success, var elapsed) = await AsyncSteps.RunAsync(); //Stop the downloader so it doesn't keep running in the background. if (!success) - nfsPersister.Dispose(); + NfsPersister.Dispose(); await progressTask; var speedup = DownloadOptions.RuntimeLength / elapsed; Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); - nfsPersister.Dispose(); + NfsPersister.Dispose(); return success; async Task reportProgress() @@ -129,50 +130,43 @@ namespace AaxDecrypter protected abstract Task Step_DownloadAndDecryptAudiobookAsync(); public virtual void SetCoverArt(byte[] coverArt) { } - - protected void OnRetrievedTitle(string title) + protected void OnRetrievedTitle(string? title) => RetrievedTitle?.Invoke(this, title); - protected void OnRetrievedAuthors(string authors) + protected void OnRetrievedAuthors(string? authors) => RetrievedAuthors?.Invoke(this, authors); - protected void OnRetrievedNarrators(string narrators) + protected void OnRetrievedNarrators(string? narrators) => RetrievedNarrators?.Invoke(this, narrators); - protected void OnRetrievedCoverArt(byte[] coverArt) + protected void OnRetrievedCoverArt(byte[]? coverArt) => RetrievedCoverArt?.Invoke(this, coverArt); protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress) => DecryptProgressUpdate?.Invoke(this, downloadProgress); protected void OnDecryptTimeRemaining(TimeSpan timeRemaining) => DecryptTimeRemaining?.Invoke(this, timeRemaining); - protected void OnFileCreated(string path) - => FileCreated?.Invoke(this, path); + public void OnTempFileCreated(TempFile path) + => TempFileCreated?.Invoke(this, path); protected virtual void FinalizeDownload() { - nfsPersister?.Dispose(); + NfsPersister.Dispose(); downloadFinished = true; } - protected async Task Step_DownloadClipsBookmarksAsync() - { - if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks) - { - var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName); - - if (File.Exists(recordsFile)) - OnFileCreated(recordsFile); - } - return !IsCanceled; - } - protected async Task Step_CreateCueAsync() { if (!DownloadOptions.CreateCueSheet) return !IsCanceled; + if (DownloadOptions.ChapterInfo.Count <= 1) + { + Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters."); + return !IsCanceled; + } + // not a critical step. its failure should not prevent future steps from running try { - var path = Path.ChangeExtension(OutputFileName, ".cue"); - await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo)); - OnFileCreated(path); + var tempFile = GetNewTempFilePath(".cue"); + await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo)); + OnTempFileCreated(tempFile); } catch (Exception ex) { @@ -181,58 +175,9 @@ namespace AaxDecrypter return !IsCanceled; } - private async Task CleanupAsync() - { - if (IsCanceled) return false; - - FileUtility.SaferDelete(jsonDownloadState); - - if (DownloadOptions.DecryptionKeys != null && - DownloadOptions.RetainEncryptedFile && - DownloadOptions.InputType is AAXClean.FileType fileType) - { - //Write aax decryption key - string keyPath = Path.ChangeExtension(tempFilePath, ".key"); - FileUtility.SaferDelete(keyPath); - string aaxPath; - - if (fileType is AAXClean.FileType.Aax) - { - await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}"); - aaxPath = Path.ChangeExtension(tempFilePath, ".aax"); - } - else if (fileType is AAXClean.FileType.Aaxc) - { - await File.WriteAllTextAsync(keyPath, - $"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" + - $"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}"); - aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc"); - } - else if (fileType is AAXClean.FileType.Dash) - { - await File.WriteAllTextAsync(keyPath, - $"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" + - $"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}"); - aaxPath = Path.ChangeExtension(tempFilePath, ".dash"); - } - else - throw new InvalidOperationException($"Unknown file type: {fileType}"); - - if (tempFilePath != aaxPath) - FileUtility.SaferMove(tempFilePath, aaxPath); - - OnFileCreated(aaxPath); - OnFileCreated(keyPath); - } - else - FileUtility.SaferDelete(tempFilePath); - - return !IsCanceled; - } - private NetworkFileStreamPersister OpenNetworkFileStream() { - NetworkFileStreamPersister nfsp = default; + NetworkFileStreamPersister? nfsp = default; try { if (!File.Exists(jsonDownloadState)) @@ -253,8 +198,14 @@ namespace AaxDecrypter } finally { - nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent; - nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; + //nfsp will only be null when an unhandled exception occurs. Let the caller handle it. + if (nfsp is not null) + { + nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent; + nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; + OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString())); + OnTempFileCreated(new(jsonDownloadState)); + } } NetworkFileStreamPersister newNetworkFilePersister() diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index fb22350e..d80a5b95 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -1,6 +1,5 @@ using AAXClean; using System; -using System.Threading.Tasks; #nullable enable namespace AaxDecrypter @@ -33,11 +32,8 @@ namespace AaxDecrypter KeyData[]? DecryptionKeys { get; } TimeSpan RuntimeLength { get; } OutputFormat OutputFormat { get; } - bool TrimOutputToChapterLength { get; } - bool RetainEncryptedFile { get; } bool StripUnabridged { get; } bool CreateCueSheet { get; } - bool DownloadClipsBookmarks { get; } long DownloadSpeedBps { get; } ChapterInfo ChapterInfo { get; } bool FixupFile { get; } @@ -52,9 +48,7 @@ namespace AaxDecrypter bool Downsample { get; } bool MatchSourceBitrate { get; } bool MoveMoovToBeginning { get; } - string GetMultipartFileName(MultiConvertFileProperties props); string GetMultipartTitle(MultiConvertFileProperties props); - Task SaveClipsAndBookmarksAsync(string fileName); public FileType? InputType { get; } } } diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index 1be60545..769a76fc 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -100,6 +100,12 @@ namespace AaxDecrypter Position = WritePosition }; + if (_writeFile.Length < WritePosition) + { + _writeFile.Dispose(); + throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}"); + } + _readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); SetUriForSameFile(uri); diff --git a/Source/AaxDecrypter/TempFile.cs b/Source/AaxDecrypter/TempFile.cs new file mode 100644 index 00000000..f6b37ee4 --- /dev/null +++ b/Source/AaxDecrypter/TempFile.cs @@ -0,0 +1,17 @@ +using FileManager; + +#nullable enable +namespace AaxDecrypter; + +public record TempFile +{ + public LongPath FilePath { get; init; } + public string Extension { get; } + public MultiConvertFileProperties? PartProperties { get; init; } + public TempFile(LongPath filePath, string? extension = null) + { + FilePath = filePath; + extension ??= System.IO.Path.GetExtension(filePath); + Extension = FileUtility.GetStandardizedExtension(extension).ToLowerInvariant(); + } +} diff --git a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs index 18c57054..1c6f09e8 100644 --- a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs +++ b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs @@ -1,5 +1,4 @@ using FileManager; -using System; using System.Threading.Tasks; namespace AaxDecrypter @@ -8,13 +7,12 @@ namespace AaxDecrypter { protected override long InputFilePosition => InputFileStream.WritePosition; - public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic) - : base(outFileName, cacheDirectory, dlLic) + public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic) + : base(outDirectory, cacheDirectory, dlLic) { AsyncSteps.Name = "Download Unencrypted Audiobook"; AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; - AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync; - AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync; + AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync; } protected override async Task Step_DownloadAndDecryptAudiobookAsync() @@ -26,8 +24,9 @@ namespace AaxDecrypter else { FinalizeDownload(); - FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName); - OnFileCreated(OutputFileName); + var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); + FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath); + OnTempFileCreated(tempFile); return true; } } diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 7c373d0b..f2e2a7cc 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -521,8 +521,8 @@ namespace ApplicationServices udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating); }); - public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion) - => lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); }); + public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion) + => lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); }); public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus) => libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus); diff --git a/Source/ApplicationServices/LibraryExporter.cs b/Source/ApplicationServices/LibraryExporter.cs index f0a62193..1f16052a 100644 --- a/Source/ApplicationServices/LibraryExporter.cs +++ b/Source/ApplicationServices/LibraryExporter.cs @@ -4,8 +4,8 @@ using System.Linq; using CsvHelper; using CsvHelper.Configuration.Attributes; using DataLayer; +using Newtonsoft.Json; using NPOI.XSSF.UserModel; -using Serilog; namespace ApplicationServices { @@ -115,7 +115,29 @@ namespace ApplicationServices [Name("IsFinished")] public bool IsFinished { get; set; } - } + + [Name("IsSpatial")] + public bool IsSpatial { get; set; } + + [Name("Last Downloaded File Version")] + public string LastDownloadedFileVersion { get; set; } + + [Ignore /* csv ignore */] + public AudioFormat LastDownloadedFormat { get; set; } + + [Name("Last Downloaded Codec"), JsonIgnore] + public string CodecString => LastDownloadedFormat?.CodecString ?? ""; + + [Name("Last Downloaded Sample rate"), JsonIgnore] + public int? SampleRate => LastDownloadedFormat?.SampleRate; + + [Name("Last Downloaded Audio Channels"), JsonIgnore] + public int? ChannelCount => LastDownloadedFormat?.ChannelCount; + + [Name("Last Downloaded Bitrate"), JsonIgnore] + public int? BitRate => LastDownloadedFormat?.BitRate; + } + public static class LibToDtos { public static List ToDtos(this IEnumerable library) @@ -135,16 +157,16 @@ namespace ApplicationServices HasPdf = a.Book.HasPdf(), SeriesNames = a.Book.SeriesNames(), SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "", - CommunityRatingOverall = a.Book.Rating?.OverallRating, - CommunityRatingPerformance = a.Book.Rating?.PerformanceRating, - CommunityRatingStory = a.Book.Rating?.StoryRating, + CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(), + CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(), + CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(), PictureId = a.Book.PictureId, IsAbridged = a.Book.IsAbridged, DatePublished = a.Book.DatePublished, CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()), - MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating, - MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating, - MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating, + MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(), + MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(), + MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(), MyLibationTags = a.Book.UserDefinedItem.Tags, BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(), PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(), @@ -152,8 +174,13 @@ namespace ApplicationServices Language = a.Book.Language, LastDownloaded = a.Book.UserDefinedItem.LastDownloaded, LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "", - IsFinished = a.Book.UserDefinedItem.IsFinished - }).ToList(); + IsFinished = a.Book.UserDefinedItem.IsFinished, + IsSpatial = a.Book.IsSpatial, + LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "", + LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat + }).ToList(); + + private static float? ZeroIsNull(this float value) => value is 0 ? null : value; } public static class LibraryExporter { @@ -162,7 +189,6 @@ namespace ApplicationServices var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); if (!dtos.Any()) return; - using var writer = new System.IO.StreamWriter(saveFilePath); using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture); @@ -174,7 +200,7 @@ namespace ApplicationServices public static void ToJson(string saveFilePath) { var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); - var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented); + var json = JsonConvert.SerializeObject(dtos, Formatting.Indented); System.IO.File.WriteAllText(saveFilePath, json); } @@ -227,7 +253,13 @@ namespace ApplicationServices nameof(ExportDto.Language), nameof(ExportDto.LastDownloaded), nameof(ExportDto.LastDownloadedVersion), - nameof(ExportDto.IsFinished) + nameof(ExportDto.IsFinished), + nameof(ExportDto.IsSpatial), + nameof(ExportDto.LastDownloadedFileVersion), + nameof(ExportDto.CodecString), + nameof(ExportDto.SampleRate), + nameof(ExportDto.ChannelCount), + nameof(ExportDto.BitRate) }; var col = 0; foreach (var c in columns) @@ -248,15 +280,10 @@ namespace ApplicationServices foreach (var dto in dtos) { col = 0; - - row = sheet.CreateRow(rowIndex); + row = sheet.CreateRow(rowIndex++); row.CreateCell(col++).SetCellValue(dto.Account); - - var dateCell = row.CreateCell(col++); - dateCell.CellStyle = dateStyle; - dateCell.SetCellValue(dto.DateAdded); - + row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle; row.CreateCell(col++).SetCellValue(dto.AudibleProductId); row.CreateCell(col++).SetCellValue(dto.Locale); row.CreateCell(col++).SetCellValue(dto.Title); @@ -269,56 +296,46 @@ namespace ApplicationServices row.CreateCell(col++).SetCellValue(dto.HasPdf); row.CreateCell(col++).SetCellValue(dto.SeriesNames); row.CreateCell(col++).SetCellValue(dto.SeriesOrder); - - col = createCell(row, col, dto.CommunityRatingOverall); - col = createCell(row, col, dto.CommunityRatingPerformance); - col = createCell(row, col, dto.CommunityRatingStory); - + row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall); + row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance); + row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory); row.CreateCell(col++).SetCellValue(dto.PictureId); row.CreateCell(col++).SetCellValue(dto.IsAbridged); - - var datePubCell = row.CreateCell(col++); - datePubCell.CellStyle = dateStyle; - if (dto.DatePublished.HasValue) - datePubCell.SetCellValue(dto.DatePublished.Value); - else - datePubCell.SetCellValue(""); - + row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle; row.CreateCell(col++).SetCellValue(dto.CategoriesNames); - - col = createCell(row, col, dto.MyRatingOverall); - col = createCell(row, col, dto.MyRatingPerformance); - col = createCell(row, col, dto.MyRatingStory); - + row.CreateCell(col++).SetCellValue(dto.MyRatingOverall); + row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance); + row.CreateCell(col++).SetCellValue(dto.MyRatingStory); row.CreateCell(col++).SetCellValue(dto.MyLibationTags); row.CreateCell(col++).SetCellValue(dto.BookStatus); row.CreateCell(col++).SetCellValue(dto.PdfStatus); row.CreateCell(col++).SetCellValue(dto.ContentType); - row.CreateCell(col++).SetCellValue(dto.Language); - - if (dto.LastDownloaded.HasValue) - { - dateCell = row.CreateCell(col); - dateCell.CellStyle = dateStyle; - dateCell.SetCellValue(dto.LastDownloaded.Value); - } - - row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion); - row.CreateCell(++col).SetCellValue(dto.IsFinished); - - rowIndex++; + row.CreateCell(col++).SetCellValue(dto.Language); + row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle; + row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion); + row.CreateCell(col++).SetCellValue(dto.IsFinished); + row.CreateCell(col++).SetCellValue(dto.IsSpatial); + row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion); + row.CreateCell(col++).SetCellValue(dto.CodecString); + row.CreateCell(col++).SetCellValue(dto.SampleRate); + row.CreateCell(col++).SetCellValue(dto.ChannelCount); + row.CreateCell(col++).SetCellValue(dto.BitRate); } using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create); workbook.Write(fileData); } - private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat) - { - if (nullableFloat.HasValue) - row.CreateCell(col++).SetCellValue(nullableFloat.Value); - else - row.CreateCell(col++).SetCellValue(""); - return col; - } + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate) + => nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt) + => nullableInt.HasValue ? cell.SetCellValue(nullableInt.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, float? nullableFloat) + => nullableFloat.HasValue ? cell.SetCellValue(nullableFloat.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); } } diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 8007d316..d9916eaa 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -5,7 +5,7 @@ - + diff --git a/Source/DataLayer/AudioFormat.cs b/Source/DataLayer/AudioFormat.cs new file mode 100644 index 00000000..2a517677 --- /dev/null +++ b/Source/DataLayer/AudioFormat.cs @@ -0,0 +1,70 @@ +#nullable enable +using Newtonsoft.Json; + +namespace DataLayer; + +public enum Codec : byte +{ + Unknown, + Mp3, + AAC_LC, + xHE_AAC, + EC_3, + AC_4 +} + +public class AudioFormat +{ + public static AudioFormat Default => new(Codec.Unknown, 0, 0, 0); + [JsonIgnore] + public bool IsDefault => Codec is Codec.Unknown && BitRate == 0 && SampleRate == 0 && ChannelCount == 0; + [JsonIgnore] + public Codec Codec { get; set; } + public int SampleRate { get; set; } + public int ChannelCount { get; set; } + public int BitRate { get; set; } + + public AudioFormat(Codec codec, int bitRate, int sampleRate, int channelCount) + { + Codec = codec; + BitRate = bitRate; + SampleRate = sampleRate; + ChannelCount = channelCount; + } + + public string CodecString => Codec switch + { + Codec.Mp3 => "mp3", + Codec.AAC_LC => "AAC-LC", + Codec.xHE_AAC => "xHE-AAC", + Codec.EC_3 => "EC-3", + Codec.AC_4 => "AC-4", + Codec.Unknown or _ => "[Unknown]", + }; + + //Property | Start | Num | Max | Current Max | + // | Bit | Bits | Value | Value Used | + //----------------------------------------------------- + //Codec | 35 | 4 | 15 | 5 | + //BitRate | 23 | 12 | 4_095 | 768 | + //SampleRate | 5 | 18 | 262_143 | 48_000 | + //ChannelCount | 0 | 5 | 31 | 6 | + public long Serialize() => + ((long)Codec << 35) | + ((long)BitRate << 23) | + ((long)SampleRate << 5) | + (long)ChannelCount; + + public static AudioFormat Deserialize(long value) + { + var codec = (Codec)((value >> 35) & 15); + var bitRate = (int)((value >> 23) & 4_095); + var sampleRate = (int)((value >> 5) & 262_143); + var channelCount = (int)(value & 31); + return new AudioFormat(codec, bitRate, sampleRate, channelCount); + } + + public override string ToString() + => IsDefault ? "[Unknown Audio Format]" + : $"{CodecString} ({ChannelCount}ch | {SampleRate:N0}Hz | {BitRate}kbps)"; +} diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index bafa27e6..b0d802ce 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -13,7 +13,6 @@ namespace DataLayer.Configurations entity.OwnsOne(b => b.Rating); - entity.Property(nameof(Book._audioFormat)); // // CRUCIAL: ignore unmapped collections, even get-only // @@ -50,6 +49,11 @@ namespace DataLayer.Configurations b_udi .Property(udi => udi.LastDownloadedVersion) .HasConversion(ver => ver.ToString(), str => Version.Parse(str)); + b_udi + .Property(udi => udi.LastDownloadedFormat) + .HasConversion(af => af.Serialize(), str => AudioFormat.Deserialize(str)); + + b_udi.Property(udi => udi.LastDownloadedFileVersion); // owns it 1:1, store in same table b_udi.OwnsOne(udi => udi.Rating); diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 33cbbaf5..11aedf1d 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -43,18 +43,13 @@ namespace DataLayer public ContentType ContentType { get; private set; } public string Locale { get; private set; } - //This field is now unused, however, there is little sense in adding a - //database migration to remove an unused field. Leave it for compatibility. -#pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0 - internal long _audioFormat; -#pragma warning restore CS0649 - // mutable public string PictureId { get; set; } public string PictureLarge { get; set; } // book details public bool IsAbridged { get; private set; } + public bool IsSpatial { get; private set; } public DateTime? DatePublished { get; private set; } public string Language { get; private set; } @@ -242,10 +237,11 @@ namespace DataLayer public void UpdateProductRating(float overallRating, float performanceRating, float storyRating) => Rating.Update(overallRating, performanceRating, storyRating); - public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language) + public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string language) { // don't overwrite with default values IsAbridged |= isAbridged; + IsSpatial |= isSpatial ?? false; DatePublished = datePublished ?? DatePublished; Language = language?.FirstCharToUpper() ?? Language; } diff --git a/Source/DataLayer/EfClasses/UserDefinedItem.cs b/Source/DataLayer/EfClasses/UserDefinedItem.cs index dce1dfbe..7aa977f0 100644 --- a/Source/DataLayer/EfClasses/UserDefinedItem.cs +++ b/Source/DataLayer/EfClasses/UserDefinedItem.cs @@ -24,24 +24,52 @@ namespace DataLayer { internal int BookId { get; private set; } public Book Book { get; private set; } - public DateTime? LastDownloaded { get; private set; } - public Version LastDownloadedVersion { get; private set; } + /// + /// Date the audio file was last downloaded. + /// + public DateTime? LastDownloaded { get; private set; } + /// + /// Version of Libation used the last time the audio file was downloaded. + /// + public Version LastDownloadedVersion { get; private set; } + /// + /// Audio format of the last downloaded audio file. + /// + public AudioFormat LastDownloadedFormat { get; private set; } + /// + /// Version of the audio file that was last downloaded. + /// + public string LastDownloadedFileVersion { get; private set; } - public void SetLastDownloaded(Version version) + public void SetLastDownloaded(Version libationVersion, AudioFormat audioFormat, string audioVersion) { - if (LastDownloadedVersion != version) + if (LastDownloadedVersion != libationVersion) { - LastDownloadedVersion = version; + LastDownloadedVersion = libationVersion; OnItemChanged(nameof(LastDownloadedVersion)); } + if (LastDownloadedFormat != audioFormat) + { + LastDownloadedFormat = audioFormat; + OnItemChanged(nameof(LastDownloadedFormat)); + } + if (LastDownloadedFileVersion != audioVersion) + { + LastDownloadedFileVersion = audioVersion; + OnItemChanged(nameof(LastDownloadedFileVersion)); + } - if (version is null) + if (libationVersion is null) + { LastDownloaded = null; + LastDownloadedFormat = null; + LastDownloadedFileVersion = null; + } else { - LastDownloaded = DateTime.Now; + LastDownloaded = DateTime.Now; OnItemChanged(nameof(LastDownloaded)); - } + } } private UserDefinedItem() { } diff --git a/Source/DataLayer/LibationContextFactory.cs b/Source/DataLayer/LibationContextFactory.cs index 92f1f7d1..8718f636 100644 --- a/Source/DataLayer/LibationContextFactory.cs +++ b/Source/DataLayer/LibationContextFactory.cs @@ -1,5 +1,6 @@ using Dinah.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace DataLayer { @@ -7,6 +8,7 @@ namespace DataLayer { protected override LibationContext CreateNewInstance(DbContextOptions options) => new LibationContext(options); protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) - => optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); + => optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); } } diff --git a/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs new file mode 100644 index 00000000..a39c89db --- /dev/null +++ b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs @@ -0,0 +1,474 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20250725074123_AddAudioFormatData")] + partial class AddAudioFormatData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.Property("_categoriesCategoryId") + .HasColumnType("INTEGER"); + + b.Property("_categoryLaddersCategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId"); + + b.HasIndex("_categoryLaddersCategoryLadderId"); + + b.ToTable("CategoryCategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property("ContentType") + .HasColumnType("INTEGER"); + + b.Property("DatePublished") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAbridged") + .HasColumnType("INTEGER"); + + b.Property("IsSpatial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LengthInMinutes") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasColumnType("TEXT"); + + b.Property("PictureId") + .HasColumnType("TEXT"); + + b.Property("PictureLarge") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("CategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "CategoryLadderId"); + + b.HasIndex("BookId"); + + b.HasIndex("CategoryLadderId"); + + b.ToTable("BookCategory"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("ContributorId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleCategoryId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Property("CategoryLadderId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleContributorId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("AbsentFromLastScan") + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.HasKey("BookId"); + + b.ToTable("LibraryBooks"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleSeriesId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.HasOne("DataLayer.Category", null) + .WithMany() + .HasForeignKey("_categoriesCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", null) + .WithMany() + .HasForeignKey("_categoryLaddersCategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("OverallRating") + .HasColumnType("REAL"); + + b1.Property("PerformanceRating") + .HasColumnType("REAL"); + + b1.Property("StoryRating") + .HasColumnType("REAL"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.WithOwner() + .HasForeignKey("BookId"); + }); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("Url") + .HasColumnType("TEXT"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.Navigation("Book"); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("BookStatus") + .HasColumnType("INTEGER"); + + b1.Property("IsFinished") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloaded") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFileVersion") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("TEXT"); + + b1.Property("PdfStatus") + .HasColumnType("INTEGER"); + + b1.Property("Tags") + .HasColumnType("TEXT"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem", (string)null); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId") + .HasColumnType("INTEGER"); + + b2.Property("OverallRating") + .HasColumnType("REAL"); + + b2.Property("PerformanceRating") + .HasColumnType("REAL"); + + b2.Property("StoryRating") + .HasColumnType("REAL"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.WithOwner() + .HasForeignKey("UserDefinedItemBookId"); + }); + + b1.Navigation("Book"); + + b1.Navigation("Rating"); + }); + + b.Navigation("Rating"); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("CategoriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", "CategoryLadder") + .WithMany("BooksLink") + .HasForeignKey("CategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("CategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Contributor"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Navigation("CategoriesLink"); + + b.Navigation("ContributorsLink"); + + b.Navigation("SeriesLink"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Navigation("BooksLink"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs new file mode 100644 index 00000000..f653c00f --- /dev/null +++ b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// + public partial class AddAudioFormatData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "_audioFormat", + table: "Books", + newName: "IsSpatial"); + + migrationBuilder.AddColumn( + name: "LastDownloadedFileVersion", + table: "UserDefinedItem", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastDownloadedFormat", + table: "UserDefinedItem", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastDownloadedFileVersion", + table: "UserDefinedItem"); + + migrationBuilder.DropColumn( + name: "LastDownloadedFormat", + table: "UserDefinedItem"); + + migrationBuilder.RenameColumn( + name: "IsSpatial", + table: "Books", + newName: "_audioFormat"); + } + } +} diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index 2a17687e..99f70af0 100644 --- a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace DataLayer.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); modelBuilder.Entity("CategoryCategoryLadder", b => { @@ -53,6 +53,9 @@ namespace DataLayer.Migrations b.Property("IsAbridged") .HasColumnType("INTEGER"); + b.Property("IsSpatial") + .HasColumnType("INTEGER"); + b.Property("Language") .HasColumnType("TEXT"); @@ -74,9 +77,6 @@ namespace DataLayer.Migrations b.Property("Title") .HasColumnType("TEXT"); - b.Property("_audioFormat") - .HasColumnType("INTEGER"); - b.HasKey("BookId"); b.HasIndex("AudibleProductId"); @@ -318,6 +318,12 @@ namespace DataLayer.Migrations b1.Property("LastDownloaded") .HasColumnType("TEXT"); + b1.Property("LastDownloadedFileVersion") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("INTEGER"); + b1.Property("LastDownloadedVersion") .HasColumnType("TEXT"); diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index 95c86a64..e6596d09 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -137,8 +137,6 @@ namespace DtoImporterService book.ReplacePublisher(publisher); } - book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language); - if (item.PdfUrl is not null) book.AddSupplementDownloadUrl(item.PdfUrl.ToString()); @@ -166,8 +164,9 @@ namespace DtoImporterService // 2023-02-01 // updateBook must update language on books which were imported before the migration which added language. - // Can eventually delete this - book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language); + // 2025-07-30 + // updateBook must update isSpatial on books which were imported before the migration which added isSpatial. + book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language); book.UpdateProductRating( (float)(item.Rating?.OverallDistribution?.AverageRating ?? 0), diff --git a/Source/FileLiberator/AudioFileStorageExt.cs b/Source/FileLiberator/AudioFileStorageExt.cs index c0ce9bd0..90ab45b1 100644 --- a/Source/FileLiberator/AudioFileStorageExt.cs +++ b/Source/FileLiberator/AudioFileStorageExt.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AaxDecrypter; using DataLayer; using LibationFileManager; using LibationFileManager.Templates; @@ -34,30 +35,17 @@ namespace FileLiberator return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, ""); } - /// - /// DownloadDecryptBook: - /// Path: in progress directory. - /// File name: final file name. - /// - public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension) - => Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true); - /// /// PDF: audio file does not exist /// - public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension) - => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension); - - /// - /// PDF: audio file does not exist - /// - public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension) - => Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension); + public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false) + => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension, returnFirstExisting: returnFirstExisting); /// /// PDF: audio file already exists /// - public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension) - => Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension); + public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties partProperties = null, bool returnFirstExisting = false) + => partProperties is null ? Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension, returnFirstExisting: returnFirstExisting) + : Templates.ChapterFile.GetFilename(libraryBook.ToDto(), partProperties, dirFullPath, extension, returnFirstExisting: returnFirstExisting); } } diff --git a/Source/FileLiberator/AudioFormatDecoder.cs b/Source/FileLiberator/AudioFormatDecoder.cs new file mode 100644 index 00000000..1894298d --- /dev/null +++ b/Source/FileLiberator/AudioFormatDecoder.cs @@ -0,0 +1,242 @@ +using AAXClean; +using DataLayer; +using FileManager; +using Mpeg4Lib.Boxes; +using Mpeg4Lib.Util; +using NAudio.Lame.ID3; +using System; +using System.Collections.Generic; +using System.IO; + +#nullable enable +namespace AaxDecrypter; + +/// Read audio codec, bitrate, sample rate, and channel count from MP4 and MP3 audio files. +internal static class AudioFormatDecoder +{ + public static AudioFormat FromMpeg4(string filename) + { + using var fileStream = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.Read); + return FromMpeg4(new Mp4File(fileStream)); + } + + public static AudioFormat FromMpeg4(Mp4File mp4File) + { + Codec codec; + if (mp4File.AudioSampleEntry.Dac4 is not null) + { + codec = Codec.AC_4; + } + else if (mp4File.AudioSampleEntry.Dec3 is not null) + { + codec = Codec.EC_3; + } + else if (mp4File.AudioSampleEntry.Esds is EsdsBox esds) + { + var objectType = esds.ES_Descriptor.DecoderConfig.AudioSpecificConfig.AudioObjectType; + codec + = objectType == 2 ? Codec.AAC_LC + : objectType == 42 ? Codec.xHE_AAC + : Codec.Unknown; + } + else + return AudioFormat.Default; + + var bitrate = (int)Math.Round(mp4File.AverageBitrate / 1024d); + + return new AudioFormat(codec, bitrate, mp4File.TimeScale, mp4File.AudioChannels); + } + + public static AudioFormat FromMpeg3(LongPath mp3Filename) + { + using var mp3File = File.Open(mp3Filename, FileMode.Open, FileAccess.Read, FileShare.Read); + if (Id3Header.Create(mp3File) is Id3Header id3header) + id3header.SeekForwardToPosition(mp3File, mp3File.Position + id3header.Size); + else + { + Serilog.Log.Logger.Debug("File appears not to have ID3 tags."); + mp3File.Position = 0; + } + + if (!SeekToFirstKeyFrame(mp3File)) + { + Serilog.Log.Logger.Warning("Invalid frame sync read from file at end of ID3 tag."); + return AudioFormat.Default; + } + + var mpegSize = mp3File.Length - mp3File.Position; + if (mpegSize < 64) + { + Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {@File}", mp3Filename); + return AudioFormat.Default; + } + + #region read first mp3 frame header + //https://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header#VBRIHeader + var reader = new BitReader(mp3File.ReadBlock(4)); + reader.Position = 11; //Skip frame header magic bits + var versionId = (Version)reader.Read(2); + var layerDesc = (Layer)reader.Read(2); + + if (layerDesc is not Layer.Layer_3) + { + Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString()); + return AudioFormat.Default; + } + + if (versionId is Version.Reserved) + { + Serilog.Log.Logger.Warning("Mp3 data data cannot be read from a file with version = 'Reserved'"); + return AudioFormat.Default; + } + + var protectionBit = reader.ReadBool(); + var bitrateIndex = reader.Read(4); + var freqIndex = reader.Read(2); + _ = reader.ReadBool(); //Padding bit + _ = reader.ReadBool(); //Private bit + var channelMode = reader.Read(2); + _ = reader.Read(2); //Mode extension + _ = reader.ReadBool(); //Copyright + _ = reader.ReadBool(); //Original + _ = reader.Read(2); //Emphasis + #endregion + + //Read the sample rate,and channels from the first frame's header. + var sampleRate = Mp3SampleRateIndex[versionId][freqIndex]; + var channelCount = channelMode == 3 ? 1 : 2; + + //Try to read variable bitrate info from the first frame. + //Revert to fixed bitrate from frame header if not found. + var bitrate + = TryReadXingBitrate(out var br) ? br + : TryReadVbriBitrate(out br) ? br + : Mp3BitrateIndex[versionId][bitrateIndex]; + + return new AudioFormat(Codec.Mp3, bitrate, sampleRate, channelCount); + + #region Variable bitrate header readers + bool TryReadXingBitrate(out int bitrate) + { + const int XingHeader = 0x58696e67; + const int InfoHeader = 0x496e666f; + + var sideInfoSize = GetSideInfo(channelCount == 2, versionId) + (protectionBit ? 0 : 2); + mp3File.Position += sideInfoSize; + + if (mp3File.ReadUInt32BE() is XingHeader or InfoHeader) + { + //Xing or Info header (common) + var flags = mp3File.ReadUInt32BE(); + bool hasFramesField = (flags & 1) == 1; + bool hasBytesField = (flags & 2) == 2; + + if (hasFramesField) + { + var numFrames = mp3File.ReadUInt32BE(); + if (hasBytesField) + { + mpegSize = mp3File.ReadUInt32BE(); + } + + var samplesPerFrame = GetSamplesPerFrame(sampleRate); + var duration = samplesPerFrame * numFrames / sampleRate; + bitrate = (short)(mpegSize / duration / 1024 * 8); + return true; + } + } + else + mp3File.Position -= sideInfoSize + 4; + + bitrate = 0; + return false; + } + + bool TryReadVbriBitrate(out int bitrate) + { + const int VBRIHeader = 0x56425249; + + mp3File.Position += 32; + + if (mp3File.ReadUInt32BE() is VBRIHeader) + { + //VBRI header (rare) + _ = mp3File.ReadBlock(6); + mpegSize = mp3File.ReadUInt32BE(); + var numFrames = mp3File.ReadUInt32BE(); + + var samplesPerFrame = GetSamplesPerFrame(sampleRate); + var duration = samplesPerFrame * numFrames / sampleRate; + bitrate = (short)(mpegSize / duration / 1024 * 8); + return true; + } + bitrate = 0; + return false; + } + #endregion + } + + #region MP3 frame decoding helpers + private static bool SeekToFirstKeyFrame(Stream file) + { + //Frame headers begin with first 11 bits set. + const int MaxSeekBytes = 4096; + var maxPosition = Math.Min(file.Length, file.Position + MaxSeekBytes) - 2; + + while (file.Position < maxPosition) + { + if (file.ReadByte() == 0xff) + { + if ((file.ReadByte() & 0xe0) == 0xe0) + { + file.Position -= 2; + return true; + } + file.Position--; + } + } + return false; + } + + private enum Version + { + Version_2_5, + Reserved, + Version_2, + Version_1 + } + + private enum Layer + { + Reserved, + Layer_3, + Layer_2, + Layer_1 + } + + private static double GetSamplesPerFrame(int sampleRate) => sampleRate >= 32000 ? 1152 : 576; + + private static byte GetSideInfo(bool stereo, Version version) => (stereo, version) switch + { + (true, Version.Version_1) => 32, + (true, Version.Version_2 or Version.Version_2_5) => 17, + (false, Version.Version_1) => 17, + (false, Version.Version_2 or Version.Version_2_5) => 9, + _ => 0, + }; + + private static readonly Dictionary Mp3SampleRateIndex = new() + { + { Version.Version_2_5, [11025, 12000, 8000] }, + { Version.Version_2, [22050, 24000, 16000] }, + { Version.Version_1, [44100, 48000, 32000] }, + }; + + private static readonly Dictionary Mp3BitrateIndex = new() + { + { Version.Version_2_5, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]}, + { Version.Version_2, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]}, + { Version.Version_1, [-1,32,40,48,56,64,80,96,112,128,160,192,224,256,320,-1]} + }; + #endregion +} diff --git a/Source/FileLiberator/ConvertToMp3.cs b/Source/FileLiberator/ConvertToMp3.cs index 2639c23f..6fc97d7c 100644 --- a/Source/FileLiberator/ConvertToMp3.cs +++ b/Source/FileLiberator/ConvertToMp3.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using AAXClean; using AAXClean.Codecs; @@ -19,7 +20,13 @@ namespace FileLiberator private readonly AaxDecrypter.AverageSpeed averageSpeed = new(); private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3"); - public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask; + private CancellationTokenSource CancellationTokenSource { get; set; } + public override async Task CancelAsync() + { + await CancellationTokenSource.CancelAsync(); + if (Mp4Operation is not null) + await Mp4Operation.CancelAsync(); + } public static bool ValidateMp3(LibraryBook libraryBook) { @@ -32,17 +39,29 @@ namespace FileLiberator public override async Task ProcessAsync(LibraryBook libraryBook) { OnBegin(libraryBook); + var cancellationToken = (CancellationTokenSource = new()).Token; try { - var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId); + var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId) + .Where(m4bPath => File.Exists(m4bPath)) + .Select(m4bPath => new { m4bPath, proposedMp3Path = Mp3FileName(m4bPath), m4bSize = new FileInfo(m4bPath).Length }) + .Where(p => !File.Exists(p.proposedMp3Path)) + .ToArray(); - foreach (var m4bPath in m4bPaths) + long totalInputSize = m4bPaths.Sum(p => p.m4bSize); + long sizeOfCompletedFiles = 0L; + foreach (var entry in m4bPaths) { - var proposedMp3Path = Mp3FileName(m4bPath); - if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue; + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(entry.proposedMp3Path) || !File.Exists(entry.m4bPath)) + { + sizeOfCompletedFiles += entry.m4bSize; + continue; + } - var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read)); + using var m4bFileStream = File.Open(entry.m4bPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var m4bBook = new Mp4File(m4bFileStream); //AAXClean.Codecs only supports decoding AAC and E-AC-3 audio. if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null) @@ -69,74 +88,85 @@ namespace FileLiberator lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString(); } - using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite); + long currentFileNumBytesProcessed = 0; try { - Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters); - Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate; - await Mp4Operation; - - if (Mp4Operation.IsCanceled) + var tempPath = Path.GetTempFileName(); + using (var mp3File = File.Open(tempPath, FileMode.OpenOrCreate, FileAccess.ReadWrite)) { - FileUtility.SaferDelete(mp3File.Name); - return new StatusHandler { "Cancelled" }; + Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters); + Mp4Operation.ConversionProgressUpdate += m4bBook_ConversionProgressUpdate; + await Mp4Operation; } - else - { - var realMp3Path + + if (cancellationToken.IsCancellationRequested) + FileUtility.SaferDelete(tempPath); + + cancellationToken.ThrowIfCancellationRequested(); + + var realMp3Path = FileUtility.SaferMoveToValidPath( - mp3File.Name, - proposedMp3Path, + tempPath, + entry.proposedMp3Path, Configuration.Instance.ReplacementCharacters, extension: "mp3", Configuration.Instance.OverwriteExisting); - SetFileTime(libraryBook, realMp3Path); - SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path)); - - OnFileCreated(libraryBook, realMp3Path); - } - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "AAXClean error"); - return new StatusHandler { "Conversion failed" }; + SetFileTime(libraryBook, realMp3Path); + SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path)); + OnFileCreated(libraryBook, realMp3Path); } finally { if (Mp4Operation is not null) - Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate; + Mp4Operation.ConversionProgressUpdate -= m4bBook_ConversionProgressUpdate; - m4bBook.InputStream.Close(); - mp3File.Close(); + sizeOfCompletedFiles += entry.m4bSize; + } + void m4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e) + { + currentFileNumBytesProcessed = (long)(e.FractionCompleted * entry.m4bSize); + var bytesCompleted = sizeOfCompletedFiles + currentFileNumBytesProcessed; + ConversionProgressUpdate(totalInputSize, bytesCompleted); } } + return new StatusHandler(); + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + Serilog.Log.Error(ex, "AAXClean error"); + return new StatusHandler { "Conversion failed" }; + } + return new StatusHandler { "Cancelled" }; } finally { OnCompleted(libraryBook); + CancellationTokenSource.Dispose(); + CancellationTokenSource = null; } - return new StatusHandler(); } - private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e) + private void ConversionProgressUpdate(long totalInputSize, long bytesCompleted) { - averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds); + averageSpeed.AddPosition(bytesCompleted); - var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds; - var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average; + var remainingBytes = (totalInputSize - bytesCompleted); + var estTimeRemaining = remainingBytes / averageSpeed.Average; if (double.IsNormal(estTimeRemaining)) OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining)); - double progressPercent = 100 * e.FractionCompleted; + double progressPercent = 100 * bytesCompleted / totalInputSize; OnStreamingProgressChanged( new DownloadProgress { ProgressPercentage = progressPercent, - BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds, - TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds + BytesReceived = bytesCompleted, + TotalBytesToReceive = totalInputSize }); } } diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index 26ffb9b9..de4d35d1 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -1,116 +1,105 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AaxDecrypter; +using AaxDecrypter; using ApplicationServices; using AudibleApi.Common; using DataLayer; using Dinah.Core; using Dinah.Core.ErrorHandling; +using Dinah.Core.Net.Http; using FileManager; using LibationFileManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +#nullable enable namespace FileLiberator { - public class DownloadDecryptBook : AudioDecodable - { - public override string Name => "Download & Decrypt"; - private AudiobookDownloadBase abDownloader; - private readonly CancellationTokenSource cancellationTokenSource = new(); + public class DownloadDecryptBook : AudioDecodable + { + public override string Name => "Download & Decrypt"; + private CancellationTokenSource? cancellationTokenSource; + private AudiobookDownloadBase? abDownloader; public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists(); - public override async Task CancelAsync() - { - cancellationTokenSource.Cancel(); - if (abDownloader is not null) - await abDownloader.CancelAsync(); + public override async Task CancelAsync() + { + if (abDownloader is not null) await abDownloader.CancelAsync(); + if (cancellationTokenSource is not null) await cancellationTokenSource.CancelAsync(); } - public override async Task ProcessAsync(LibraryBook libraryBook) - { - var entries = new List(); - // these only work so minimally b/c CacheEntry is a record. - // in case of parallel decrypts, only capture the ones for this book id. - // if user somehow starts multiple decrypts of the same book in parallel: on their own head be it - void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e) - { - if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId)) - entries.Add(e); - } - void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e) - { - if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId)) - entries.Remove(e); - } - - OnBegin(libraryBook); - var cancellationToken = cancellationTokenSource.Token; + public override async Task ProcessAsync(LibraryBook libraryBook) + { + OnBegin(libraryBook); + cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; try - { - if (libraryBook.Book.Audio_Exists()) - return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; + { + if (libraryBook.Book.Audio_Exists()) + return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; + + DownloadValidation(libraryBook); - downloadValidation(libraryBook); var api = await libraryBook.GetApiAsync(); - var config = Configuration.Instance; - using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook, cancellationToken); - - bool success = false; - try - { - FilePathCache.Inserted += FilePathCache_Inserted; - FilePathCache.Removed += FilePathCache_Removed; - - success = await downloadAudiobookAsync(api, config, downloadOptions); - } - finally - { - FilePathCache.Inserted -= FilePathCache_Inserted; - FilePathCache.Removed -= FilePathCache_Removed; - } - - // decrypt failed - if (!success || getFirstAudioFile(entries) == default) - { - await Task.WhenAll( - entries - .Where(f => f.FileType != FileType.AAXC) - .Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path)))); + using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken); + var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken); + 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)); cancellationToken.ThrowIfCancellationRequested(); return new StatusHandler { "Decrypt failed" }; - } + } - var finalStorageDir = getDestinationDirectory(libraryBook); + if (Configuration.Instance.RetainAaxFile) + { + //Add the cached aaxc and key files to the entries list to be moved to the Books directory. + result.ResultFiles.AddRange(getAaxcFiles(result.CacheFiles)); + } - var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken)); - Task[] finalTasks = - [ - Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)), - moveFilesTask, - Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken)) - ]; + //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. + var moveFilesTask = Task.Run(() => MoveFilesToBooksDir(libraryBook, finalStorageDir, result.ResultFiles, cancellationToken)); + Task[] finalTasks = + [ + moveFilesTask, + Task.Run(() => DownloadCoverArt(finalStorageDir, downloadOptions, cancellationToken)), + Task.Run(() => DownloadRecordsAsync(api, finalStorageDir, downloadOptions, cancellationToken)), + Task.Run(() => DownloadMetadataAsync(api, finalStorageDir, downloadOptions, cancellationToken)), + Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken)) + ]; try - { + { await Task.WhenAll(finalTasks); } - catch when (!moveFilesTask.IsFaulted) + catch when (!moveFilesTask.IsFaulted) { - //Swallow downloadCoverArt and SetCoverAsFolderIcon 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) - { - await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)); - + { + if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested) + { + libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion); SetDirectoryTime(libraryBook, finalStorageDir); + foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath))) + { + //Delete cache files only after the download/decrypt operation completes successfully. + FileUtility.SaferDelete(cacheFile.FilePath); + } } } @@ -122,59 +111,86 @@ namespace FileLiberator return new StatusHandler { "Cancelled" }; } finally - { - OnCompleted(libraryBook); - } - } - - private async Task downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions) - { - var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower()); - var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; - - if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine) - abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions); - else - { - AaxcDownloadConvertBase converter - = config.SplitFilesByChapter ? - new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) : - new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions); - - if (config.AllowLibationFixup) - converter.RetrievedMetadata += Converter_RetrievedMetadata; - - abDownloader = converter; - } - - abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged; - abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining; - abDownloader.RetrievedTitle += OnTitleDiscovered; - abDownloader.RetrievedAuthors += OnAuthorsDiscovered; - abDownloader.RetrievedNarrators += OnNarratorsDiscovered; - abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; - abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path); - - // REAL WORK DONE HERE - var success = await abDownloader.RunAsync(); - - if (success && config.SaveMetadataToFile) - { - var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json"); - - var item = await api.GetCatalogProductAsync(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); - item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo)); - item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference)); - - File.WriteAllText(metadataFile, item.SourceJson.ToString()); - OnFileCreated(dlOptions.LibraryBook, metadataFile); - } - return success; + { + OnCompleted(libraryBook); + cancellationTokenSource.Dispose(); + cancellationTokenSource = null; + } } - private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags) + private record AudiobookDecryptResult(bool Success, List ResultFiles, List CacheFiles); + + private async Task DownloadAudiobookAsync(AudibleApi.Api api, DownloadOptions dlOptions, CancellationToken cancellationToken) { - if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options) + var outpoutDir = AudibleFileStorage.DecryptInProgressDirectory; + var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; + var result = new AudiobookDecryptResult(false, [], []); + + try + { + if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine) + abDownloader = new UnencryptedAudiobookDownloader(outpoutDir, cacheDir, dlOptions); + else + { + AaxcDownloadConvertBase converter + = dlOptions.Config.SplitFilesByChapter && dlOptions.ChapterInfo.Count > 1 ? + new AaxcDownloadMultiConverter(outpoutDir, cacheDir, dlOptions) : + new AaxcDownloadSingleConverter(outpoutDir, cacheDir, dlOptions); + + if (dlOptions.Config.AllowLibationFixup) + converter.RetrievedMetadata += Converter_RetrievedMetadata; + + abDownloader = converter; + } + + abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged; + abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining; + abDownloader.RetrievedTitle += OnTitleDiscovered; + abDownloader.RetrievedAuthors += OnAuthorsDiscovered; + abDownloader.RetrievedNarrators += OnNarratorsDiscovered; + abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; + abDownloader.TempFileCreated += AbDownloader_TempFileCreated; + + // REAL WORK DONE HERE + bool success = await abDownloader.RunAsync(); + return result with { Success = success }; + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + Serilog.Log.Logger.Error(ex, "Error downloading audiobook {@Book}", dlOptions.LibraryBook.LogFriendly()); + //don't throw any exceptions so the caller can delete any temp files. + return result; + } + finally + { + OnStreamingProgressChanged(new() { ProgressPercentage = 100 }); + } + + void AbDownloader_TempFileCreated(object? sender, TempFile e) + { + if (Path.GetDirectoryName(e.FilePath) == outpoutDir) + { + result.ResultFiles.Add(e); + } + else if (Path.GetDirectoryName(e.FilePath) == cacheDir) + { + result.CacheFiles.Add(e); + // Notify that the aaxc file has been created so that + // the UI can know about partially-downloaded files + if (getFileType(e) is FileType.AAXC) + OnFileCreated(dlOptions.LibraryBook, e.FilePath); + } + } + } + + #region Decryptor event handlers + private void Converter_RetrievedMetadata(object? sender, AAXClean.AppleTags tags) + { + if (sender is not AaxcDownloadConvertBase converter || + converter.AaxFile is not AAXClean.Mp4File aaxFile || + converter.DownloadOptions is not DownloadOptions options || + options.ChapterInfo.Chapters is not List chapters) return; #region Prevent erroneous truncation due to incorrect chapter info @@ -185,159 +201,312 @@ namespace FileLiberator //the chapter. This is never desirable, so pad the last chapter to match //the original audio length. - var fileDuration = converter.AaxFile.Duration; - if (options.Config.StripAudibleBrandAudio) - fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs); + var fileDuration = aaxFile.Duration; + if (options.Config.StripAudibleBrandAudio) + fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs); - var durationDelta = fileDuration - options.ChapterInfo.EndOffset; + var durationDelta = fileDuration - options.ChapterInfo.EndOffset; //Remove the last chapter and re-add it with the durationDelta that will - //make the chapter's end coincide with the end of the audio file. - var chapters = options.ChapterInfo.Chapters as List; + //make the chapter's end coincide with the end of the audio file. var lastChapter = chapters[^1]; chapters.Remove(lastChapter); options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta); - - #endregion + + #endregion tags.Title ??= options.LibraryBookDto.TitleWithSubtitle; - tags.Album ??= tags.Title; - tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name)); - tags.AlbumArtists ??= tags.Artist; + tags.Album ??= tags.Title; + tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name)); + tags.AlbumArtists ??= tags.Artist; tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames()); - tags.ProductID ??= options.ContentMetadata.ContentReference.Sku; - tags.Comment ??= options.LibraryBook.Book.Description; - tags.LongDescription ??= tags.Comment; - tags.Publisher ??= options.LibraryBook.Book.Publisher; + tags.ProductID ??= options.ContentMetadata.ContentReference.Sku; + tags.Comment ??= options.LibraryBook.Book.Description; + tags.LongDescription ??= tags.Comment; + tags.Publisher ??= options.LibraryBook.Book.Publisher; tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name)); tags.Asin = options.LibraryBook.Book.AudibleProductId; tags.Acr = options.ContentMetadata.ContentReference.Acr; tags.Version = options.ContentMetadata.ContentReference.Version; if (options.LibraryBook.Book.DatePublished is DateTime pubDate) - { - tags.Year ??= pubDate.Year.ToString(); - tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy"); - } - } - - private static void downloadValidation(LibraryBook libraryBook) - { - string errorString(string field) - => $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book."; - - string errorTitle() - { - var title - = (libraryBook.Book.TitleWithSubtitle.Length > 53) - ? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..." - : libraryBook.Book.TitleWithSubtitle; - var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]"; - return errorBookTitle; - }; - - if (string.IsNullOrWhiteSpace(libraryBook.Account)) - throw new Exception(errorString("Account")); - - if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale)) - throw new Exception(errorString("Locale")); - } - - private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e) - { - if (Configuration.Instance.AllowLibationFixup) - { - try - { - e = OnRequestCoverArt(); - abDownloader.SetCoverArt(e); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server."); - } - } - - if (e is not null) - OnCoverImageDiscovered(e); + { + tags.Year ??= pubDate.Year.ToString(); + tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy"); + } } - /// Move new files to 'Books' directory - /// Return directory if audiobook file(s) were successfully created and can be located on disk. Else null. - private static void moveFilesToBooksDir(LibraryBook libraryBook, List entries, CancellationToken cancellationToken) - { - // create final directory. move each file into it - var destinationDir = getDestinationDirectory(libraryBook); - cancellationToken.ThrowIfCancellationRequested(); - - for (var i = 0; i < entries.Count; i++) - { - var entry = entries[i]; - - var realDest - = FileUtility.SaferMoveToValidPath( - entry.Path, - Path.Combine(destinationDir, Path.GetFileName(entry.Path)), - Configuration.Instance.ReplacementCharacters, - overwrite: Configuration.Instance.OverwriteExisting); - - SetFileTime(libraryBook, realDest); - FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest); - - // propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop) - entries[i] = entry with { Path = realDest }; - cancellationToken.ThrowIfCancellationRequested(); + private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e) + { + if (Configuration.Instance.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader) + { + try + { + e = OnRequestCoverArt(); + downloader.SetCoverArt(e); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server."); + } } - var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue); - if (cue != default) - { - Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path); - SetFileTime(libraryBook, cue.Path); + if (e is not null) + OnCoverImageDiscovered(e); + } + #endregion + + #region Validation + + private static void DownloadValidation(LibraryBook libraryBook) + { + string errorString(string field) + => $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book."; + + string errorTitle() + { + var title + = (libraryBook.Book.TitleWithSubtitle.Length > 53) + ? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..." + : libraryBook.Book.TitleWithSubtitle; + var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]"; + return errorBookTitle; + }; + + if (string.IsNullOrWhiteSpace(libraryBook.Account)) + throw new InvalidOperationException(errorString("Account")); + + if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale)) + throw new InvalidOperationException(errorString("Locale")); + } + #endregion + + #region Post-success routines + /// Read the audio format from the audio file's metadata. + public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile) + { + try + { + return firstAudioFile.Extension.ToLowerInvariant() switch + { + ".m4b" or ".m4a" or ".mp4" => GetMp4AudioFormat(), + ".mp3" => AudioFormatDecoder.FromMpeg3(firstAudioFile.FilePath), + _ => AudioFormat.Default + }; + } + catch (Exception ex) + { + //Failure to determine output audio format should not be considered a failure to download the book + Serilog.Log.Logger.Error(ex, "Error determining output audio format for {@Book}. File = '{@audioFile}'", options.LibraryBook, firstAudioFile); + return AudioFormat.Default; + } + + AudioFormat GetMp4AudioFormat() + => abDownloader is AaxcDownloadConvertBase converter && converter.AaxFile is AAXClean.Mp4File mp4File + ? AudioFormatDecoder.FromMpeg4(mp4File) + : AudioFormatDecoder.FromMpeg4(firstAudioFile.FilePath); + } + + /// Move new files to 'Books' directory + /// Return directory if audiobook file(s) were successfully created and can be located on disk. Else null. + private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List entries, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + AverageSpeed averageSpeed = new(); + + var totalSizeToMove = entries.Sum(f => new FileInfo(f.FilePath).Length); + long totalBytesMoved = 0; + + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + + var destFileName + = AudibleFileStorage.Audio.GetCustomDirFilename( + libraryBook, + destinationDir, + entry.Extension, + entry.PartProperties, + Configuration.Instance.OverwriteExisting); + + var realDest + = FileUtility.SaferMoveToValidPath( + entry.FilePath, + destFileName, + Configuration.Instance.ReplacementCharacters, + entry.Extension, + Configuration.Instance.OverwriteExisting); + + #region File Move Progress + totalBytesMoved += new FileInfo(realDest).Length; + averageSpeed.AddPosition(totalBytesMoved); + var estSecsRemaining = (totalSizeToMove - totalBytesMoved) / averageSpeed.Average; + + if (double.IsNormal(estSecsRemaining)) + OnStreamingTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining)); + + OnStreamingProgressChanged(new DownloadProgress + { + ProgressPercentage = 100d * totalBytesMoved / totalSizeToMove, + BytesReceived = totalBytesMoved, + TotalBytesToReceive = totalSizeToMove + }); + #endregion + + // propagate corrected path for cue file (after this for-loop) + entries[i] = entry with { FilePath = realDest }; + + SetFileTime(libraryBook, realDest); + OnFileCreated(libraryBook, realDest); + cancellationToken.ThrowIfCancellationRequested(); + } + + if (entries.FirstOrDefault(f => getFileType(f) is FileType.Cue) is TempFile cue + && getFirstAudioFile(entries)?.FilePath is LongPath audioFilePath) + { + Cue.UpdateFileName(cue.FilePath, audioFilePath); + SetFileTime(libraryBook, cue.FilePath); } cancellationToken.ThrowIfCancellationRequested(); AudibleFileStorage.Audio.Refresh(); - } - - private static string getDestinationDirectory(LibraryBook libraryBook) - { - var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); - if (!Directory.Exists(destinationDir)) - Directory.CreateDirectory(destinationDir); - return destinationDir; } - private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable entries) - => entries.FirstOrDefault(f => f.FileType == FileType.Audio); + private void DownloadCoverArt(LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken) + { + if (!options.Config.DownloadCoverArt) return; - private static void downloadCoverArt(DownloadOptions options, CancellationToken cancellationToken) - { - if (!Configuration.Instance.DownloadCoverArt) return; + var coverPath = "[null]"; - var coverPath = "[null]"; + try + { + coverPath + = AudibleFileStorage.Audio.GetCustomDirFilename( + options.LibraryBook, + destinationDir, + extension: ".jpg", + returnFirstExisting: Configuration.Instance.OverwriteExisting); - try - { - var destinationDir = getDestinationDirectory(options.LibraryBook); - coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg"); - coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath)); + if (File.Exists(coverPath)) + FileUtility.SaferDelete(coverPath); - if (File.Exists(coverPath)) - FileUtility.SaferDelete(coverPath); + var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken); + if (picBytes.Length > 0) + { + File.WriteAllBytes(coverPath, picBytes); + SetFileTime(options.LibraryBook, coverPath); + OnFileCreated(options.LibraryBook, coverPath); + } + } + catch (Exception ex) + { + //Failure to download cover art should not be considered a failure to download the book + if (!cancellationToken.IsCancellationRequested) + Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {@metadataFile}.", options.LibraryBook, coverPath); + throw; + } + } - var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken); - if (picBytes.Length > 0) - { - File.WriteAllBytes(coverPath, picBytes); - SetFileTime(options.LibraryBook, coverPath); - } - } - catch (Exception ex) - { - //Failure to download cover art should not be considered a failure to download the book - Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product."); - throw; - } - } - } + public async Task DownloadRecordsAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken) + { + if (!options.Config.DownloadClipsBookmarks) return; + + var recordsPath = "[null]"; + var format = options.Config.ClipsBookmarksFileFormat; + var formatExtension = FileUtility.GetStandardizedExtension(format.ToString().ToLowerInvariant()); + + try + { + recordsPath + = AudibleFileStorage.Audio.GetCustomDirFilename( + options.LibraryBook, + destinationDir, + extension: formatExtension, + returnFirstExisting: Configuration.Instance.OverwriteExisting); + + if (File.Exists(recordsPath)) + FileUtility.SaferDelete(recordsPath); + + var records = await api.GetRecordsAsync(options.AudibleProductId); + + switch (format) + { + case Configuration.ClipBookmarkFormat.CSV: + RecordExporter.ToCsv(recordsPath, records); + break; + case Configuration.ClipBookmarkFormat.Xlsx: + RecordExporter.ToXlsx(recordsPath, records); + break; + case Configuration.ClipBookmarkFormat.Json: + RecordExporter.ToJson(recordsPath, options.LibraryBook, records); + break; + default: + throw new NotSupportedException($"Unsupported record export format: {format}"); + } + + SetFileTime(options.LibraryBook, recordsPath); + OnFileCreated(options.LibraryBook, recordsPath); + } + catch (Exception ex) + { + //Failure to download records should not be considered a failure to download the book + if (!cancellationToken.IsCancellationRequested) + Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {@recordsPath}.", options.LibraryBook, recordsPath); + throw; + } + } + + private async Task DownloadMetadataAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken) + { + if (!options.Config.SaveMetadataToFile) return; + + string metadataPath = "[null]"; + + try + { + metadataPath + = AudibleFileStorage.Audio.GetCustomDirFilename( + options.LibraryBook, + destinationDir, + extension: ".metadata.json", + returnFirstExisting: Configuration.Instance.OverwriteExisting); + + if (File.Exists(metadataPath)) + FileUtility.SaferDelete(metadataPath); + + var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); + item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo)); + item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference)); + + cancellationToken.ThrowIfCancellationRequested(); + File.WriteAllText(metadataPath, item.SourceJson.ToString()); + SetFileTime(options.LibraryBook, metadataPath); + OnFileCreated(options.LibraryBook, metadataPath); + } + catch (Exception ex) + { + //Failure to download metadata should not be considered a failure to download the book + if (!cancellationToken.IsCancellationRequested) + Serilog.Log.Logger.Error(ex, "Error downloading metdatat of {@Book} to {@metadataFile}.", options.LibraryBook, metadataPath); + throw; + } + } + #endregion + + #region Macros + private static string getDestinationDirectory(LibraryBook libraryBook) + { + var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); + if (!Directory.Exists(destinationDir)) + Directory.CreateDirectory(destinationDir); + return destinationDir; + } + + private static FileType getFileType(TempFile file) + => FileTypes.GetFileTypeFromPath(file.FilePath); + private static TempFile? getFirstAudioFile(IEnumerable entries) + => entries.FirstOrDefault(f => File.Exists(f.FilePath) && getFileType(f) is FileType.Audio); + private static IEnumerable getAaxcFiles(IEnumerable entries) + => entries.Where(f => File.Exists(f.FilePath) && (getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase))); + #endregion + } } diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 95fdacb7..af58d360 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -112,7 +111,6 @@ public partial class DownloadOptions } } - private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) { long chapterStartMs @@ -126,13 +124,6 @@ public partial class DownloadOptions RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs), }; - if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels)) - { - dlOptions.LibraryBookDto.BitRate = bitrate; - dlOptions.LibraryBookDto.SampleRate = sampleRate; - dlOptions.LibraryBookDto.Channels = channels; - } - var titleConcat = config.CombineNestedChapterTitles ? ": " : null; var chapters = flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat) @@ -159,43 +150,6 @@ public partial class DownloadOptions return dlOptions; } - /// - /// The most reliable way to get these audio file properties is from the filename itself. - /// Using AAXClean to read the metadata works well for everything except AC-4 bitrate. - /// - private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels) - { - bitrate = sampleRate = channels = null; - - if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri)) - return false; - - var file = Path.GetFileName(uri.LocalPath); - - var match = AdrmAudioProperties().Match(file); - if (match.Success) - { - bitrate = int.Parse(match.Groups[1].Value); - sampleRate = int.Parse(match.Groups[2].Value); - channels = int.Parse(match.Groups[3].Value); - return true; - } - else if ((match = WidevineAudioProperties().Match(file)).Success) - { - bitrate = int.Parse(match.Groups[2].Value); - sampleRate = int.Parse(match.Groups[1].Value) * 1000; - channels = match.Groups[3].Value switch - { - "ec3" => 6, - "ac4" => 3, - _ => null - }; - return true; - } - - return false; - } - public static LameConfig GetLameOptions(Configuration config) { LameConfig lameConfig = new() @@ -350,12 +304,4 @@ public partial class DownloadOptions chapters.Remove(chapters[^1]); } } - - static double RelativePercentDifference(long num1, long num2) - => Math.Abs(num1 - num2) / (double)(num1 + num2); - - [GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)] - private static partial Regex WidevineAudioProperties(); - [GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)] - private static partial Regex AdrmAudioProperties(); } diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 86777021..29e7b286 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -3,10 +3,8 @@ using AAXClean; using Dinah.Core; using DataLayer; using LibationFileManager; -using System.Threading.Tasks; using System; using System.IO; -using ApplicationServices; using LibationFileManager.Templates; #nullable enable @@ -31,12 +29,9 @@ namespace FileLiberator public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number; public NAudio.Lame.LameConfig? LameConfig { get; } public string UserAgent => AudibleApi.Resources.Download_User_Agent; - public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio; public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged; public bool CreateCueSheet => Config.CreateCueSheet; - public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks; public long DownloadSpeedBps => Config.DownloadSpeedLimit; - public bool RetainEncryptedFile => Config.RetainAaxFile; public bool FixupFile => Config.AllowLibationFixup; public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono; public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate; @@ -45,45 +40,9 @@ namespace FileLiberator public AudibleApi.Common.DrmType DrmType { get; } public AudibleApi.Common.ContentMetadata ContentMetadata { get; } - public string GetMultipartFileName(MultiConvertFileProperties props) - { - var baseDir = Path.GetDirectoryName(props.OutputFileName); - var extension = Path.GetExtension(props.OutputFileName); - return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension); - } - public string GetMultipartTitle(MultiConvertFileProperties props) => Templates.ChapterTitle.GetName(LibraryBookDto, props); - public async Task SaveClipsAndBookmarksAsync(string fileName) - { - if (DownloadClipsBookmarks) - { - var format = Config.ClipsBookmarksFileFormat; - - var formatExtension = format.ToString().ToLowerInvariant(); - var filePath = Path.ChangeExtension(fileName, formatExtension); - - var api = await LibraryBook.GetApiAsync(); - var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId); - - switch(format) - { - case Configuration.ClipBookmarkFormat.CSV: - RecordExporter.ToCsv(filePath, records); - break; - case Configuration.ClipBookmarkFormat.Xlsx: - RecordExporter.ToXlsx(filePath, records); - break; - case Configuration.ClipBookmarkFormat.Json: - RecordExporter.ToJson(filePath, LibraryBook, records); - break; - } - return filePath; - } - return string.Empty; - } - public Configuration Config { get; } private readonly IDisposable cancellation; public void Dispose() @@ -123,7 +82,6 @@ namespace FileLiberator // no null/empty check for key/iv. unencrypted files do not have them LibraryBookDto = LibraryBook.ToDto(); - LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec; cancellation = config diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 4a2f6de5..4459f09a 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -61,7 +61,13 @@ namespace FileLiberator IsPodcastParent = libraryBook.Book.IsEpisodeParent(), IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), - Language = libraryBook.Book.Language + Language = libraryBook.Book.Language, + Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, + BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate, + SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate, + Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount, + LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString(3), + FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion }; } diff --git a/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml b/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml index 82ae89cc..68f41b76 100644 --- a/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml +++ b/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml @@ -24,9 +24,8 @@ CanUserSortColumns="False" AutoGenerateColumns="False" IsReadOnly="False" - ItemsSource="{Binding Filters}" + ItemsSource="{CompiledBinding Filters}" GridLinesVisibility="All"> - @@ -38,7 +37,7 @@ VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" - IsEnabled="{Binding !IsDefault}" + IsEnabled="{CompiledBinding !IsDefault}" Click="DeleteButton_Clicked" /> @@ -48,14 +47,13 @@ - @@ -67,16 +65,19 @@ VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" - IsEnabled="{Binding !IsDefault}" - ToolTip.Tip="Export account authorization to audible-cli" - Click="MoveUpButton_Clicked" /> + Click="MoveUpButton_Clicked"> + + + + + + + - - - + @@ -86,15 +87,18 @@ VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" - IsEnabled="{Binding !IsDefault}" - ToolTip.Tip="Export account authorization to audible-cli" - Click="MoveDownButton_Clicked" /> + Click="MoveDownButton_Clicked"> + + + + + + + - - Filters { get; } = new(); + public AvaloniaList Filters { get; } = new(); public class Filter : ViewModels.ViewModelBase { @@ -17,11 +17,8 @@ namespace LibationAvalonia.Dialogs public string Name { get => _name; - set - { - this.RaiseAndSetIfChanged(ref _name, value); - } - } + set => this.RaiseAndSetIfChanged(ref _name, value); + } private string _filterString; public string FilterString @@ -35,6 +32,10 @@ namespace LibationAvalonia.Dialogs } } public bool IsDefault { get; private set; } = true; + private bool _isTop; + private bool _isBottom; + public bool IsTop { get => _isTop; set => this.RaiseAndSetIfChanged(ref _isTop, value); } + public bool IsBottom { get => _isBottom; set => this.RaiseAndSetIfChanged(ref _isBottom, value); } public QuickFilters.NamedFilter AsNamedFilter() => new(FilterString, Name); @@ -44,12 +45,12 @@ namespace LibationAvalonia.Dialogs InitializeComponent(); if (Design.IsDesignMode) { - Filters = new ObservableCollection([ - new Filter { Name = "Filter 1", FilterString = "[filter1 string]" }, + Filters = [ + new Filter { Name = "Filter 1", FilterString = "[filter1 string]", IsTop = true }, new Filter { Name = "Filter 2", FilterString = "[filter2 string]" }, new Filter { Name = "Filter 3", FilterString = "[filter3 string]" }, - new Filter { Name = "Filter 4", FilterString = "[filter4 string]" } - ]); + new Filter { Name = "Filter 4", FilterString = "[filter4 string]", IsBottom = true }, + new Filter()]; DataContext = this; return; } @@ -65,6 +66,8 @@ namespace LibationAvalonia.Dialogs ControlToFocusOnShow = this.FindControl