using Dinah.Core; using Dinah.Core.Net.Http; using Dinah.Core.StepRunner; using FileManager; using System; using System.IO; using System.Threading.Tasks; 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 bool IsCanceled { get; protected set; } protected AsyncStepSequence AsyncSteps { get; } = new(); protected string OutputFileName { get; } protected IDownloadOptions DownloadOptions { get; } protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream; private readonly NetworkFileStreamPersister nfsPersister; 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)); var outDir = Path.GetDirectoryName(OutputFileName); if (!Directory.Exists(outDir)) Directory.CreateDirectory(outDir); if (!Directory.Exists(cacheDirectory)) Directory.CreateDirectory(cacheDirectory); jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".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, ProgressPercentage = 0, TotalBytesToReceive = InputFileStream.Length }; OnDecryptProgressUpdate(zeroProgress); } public async Task RunAsync() { AsyncSteps[$"Cleanup"] = CleanupAsync; (bool success, var elapsed) = await AsyncSteps.RunAsync(); var speedup = DownloadOptions.RuntimeLength / elapsed; Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); return success; } public abstract Task CancelAsync(); protected abstract Task Step_DownloadAndDecryptAudiobookAsync(); public virtual void SetCoverArt(byte[] coverArt) { if (coverArt is not null) OnRetrievedCoverArt(coverArt); } protected void OnRetrievedTitle(string title) => RetrievedTitle?.Invoke(this, title); protected void OnRetrievedAuthors(string authors) => RetrievedAuthors?.Invoke(this, authors); protected void OnRetrievedNarrators(string narrators) => RetrievedNarrators?.Invoke(this, narrators); 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); protected virtual void FinalizeDownload() { nfsPersister?.Dispose(); OnDecryptProgressUpdate(zeroProgress); } 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; // 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); } catch (Exception ex) { Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed"); } return !IsCanceled; } private async Task CleanupAsync() { if (IsCanceled) return false; FileUtility.SaferDelete(jsonDownloadState); if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) && !string.IsNullOrEmpty(DownloadOptions.AudibleIV) && DownloadOptions.RetainEncryptedFile) { string aaxPath = Path.ChangeExtension(tempFilePath, ".aax"); FileUtility.SaferMove(tempFilePath, aaxPath); //Write aax decryption key string keyPath = Path.ChangeExtension(aaxPath, ".key"); FileUtility.SaferDelete(keyPath); await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}"); OnFileCreated(aaxPath); OnFileCreated(keyPath); } else FileUtility.SaferDelete(tempFilePath); return !IsCanceled; } private NetworkFileStreamPersister OpenNetworkFileStream() { NetworkFileStreamPersister nfsp = default; try { if (!File.Exists(jsonDownloadState)) return nfsp = newNetworkFilePersister(); nfsp = new NetworkFileStreamPersister(jsonDownloadState); // The download url expires after 1 hour. // The new url points to the same file. nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl)); return nfsp; } catch { FileUtility.SaferDelete(jsonDownloadState); FileUtility.SaferDelete(tempFilePath); return nfsp = newNetworkFilePersister(); } finally { nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; } NetworkFileStreamPersister newNetworkFilePersister() { var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } }); return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState); } } } }