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 CancellationTokenSource? cancellationTokenSource; private AudiobookDownloadBase? abDownloader; public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists(); 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) { 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" }; DownloadValidation(libraryBook); var api = await libraryBook.GetApiAsync(); using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken); var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken); if (!result.Success || getFirstAudioFile(result.ResultFiles) == default) { // 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" }; } 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 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) { //Swallow DownloadCoverArt, SetCoverAsFolderIcon, and SaveMetadataAsync exceptions. //Only fail if the downloaded audio files failed to move to Books directory } finally { if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested) { libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion!); 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); } } } return new StatusHandler(); } catch when (cancellationToken.IsCancellationRequested) { Serilog.Log.Logger.Information("Download/Decrypt was cancelled. {@Book}", libraryBook.LogFriendly()); return new StatusHandler { "Cancelled" }; } finally { OnCompleted(libraryBook); cancellationTokenSource.Dispose(); cancellationTokenSource = null; } } private record AudiobookDecryptResult(bool Success, List ResultFiles, List CacheFiles); private async Task DownloadAudiobookAsync(AudibleApi.Api api, DownloadOptions dlOptions, CancellationToken cancellationToken) { 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 ? 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 //Sometimes the chapter info is not accurate. Since AAXClean trims audio //files to the chapters start and end, if the last chapter's end time is //before the end of the audio file, the file will be truncated to match //the chapter. This is never desirable, so pad the last chapter to match //the original audio length. var fileDuration = aaxFile.Duration; if (options.Config.StripAudibleBrandAudio) fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs); 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 lastChapter = chapters[^1]; chapters.Remove(lastChapter); options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta); #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.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.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 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."); } } 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 /// 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 void DownloadCoverArt(LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken) { if (!options.Config.DownloadCoverArt) return; var coverPath = "[null]"; try { coverPath = AudibleFileStorage.Audio.GetCustomDirFilename( options.LibraryBook, destinationDir, extension: ".jpg", returnFirstExisting: Configuration.Instance.OverwriteExisting); 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; } } 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 => getFileType(f) is FileType.Audio); private static IEnumerable getAaxcFiles(IEnumerable entries) => entries.Where(f => getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase)); #endregion } }