Completely redesigned donload and decrypt.
This commit is contained in:
parent
d619c82fd8
commit
1e88070f3a
199
FileLiberator/AaxcDownloadDecrypt/AaxcDownloadConverter.cs
Normal file
199
FileLiberator/AaxcDownloadDecrypt/AaxcDownloadConverter.cs
Normal file
@ -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<int> 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<int> 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<AaxcDownloadConverter> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
FileLiberator/AaxcDownloadDecrypt/Cue.cs
Normal file
60
FileLiberator/AaxcDownloadDecrypt/Cue.cs
Normal file
@ -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}";
|
||||
}
|
||||
}
|
||||
19
FileLiberator/AaxcDownloadDecrypt/DownloadBookDummy.cs
Normal file
19
FileLiberator/AaxcDownloadDecrypt/DownloadBookDummy.cs
Normal file
@ -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<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
|
||||
{
|
||||
return new StatusHandler();
|
||||
}
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
182
FileLiberator/AaxcDownloadDecrypt/DownloadDecryptBook.cs
Normal file
182
FileLiberator/AaxcDownloadDecrypt/DownloadDecryptBook.cs
Normal file
@ -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<LibraryBook> Begin;
|
||||
public event EventHandler<string> DecryptBegin;
|
||||
public event EventHandler<string> TitleDiscovered;
|
||||
public event EventHandler<string> AuthorsDiscovered;
|
||||
public event EventHandler<string> NarratorsDiscovered;
|
||||
public event EventHandler<byte[]> CoverImageFilepathDiscovered;
|
||||
public event EventHandler<int> UpdateProgress;
|
||||
public event EventHandler<string> DecryptCompleted;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
|
||||
public async Task<StatusHandler> 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<string> 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<FileInfo> 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);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
201
FileLiberator/AaxcDownloadDecrypt/FFMpegAaaxcProcesser.cs
Normal file
201
FileLiberator/AaxcDownloadDecrypt/FFMpegAaaxcProcesser.cs
Normal file
@ -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);
|
||||
}
|
||||
/// <summary>
|
||||
/// Download audible aaxc, decrypt, remux, add metadata, and insert cover art.
|
||||
/// </summary>
|
||||
class FFMpegAaaxcProcesser
|
||||
{
|
||||
public event EventHandler<AaxcProgress> 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
56
FileLiberator/AaxcDownloadDecrypt/NFO.cs
Normal file
56
FileLiberator/AaxcDownloadDecrypt/NFO.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
135
FileLiberator/AaxcDownloadDecrypt/NetworkFile.cs
Normal file
135
FileLiberator/AaxcDownloadDecrypt/NetworkFile.cs
Normal file
@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator.AaxcDownloadDecrypt
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides a <see cref="TagLib.File.IFileAbstraction"/> for a file over Http.
|
||||
/// </summary>
|
||||
class NetworkFileAbstraction : TagLib.File.IFileAbstraction
|
||||
{
|
||||
private NetworkFileStream aaxNetworkStream;
|
||||
|
||||
public static async Task<NetworkFileAbstraction> 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();
|
||||
}
|
||||
/// <summary>
|
||||
/// Read more data from <see cref="_networkStream"/> into <see cref="_fileBacker"/> as needed.
|
||||
/// </summary>
|
||||
/// <param name="requiredLength"></param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
FileLiberator/AaxcDownloadDecrypt/Tags.cs
Normal file
81
FileLiberator/AaxcDownloadDecrypt/Tags.cs
Normal file
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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<string> StatusUpdate;
|
||||
public event EventHandler<LibraryBook> 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)
|
||||
|
||||
BIN
FileLiberator/DecryptLib/avcodec-58.dll
Normal file
BIN
FileLiberator/DecryptLib/avcodec-58.dll
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/avdevice-58.dll
Normal file
BIN
FileLiberator/DecryptLib/avdevice-58.dll
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/avfilter-7.dll
Normal file
BIN
FileLiberator/DecryptLib/avfilter-7.dll
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/avformat-58.dll
Normal file
BIN
FileLiberator/DecryptLib/avformat-58.dll
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/avutil-56.dll
Normal file
BIN
FileLiberator/DecryptLib/avutil-56.dll
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/ffmpeg.exe
Normal file
BIN
FileLiberator/DecryptLib/ffmpeg.exe
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/ffprobe.exe
Normal file
BIN
FileLiberator/DecryptLib/ffprobe.exe
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/swresample-3.dll
Normal file
BIN
FileLiberator/DecryptLib/swresample-3.dll
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/swscale-5.dll
Normal file
BIN
FileLiberator/DecryptLib/swscale-5.dll
Normal file
Binary file not shown.
BIN
FileLiberator/DecryptLib/taglib-sharp.dll
Normal file
BIN
FileLiberator/DecryptLib/taglib-sharp.dll
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user