diff --git a/FileLiberator/AaxcDownloadDecrypt/AaxcDownloadConverter.cs b/FileLiberator/AaxcDownloadDecrypt/AaxcDownloadConverter.cs new file mode 100644 index 00000000..1462eb0a --- /dev/null +++ b/FileLiberator/AaxcDownloadDecrypt/AaxcDownloadConverter.cs @@ -0,0 +1,199 @@ +using AaxDecrypter; +using AudibleApi; +using AudibleApiDTOs; +using Dinah.Core; +using Dinah.Core.Diagnostics; +using Dinah.Core.IO; +using Dinah.Core.StepRunner; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace FileLiberator.AaxcDownloadDecrypt +{ + public interface ISimpleAaxToM4bConverter2 + { + event EventHandler DecryptProgressUpdate; + + bool Run(); + + string AppName { get; set; } + string outDir { get; } + string outputFileName { get; } + ChapterInfo chapters { get; } + Tags tags { get; } + void SetOutputFilename(string outFileName); + + } + public interface IAdvancedAaxcToM4bConverter : ISimpleAaxToM4bConverter2 + { + bool Step1_CreateDir(); + bool Step2_DownloadAndCombine(); + bool Step3_CreateCue(); + bool Step4_CreateNfo(); + bool Step5_Cleanup(); + } + class AaxcDownloadConverter : IAdvancedAaxcToM4bConverter + { + public string AppName { get; set; } = nameof(AaxcDownloadConverter); + public string outDir { get; private set; } + public string outputFileName { get; private set; } + public ChapterInfo chapters { get; private set; } + public Tags tags { get; private set; } + + public event EventHandler DecryptProgressUpdate; + + private StepSequence steps { get; } + private DownloadLicense downloadLicense { get; set; } + private string coverArtPath => Path.Combine(outDir, Path.GetFileName(outputFileName) + ".jpg"); + private string metadataPath => Path.Combine(outDir, Path.GetFileName(outputFileName) + ".ffmeta"); + + public static async Task CreateAsync(string outDirectory, DownloadLicense dlLic, ChapterInfo chapters) + { + var converter = new AaxcDownloadConverter(outDirectory, dlLic, chapters); + await converter.prelimProcessing(); + return converter; + } + + private AaxcDownloadConverter(string outDirectory, DownloadLicense dlLic, ChapterInfo chapters) + { + ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory)); + ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic)); + ArgumentValidator.EnsureNotNull(chapters, nameof(chapters)); + + if (!Directory.Exists(outDirectory)) + throw new ArgumentNullException(nameof(outDirectory), "Directory does not exist"); + outDir = outDirectory; + + steps = new StepSequence + { + Name = "Convert Aax To M4b", + + ["Step 1: Create Dir"] = Step1_CreateDir, + ["Step 2: Download and Combine Audiobook"] = Step2_DownloadAndCombine, + ["Step 3 Create Cue"] = Step3_CreateCue, + ["Step 4 Create Nfo"] = Step4_CreateNfo, + ["Step 5: Cleanup"] = Step5_Cleanup, + }; + + downloadLicense = dlLic; + this.chapters = chapters; + } + + private async Task prelimProcessing() + { + //Get metadata from the file over http + var client = new System.Net.Http.HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", Resources.UserAgent); + + var networkFile = await NetworkFileAbstraction.CreateAsync(client, new Uri(downloadLicense.DownloadUrl)); + var tagLibFile = await Task.Run(()=>TagLib.File.Create(networkFile, "audio/mp4", TagLib.ReadStyle.Average)); + + tags = new Tags(tagLibFile); + + var defaultFilename = Path.Combine( + outDir, + PathLib.ToPathSafeString(tags.author), + PathLib.ToPathSafeString(tags.title) + ".m4b" + ); + + SetOutputFilename(defaultFilename); + } + + public void SetOutputFilename(string outFileName) + { + outputFileName = PathLib.ReplaceExtension(outFileName, ".m4b"); + outDir = Path.GetDirectoryName(outputFileName); + + if (File.Exists(outputFileName)) + File.Delete(outputFileName); + } + + public bool Run() + { + var (IsSuccess, Elapsed) = steps.Run(); + + if (!IsSuccess) + { + Console.WriteLine("WARNING-Conversion failed"); + return false; + } + + var speedup = (int)(tags.duration.TotalSeconds / (long)Elapsed.TotalSeconds); + Console.WriteLine("Speedup is " + speedup + "x realtime."); + Console.WriteLine("Done"); + return true; + } + + public bool Step1_CreateDir() + { + ProcessRunner.WorkingDir = outDir; + Directory.CreateDirectory(outDir); + + return true; + } + + public bool Step2_DownloadAndCombine() + { + File.WriteAllBytes(coverArtPath, tags.coverArt); + + var ffmpegTags = tags.GenerateFfmpegTags(); + var ffmpegChapters = GenerateFfmpegChapters(chapters); + + File.WriteAllText(metadataPath, ffmpegTags + ffmpegChapters); + + var aaxcProcesser = new FFMpegAaaxcProcesser(DecryptSupportLibraries.ffmpegPath); + + aaxcProcesser.ProgressUpdate += (_, e) => + DecryptProgressUpdate?.Invoke(this, (int)e.ProgressPercent); + + aaxcProcesser.ProcessBook( + downloadLicense.DownloadUrl, + Resources.UserAgent, + downloadLicense.AudibleKey, + downloadLicense.AudibleIV, + coverArtPath, + metadataPath, + outputFileName) + .GetAwaiter() + .GetResult(); + + return aaxcProcesser.Succeeded; + } + + private static string GenerateFfmpegChapters(ChapterInfo chapters) + { + var stringBuilder = new System.Text.StringBuilder(); + + foreach (AudibleApiDTOs.Chapter c in chapters.Chapters) + { + stringBuilder.Append("[CHAPTER]\n"); + stringBuilder.Append("TIMEBASE=1/1000\n"); + stringBuilder.Append("START=" + c.StartOffsetMs + "\n"); + stringBuilder.Append("END=" + (c.StartOffsetMs + c.LengthMs) + "\n"); + stringBuilder.Append("title=" + c.Title + "\n"); + } + + return stringBuilder.ToString(); + } + + public bool Step3_CreateCue() + { + File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".cue"), Cue.CreateContents(Path.GetFileName(outputFileName), chapters)); + return true; + } + + public bool Step4_CreateNfo() + { + File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".nfo"), NFO.CreateContents(AppName, tags, chapters)); + return true; + } + + public bool Step5_Cleanup() + { + FileExt.SafeDelete(coverArtPath); + FileExt.SafeDelete(metadataPath); + return true; + } + } +} diff --git a/FileLiberator/AaxcDownloadDecrypt/Cue.cs b/FileLiberator/AaxcDownloadDecrypt/Cue.cs new file mode 100644 index 00000000..bf3de65e --- /dev/null +++ b/FileLiberator/AaxcDownloadDecrypt/Cue.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Text; +using AudibleApiDTOs; +using Dinah.Core; + +namespace FileLiberator.AaxcDownloadDecrypt +{ + public static class Cue + { + public static string CreateContents(string filePath, ChapterInfo chapters) + { + var stringBuilder = new StringBuilder(); + + stringBuilder.AppendLine(GetFileLine(filePath, "MP3")); + + var trackCount = 0; + foreach (var c in chapters.Chapters) + { + trackCount++; + var startTime = TimeSpan.FromMilliseconds(c.StartOffsetMs); + + stringBuilder.AppendLine($"TRACK {trackCount} AUDIO"); + stringBuilder.AppendLine($" TITLE \"{c.Title}\""); + stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss\\:ff}"); + } + + return stringBuilder.ToString(); + } + + public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath) + => UpdateFileName(cueFileInfo.FullName, audioFilePath); + + public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo) + => UpdateFileName(cueFilePath, audioFileInfo.FullName); + + public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo) + => UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName); + + public static void UpdateFileName(string cueFilePath, string audioFilePath) + { + var cueContents = File.ReadAllLines(cueFilePath); + + for (var i = 0; i < cueContents.Length; i++) + { + var line = cueContents[i]; + if (!line.Trim().StartsWith("FILE") || !line.Contains(" ")) + continue; + + var fileTypeBegins = line.LastIndexOf(" ") + 1; + cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]); + break; + } + + File.WriteAllLines(cueFilePath, cueContents); + } + + private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}"; + } +} diff --git a/FileLiberator/AaxcDownloadDecrypt/DownloadBookDummy.cs b/FileLiberator/AaxcDownloadDecrypt/DownloadBookDummy.cs new file mode 100644 index 00000000..52b74d51 --- /dev/null +++ b/FileLiberator/AaxcDownloadDecrypt/DownloadBookDummy.cs @@ -0,0 +1,19 @@ +using DataLayer; +using Dinah.Core.ErrorHandling; +using System.Threading.Tasks; + +namespace FileLiberator.AaxcDownloadDecrypt +{ + public class DownloadBookDummy : DownloadableBase + { + public override async Task ProcessItemAsync(LibraryBook libraryBook) + { + return new StatusHandler(); + } + + public override bool Validate(LibraryBook libraryBook) + { + return true; + } + } +} diff --git a/FileLiberator/AaxcDownloadDecrypt/DownloadDecryptBook.cs b/FileLiberator/AaxcDownloadDecrypt/DownloadDecryptBook.cs new file mode 100644 index 00000000..896663b9 --- /dev/null +++ b/FileLiberator/AaxcDownloadDecrypt/DownloadDecryptBook.cs @@ -0,0 +1,182 @@ +using DataLayer; +using Dinah.Core; +using Dinah.Core.ErrorHandling; +using FileManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace FileLiberator.AaxcDownloadDecrypt +{ + public class DownloadDecryptBook : IDecryptable + { + public event EventHandler Begin; + public event EventHandler DecryptBegin; + public event EventHandler TitleDiscovered; + public event EventHandler AuthorsDiscovered; + public event EventHandler NarratorsDiscovered; + public event EventHandler CoverImageFilepathDiscovered; + public event EventHandler UpdateProgress; + public event EventHandler DecryptCompleted; + public event EventHandler Completed; + public event EventHandler StatusUpdate; + + public async Task ProcessAsync(LibraryBook libraryBook) + { + Begin?.Invoke(this, libraryBook); + + try + { + + if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId)) + return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; + + var outputAudioFilename = await aaxToM4bConverterDecryptAsync(AudibleFileStorage.DecryptInProgress, libraryBook); + + // decrypt failed + if (outputAudioFilename is null) + return new StatusHandler { "Decrypt failed" }; + + // moves files and returns dest dir. Do not put inside of if(RetainAaxFiles) + _ = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename); + + var finalAudioExists = AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId); + if (!finalAudioExists) + return new StatusHandler { "Cannot find final audio file after decryption" }; + + return new StatusHandler(); + } + finally + { + Completed?.Invoke(this, libraryBook); + } + + } + + private async Task aaxToM4bConverterDecryptAsync(string destinationDir, LibraryBook libraryBook) + { + DecryptBegin?.Invoke(this, $"Begin decrypting {libraryBook}"); + + try + { + validate(libraryBook); + + var api = await InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale); + + var dlLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId); + + var contentMetadata = await api.GetLibraryBookMetadataAsync(libraryBook.Book.AudibleProductId); + + + var newDownloader = await AaxcDownloadConverter.CreateAsync(Path.GetDirectoryName(destinationDir), dlLic, contentMetadata?.ChapterInfo); + + newDownloader.AppName = "Libation"; + + TitleDiscovered?.Invoke(this, newDownloader.tags.title); + AuthorsDiscovered?.Invoke(this, newDownloader.tags.author); + NarratorsDiscovered?.Invoke(this, newDownloader.tags.narrator); + CoverImageFilepathDiscovered?.Invoke(this, newDownloader.tags.coverArt); + + // override default which was set in CreateAsync + var proposedOutputFile = Path.Combine(destinationDir, $"{libraryBook.Book.Title} [{libraryBook.Book.AudibleProductId}].m4b"); + newDownloader.SetOutputFilename(proposedOutputFile); + newDownloader.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress); + + // REAL WORK DONE HERE + var success = await Task.Run(() => newDownloader.Run()); + + // decrypt failed + if (!success) + return null; + + return newDownloader.outputFileName; + } + finally + { + DecryptCompleted?.Invoke(this, $"Completed downloading and decrypting {libraryBook.Book.Title}"); + } + } + + private static string moveFilesToBooksDir(Book product, string outputAudioFilename) + { + // create final directory. move each file into it. MOVE AUDIO FILE LAST + // new dir: safetitle_limit50char + " [" + productId + "]" + + var destinationDir = AudibleFileStorage.Audio.GetDestDir(product.Title, product.AudibleProductId); + Directory.CreateDirectory(destinationDir); + + var sortedFiles = getProductFilesSorted(product, outputAudioFilename); + + var musicFileExt = Path.GetExtension(outputAudioFilename).Trim('.'); + + // audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext + var audioFileName = FileUtility.GetValidFilename(destinationDir, product.Title, musicFileExt, product.AudibleProductId); + + foreach (var f in sortedFiles) + { + var dest + = AudibleFileStorage.Audio.IsFileTypeMatch(f) + ? audioFileName + // non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext + : FileUtility.GetValidFilename(destinationDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt); + + if (Path.GetExtension(dest).Trim('.').ToLower() == "cue") + Cue.UpdateFileName(f, audioFileName); + + File.Move(f.FullName, dest); + } + + return destinationDir; + } + + private static List getProductFilesSorted(Book product, string outputAudioFilename) + { + // files are: temp path\author\[asin].ext + var m4bDir = new FileInfo(outputAudioFilename).Directory; + var files = m4bDir + .EnumerateFiles() + .Where(f => f.Name.ContainsInsensitive(product.AudibleProductId)) + .ToList(); + + // move audio files to the end of the collection so these files are moved last + var musicFiles = files.Where(f => AudibleFileStorage.Audio.IsFileTypeMatch(f)); + var sortedFiles = files + .Except(musicFiles) + .Concat(musicFiles) + .ToList(); + + return sortedFiles; + } + + private static void validate(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")); + } + + public bool Validate(LibraryBook libraryBook) + => !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId) + && !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId); + + + } +} diff --git a/FileLiberator/AaxcDownloadDecrypt/FFMpegAaaxcProcesser.cs b/FileLiberator/AaxcDownloadDecrypt/FFMpegAaaxcProcesser.cs new file mode 100644 index 00000000..5adbd403 --- /dev/null +++ b/FileLiberator/AaxcDownloadDecrypt/FFMpegAaaxcProcesser.cs @@ -0,0 +1,201 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace FileLiberator.AaxcDownloadDecrypt +{ + public class AaxcProgress : EventArgs + { + public TimeSpan ProcessedTime { get; set; } + public TimeSpan AudioDuration { get; set; } + public double ProgressPercent => Math.Round(100 * ProcessedTime.TotalSeconds / AudioDuration.TotalSeconds,2); + } + /// + /// Download audible aaxc, decrypt, remux, add metadata, and insert cover art. + /// + class FFMpegAaaxcProcesser + { + public event EventHandler ProgressUpdate; + public string FFMpegPath { get; } + public bool IsRunning { get; set; } = false; + public bool Succeeded { get; private set; } + + + private static Regex processedTimeRegex = new Regex("time=(\\d{2}):(\\d{2}):(\\d{2}).\\d{2}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static Regex durationRegex = new Regex("Duration: (\\d{2}):(\\d{2}):(\\d{2}).\\d{2}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private TimeSpan duration { get; set; } + private TimeSpan position { get; set; } + public FFMpegAaaxcProcesser(string ffmpegPath) + { + FFMpegPath = ffmpegPath; + } + + public async Task ProcessBook(string aaxcUrl, string userAgent, string audibleKey, string audibleIV, string artworkPath, string metadataPath, string outputFile) + { + //This process gets the aaxc from the url and streams the decrypted + //aac stream to standard output + var downloader = new Process + { + StartInfo = getDownloaderStartInfo(aaxcUrl, userAgent, audibleKey, audibleIV) + }; + + //This process retreves an aac stream from standard input and muxes + // it into an m4b along with the cover art and metadata. + var remuxer = new Process + { + StartInfo = getRemuxerStartInfo(artworkPath, metadataPath, outputFile) + }; + + IsRunning = true; + + downloader.ErrorDataReceived += Downloader_ErrorDataReceived; + remuxer.ErrorDataReceived += Remuxer_ErrorDataReceived; + + downloader.Start(); + downloader.BeginErrorReadLine(); + + var pipedOutput = downloader.StandardOutput.BaseStream; + + remuxer.Start(); + remuxer.BeginErrorReadLine(); + + var pipedInput = remuxer.StandardInput.BaseStream; + + int lastRead = 0; + + byte[] buffer = new byte[16 * 1024]; + + //All the work done here. Copy download standard output into + //remuxer standard input + await Task.Run(() => + { + do + { + lastRead = pipedOutput.Read(buffer, 0, buffer.Length); + pipedInput.Write(buffer, 0, lastRead); + } while (lastRead > 0 && !remuxer.HasExited); + }); + + pipedInput.Close(); + + //If the remuxer exited due to failure, downloader will still have + //data in the pipe. Force kill downloader to continue. + if (remuxer.HasExited && !downloader.HasExited) + downloader.Kill(); + + remuxer.WaitForExit(); + downloader.WaitForExit(); + + IsRunning = false; + Succeeded = downloader.ExitCode == 0 && remuxer.ExitCode == 0; + } + + private void Downloader_ErrorDataReceived(object sender, DataReceivedEventArgs e) + { + if (!string.IsNullOrEmpty(e.Data) && durationRegex.IsMatch(e.Data)) + { + //get total audio stream duration + var match = durationRegex.Match(e.Data); + + int hours = int.Parse(match.Groups[1].Value); + int minutes = int.Parse(match.Groups[2].Value); + int seconds = int.Parse(match.Groups[3].Value); + + duration = new TimeSpan(hours, minutes, seconds); + } + } + + private void Remuxer_ErrorDataReceived(object sender, DataReceivedEventArgs e) + { + if (!string.IsNullOrEmpty(e.Data) && processedTimeRegex.IsMatch(e.Data)) + { + //get timestamp of of last processed audio stream position + var match = processedTimeRegex.Match(e.Data); + + int hours = int.Parse(match.Groups[1].Value); + int minutes = int.Parse(match.Groups[2].Value); + int seconds = int.Parse(match.Groups[3].Value); + + position = new TimeSpan(hours, minutes, seconds); + + ProgressUpdate?.Invoke(sender, new AaxcProgress + { + ProcessedTime = position, + AudioDuration = duration + }); + } + } + + private ProcessStartInfo getDownloaderStartInfo(string aaxcUrl, string userAgent, string audibleKey, string audibleIV) => + new ProcessStartInfo + { + FileName = FFMpegPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(FFMpegPath), + ArgumentList ={ + "-nostdin", + "-audible_key", + audibleKey, + "-audible_iv", + audibleIV, + "-i", + aaxcUrl, + "-user_agent", + userAgent, //user-agent is requied for CDN to serve the file + "-c:a", //audio codec + "copy", //copy stream + "-f", //force output format: adts + "adts", + "pipe:" //pipe output to standard output + } + }; + + private ProcessStartInfo getRemuxerStartInfo(string artworkPath, string metadataPath, string outputFile) => + new ProcessStartInfo + { + FileName = FFMpegPath, + RedirectStandardError = true, + RedirectStandardInput = true, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(FFMpegPath), + + ArgumentList = + { + "-thread_queue_size", + "1024", + "-f", //force input format: aac + "aac", + "-i", + "pipe:", //input from standard input + "-i", + artworkPath, + "-i", + metadataPath, + "-map", + "0", + "-map", + "1", + "-map_metadata", + "2", + "-c", //codec copy + "copy", + "-c:v:1", //video codec from file [1] (artwork) + "png", //video codec + "-disposition:v:1", + "attached_pic", + "-f", //force output format: mp4 + "mp4", + outputFile, + "-y" //overwritte existing + } + }; + } +} diff --git a/FileLiberator/AaxcDownloadDecrypt/NFO.cs b/FileLiberator/AaxcDownloadDecrypt/NFO.cs new file mode 100644 index 00000000..409ec1e8 --- /dev/null +++ b/FileLiberator/AaxcDownloadDecrypt/NFO.cs @@ -0,0 +1,56 @@ +using AudibleApiDTOs; + +namespace FileLiberator.AaxcDownloadDecrypt +{ + public static class NFO + { + public static string CreateContents(string ripper, Tags tags, ChapterInfo chapters) + { + var _hours = (int)tags.duration.TotalHours; + var myDuration + = (_hours > 0 ? _hours + " hours, " : "") + + tags.duration.Minutes + " minutes, " + + tags.duration.Seconds + " seconds"; + + var header + = "General Information\r\n" + + "===================\r\n" + + $" Title: {tags.title}\r\n" + + $" Author: {tags.author}\r\n" + + $" Read By: {tags.narrator}\r\n" + + $" Copyright: {tags.year}\r\n" + + $" Audiobook Copyright: {tags.year}\r\n"; + if (tags.genre != "") + header += $" Genre: {tags.genre}\r\n"; + + var s + = header + + $" Publisher: {tags.publisher}\r\n" + + $" Duration: {myDuration}\r\n" + + $" Chapters: {chapters.Chapters.Length}\r\n" + + "\r\n" + + "\r\n" + + "Media Information\r\n" + + "=================\r\n" + + " Source Format: Audible AAX\r\n" + + $" Source Sample Rate: {tags.sampleRate} Hz\r\n" + + $" Source Channels: {tags.channels}\r\n" + + $" Source Bitrate: {tags.bitrate} kbits\r\n" + + "\r\n" + + " Lossless Encode: Yes\r\n" + + " Encoded Codec: AAC / M4B\r\n" + + $" Encoded Sample Rate: {tags.sampleRate} Hz\r\n" + + $" Encoded Channels: {tags.channels}\r\n" + + $" Encoded Bitrate: {tags.bitrate} kbits\r\n" + + "\r\n" + + $" Ripper: {ripper}\r\n" + + "\r\n" + + "\r\n" + + "Book Description\r\n" + + "================\r\n" + + tags.comments; + + return s; + } + } +} diff --git a/FileLiberator/AaxcDownloadDecrypt/NetworkFile.cs b/FileLiberator/AaxcDownloadDecrypt/NetworkFile.cs new file mode 100644 index 00000000..505b07db --- /dev/null +++ b/FileLiberator/AaxcDownloadDecrypt/NetworkFile.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace FileLiberator.AaxcDownloadDecrypt +{ + /// + /// Provides a for a file over Http. + /// + class NetworkFileAbstraction : TagLib.File.IFileAbstraction + { + private NetworkFileStream aaxNetworkStream; + + public static async Task CreateAsync(HttpClient client, Uri webFileUri) + { + var response = await client.GetAsync(webFileUri, HttpCompletionOption.ResponseHeadersRead); + + if (response.StatusCode != System.Net.HttpStatusCode.OK) + throw new Exception("Can't read file from client."); + + var contentLength = response.Content.Headers.ContentLength ?? 0; + + var networkStream = await response.Content.ReadAsStreamAsync(); + + var networkFile = new NetworkFileAbstraction(Path.GetFileName(webFileUri.LocalPath), networkStream, contentLength); + + return networkFile; + } + + private NetworkFileAbstraction(string fileName, Stream netStream, long contentLength) + { + Name = fileName; + aaxNetworkStream = new NetworkFileStream(netStream, contentLength); + } + public string Name { get; private set; } + + public Stream ReadStream => aaxNetworkStream; + + public Stream WriteStream => throw new NotImplementedException(); + + public void CloseStream(Stream stream) + { + aaxNetworkStream.Close(); + } + + private class NetworkFileStream : Stream + { + private const int BUFF_SZ = 2 * 1024; + + private FileStream _fileBacker; + + private Stream _networkStream; + + private long networkBytesRead = 0; + + private long _contentLength; + public NetworkFileStream(Stream netStream, long contentLength) + { + _networkStream = netStream; + _contentLength = contentLength; + _fileBacker = File.Create(Path.GetTempFileName(), BUFF_SZ, FileOptions.DeleteOnClose); + } + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => _contentLength; + + public override long Position { get => _fileBacker.Position; set => Seek(value, 0); } + + public override void Flush() + { + throw new NotImplementedException(); + } + public override void SetLength(long value) + { + throw new NotImplementedException(); + } + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + public override int Read(byte[] buffer, int offset, int count) + { + long requiredLength = Position + offset + count; + + if (requiredLength > networkBytesRead) + readWebFileToPosition(requiredLength); + + return _fileBacker.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) + { + long newPosition = (long)origin + offset; + + if (newPosition > networkBytesRead) + readWebFileToPosition(newPosition); + + _fileBacker.Position = newPosition; + return newPosition; + } + + public override void Close() + { + _fileBacker.Close(); + _networkStream.Close(); + } + /// + /// Read more data from into as needed. + /// + /// + private void readWebFileToPosition(long requiredLength) + { + byte[] buff = new byte[BUFF_SZ]; + + long backerPosition = _fileBacker.Position; + + _fileBacker.Position = networkBytesRead; + + while (networkBytesRead < requiredLength) + { + int bytesRead = _networkStream.Read(buff, 0, BUFF_SZ); + _fileBacker.Write(buff, 0, bytesRead); + networkBytesRead += bytesRead; + } + + _fileBacker.Position = backerPosition; + } + } + } +} diff --git a/FileLiberator/AaxcDownloadDecrypt/Tags.cs b/FileLiberator/AaxcDownloadDecrypt/Tags.cs new file mode 100644 index 00000000..4c884a0b --- /dev/null +++ b/FileLiberator/AaxcDownloadDecrypt/Tags.cs @@ -0,0 +1,81 @@ +using System; +using TagLib; +using TagLib.Mpeg4; +using Dinah.Core; + +namespace FileLiberator.AaxcDownloadDecrypt +{ + public class Tags + { + public string title { get; } + public string album { get; } + public string author { get; } + public string comments { get; } + public string narrator { get; } + public string year { get; } + public string publisher { get; } + public string id { get; } + public string genre { get; } + public TimeSpan duration { get; } + public int channels { get; } + public int bitrate { get; } + public int sampleRate { get; } + + public bool hasCoverArt { get; } + public byte[] coverArt { get; } + + // input file + public Tags(TagLib.File tagLibFile) + { + title = tagLibFile.Tag.Title?.Replace(" (Unabridged)", ""); + album = tagLibFile.Tag.Album?.Replace(" (Unabridged)", ""); + author = tagLibFile.Tag.FirstPerformer ?? "[unknown]"; + year = tagLibFile.Tag.Year.ToString(); + comments = tagLibFile.Tag.Comment ?? ""; + genre = tagLibFile.Tag.FirstGenre ?? ""; + + var tag = tagLibFile.GetTag(TagTypes.Apple, true); + publisher = tag.Publisher ?? ""; + narrator = string.IsNullOrWhiteSpace(tagLibFile.Tag.FirstComposer) ? tag.Narrator : tagLibFile.Tag.FirstComposer; + comments = !string.IsNullOrWhiteSpace(tag.LongDescription) ? tag.LongDescription : tag.Description; + id = tag.AudibleCDEK; + + hasCoverArt = tagLibFile.Tag.Pictures.Length > 0; + if (hasCoverArt) + coverArt = tagLibFile.Tag.Pictures[0].Data.Data; + + duration = tagLibFile.Properties.Duration; + + bitrate = tagLibFile.Properties.AudioBitrate; + channels = tagLibFile.Properties.AudioChannels; + sampleRate = tagLibFile.Properties.AudioSampleRate; + } + + // my best guess of what this step is doing: + // re-publish the data we read from the input file => output file + public void AddAppleTags(string file) + { + using var tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average); + var tag = (AppleTag)tagLibFile.GetTag(TagTypes.Apple, true); + tag.Publisher = publisher; + tag.LongDescription = comments; + tag.Description = comments; + tagLibFile.Save(); + } + + public string GenerateFfmpegTags() + => $";FFMETADATA1" + + $"\nmajor_brand=aax" + + $"\nminor_version=1" + + $"\ncompatible_brands=aax M4B mp42isom" + + $"\ndate={year}" + + $"\ngenre={genre}" + + $"\ntitle={title}" + + $"\nartist={author}" + + $"\nalbum={album}" + + $"\ncomposer={narrator}" + + $"\ncomment={comments.Truncate(254)}" + + $"\ndescription={comments}" + + $"\n"; + } +} diff --git a/FileLiberator/BackupBook.cs b/FileLiberator/BackupBook.cs index 72ff0dd3..604994c4 100644 --- a/FileLiberator/BackupBook.cs +++ b/FileLiberator/BackupBook.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using DataLayer; using Dinah.Core.ErrorHandling; +using FileLiberator.AaxcDownloadDecrypt; using FileManager; namespace FileLiberator @@ -21,8 +22,9 @@ namespace FileLiberator public event EventHandler StatusUpdate; public event EventHandler Completed; - public DownloadBook DownloadBook { get; } = new DownloadBook(); - public DecryptBook DecryptBook { get; } = new DecryptBook(); + + public DownloadBookDummy DownloadBook { get; } = new DownloadBookDummy(); + public DownloadDecryptBook DecryptBook { get; } = new DownloadDecryptBook(); public DownloadPdf DownloadPdf { get; } = new DownloadPdf(); public bool Validate(LibraryBook libraryBook) diff --git a/FileLiberator/DecryptLib/avcodec-58.dll b/FileLiberator/DecryptLib/avcodec-58.dll new file mode 100644 index 00000000..596f682a Binary files /dev/null and b/FileLiberator/DecryptLib/avcodec-58.dll differ diff --git a/FileLiberator/DecryptLib/avdevice-58.dll b/FileLiberator/DecryptLib/avdevice-58.dll new file mode 100644 index 00000000..f88b6c5c Binary files /dev/null and b/FileLiberator/DecryptLib/avdevice-58.dll differ diff --git a/FileLiberator/DecryptLib/avfilter-7.dll b/FileLiberator/DecryptLib/avfilter-7.dll new file mode 100644 index 00000000..6cbedea3 Binary files /dev/null and b/FileLiberator/DecryptLib/avfilter-7.dll differ diff --git a/FileLiberator/DecryptLib/avformat-58.dll b/FileLiberator/DecryptLib/avformat-58.dll new file mode 100644 index 00000000..37161814 Binary files /dev/null and b/FileLiberator/DecryptLib/avformat-58.dll differ diff --git a/FileLiberator/DecryptLib/avutil-56.dll b/FileLiberator/DecryptLib/avutil-56.dll new file mode 100644 index 00000000..d1565ae4 Binary files /dev/null and b/FileLiberator/DecryptLib/avutil-56.dll differ diff --git a/FileLiberator/DecryptLib/ffmpeg.exe b/FileLiberator/DecryptLib/ffmpeg.exe new file mode 100644 index 00000000..f2840373 Binary files /dev/null and b/FileLiberator/DecryptLib/ffmpeg.exe differ diff --git a/FileLiberator/DecryptLib/ffprobe.exe b/FileLiberator/DecryptLib/ffprobe.exe new file mode 100644 index 00000000..6028bea1 Binary files /dev/null and b/FileLiberator/DecryptLib/ffprobe.exe differ diff --git a/FileLiberator/DecryptLib/swresample-3.dll b/FileLiberator/DecryptLib/swresample-3.dll new file mode 100644 index 00000000..d2f5ea87 Binary files /dev/null and b/FileLiberator/DecryptLib/swresample-3.dll differ diff --git a/FileLiberator/DecryptLib/swscale-5.dll b/FileLiberator/DecryptLib/swscale-5.dll new file mode 100644 index 00000000..a30c307e Binary files /dev/null and b/FileLiberator/DecryptLib/swscale-5.dll differ diff --git a/FileLiberator/DecryptLib/taglib-sharp.dll b/FileLiberator/DecryptLib/taglib-sharp.dll new file mode 100644 index 00000000..87ec4bf1 Binary files /dev/null and b/FileLiberator/DecryptLib/taglib-sharp.dll differ