using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using AaxDecrypter; using ApplicationServices; using AudibleApi; using DataLayer; using Dinah.Core; using Dinah.Core.ErrorHandling; using FileManager; using LibationFileManager; namespace FileLiberator { public class DownloadDecryptBook : AudioDecodable { public override string Name => "Download & Decrypt"; private AudiobookDownloadBase abDownloader; public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists(); public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask; 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); try { if (libraryBook.Book.Audio_Exists()) return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; bool success = false; try { FilePathCache.Inserted += FilePathCache_Inserted; FilePathCache.Removed += FilePathCache_Removed; success = await downloadAudiobookAsync(libraryBook); } finally { FilePathCache.Inserted -= FilePathCache_Inserted; FilePathCache.Removed -= FilePathCache_Removed; } // decrypt failed if (!success) { foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC)) FileUtility.SaferDelete(tmpFile.Path); return abDownloader?.IsCanceled == true ? new StatusHandler { "Cancelled" } : new StatusHandler { "Decrypt failed" }; } // moves new files from temp dir to final dest. // This could take a few seconds if moving hundreds of files. var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries)); // decrypt failed if (!movedAudioFile) return new StatusHandler { "Cannot find final audio file after decryption" }; if (Configuration.Instance.DownloadCoverArt) DownloadCoverArt(libraryBook); libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated); return new StatusHandler(); } finally { OnCompleted(libraryBook); } } private async Task downloadAudiobookAsync(LibraryBook libraryBook) { var config = Configuration.Instance; downloadValidation(libraryBook); var api = await libraryBook.GetApiAsync(); var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId); var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic); var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower()); var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm) 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 += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames()); 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(libraryBook, path); // REAL WORK DONE HERE var success = await abDownloader.RunAsync(); return success; } private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic) { //I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3. //I also assume that if DrmType != Adrm, the file will be an mp3. //These assumptions may be wrong, and only time and bug reports will tell. bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm; var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ? OutputFormat.Mp3 : OutputFormat.M4b; var dlOptions = new DownloadOptions ( libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl, Resources.USER_AGENT ) { AudibleKey = contentLic?.Voucher?.Key, AudibleIV = contentLic?.Voucher?.Iv, OutputFormat = outputFormat, TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio, RetainEncryptedFile = config.RetainAaxFile && encrypted, StripUnabridged = config.AllowLibationFixup && config.StripUnabridged, Downsample = config.AllowLibationFixup && config.LameDownsampleMono, MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate, CreateCueSheet = config.CreateCueSheet, LameConfig = GetLameOptions(config) }; var chapters = getChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList(); if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3) { long startMs = dlOptions.TrimOutputToChapterLength ? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0; dlOptions.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs)); for (int i = 0; i < chapters.Count; i++) { var chapter = chapters[i]; long chapLenMs = chapter.LengthMs; if (i == 0) chapLenMs -= startMs; if (config.StripAudibleBrandAudio && i == chapters.Count - 1) chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs; dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs)); } } return dlOptions; } private List getChapters(IEnumerable chapters) { List chaps = new(); foreach (var c in chapters) { if (c.Chapters is not null) { var firstSub = new AudibleApi.Common.Chapter { Title = $"{c.Title}: {c.Chapters[0].Title}", StartOffsetMs = c.StartOffsetMs, StartOffsetSec = c.StartOffsetSec, LengthMs = c.LengthMs + c.Chapters[0].LengthMs }; chaps.Add(firstSub); var children = getChapters(c.Chapters[1..]); foreach (var child in children) child.Title = string.IsNullOrEmpty(c.Title) ? child.Title : $"{c.Title}: {child.Title}"; chaps.AddRange(children); } else chaps.Add(c); } return chaps; } 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.Title.Length > 53) ? $"{libraryBook.Book.Title.Truncate(50)}..." : libraryBook.Book.Title; 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 (e is not null) OnCoverImageDiscovered(e); else if (Configuration.Instance.AllowLibationFixup) abDownloader.SetCoverArt(OnRequestCoverArt()); } /// Move new files to 'Books' directory /// True if audiobook file(s) were successfully created and can be located on disk. Else false. private static bool moveFilesToBooksDir(LibraryBook libraryBook, List entries) { // create final directory. move each file into it var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); Directory.CreateDirectory(destinationDir); FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio); if (getFirstAudio() == default) return false; 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); 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 }; } var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue); if (cue != default) Cue.UpdateFileName(cue.Path, getFirstAudio().Path); AudibleFileStorage.Audio.Refresh(); return true; } private void DownloadCoverArt(LibraryBook libraryBook) { var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg"); coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath)); try { if (File.Exists(coverPath)) FileUtility.SaferDelete(coverPath); (string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ? (libraryBook.Book.PictureId, PictureSize.Native) : (libraryBook.Book.PictureLarge, PictureSize.Native); var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size)); if (picBytes.Length > 0) File.WriteAllBytes(coverPath, picBytes); } 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 {libraryBook.Book.AudibleProductId} to {coverPath} catalog product."); } } } }