Refactor and optimize audiobook download and decrypt process
- Add more null safety - Fix possible FilePathCache race condition - Add MoveFilesToBooksDir progress reporting - All metadata is now downloaded in parallel with other post-success tasks. - Improve download resuming and file cleanup reliability - The downloader creates temp files with a UUID filename and does not insert them into the FilePathCache. Created files only receive their final file names when they are moved into the Books folder. This is to prepare for a future plan re naming templates
This commit is contained in:
parent
1f473039e1
commit
2f082a9656
@ -1,19 +1,21 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
public event EventHandler<AppleTags>? RetrievedMetadata;
|
||||||
|
|
||||||
public Mp4File AaxFile { get; private set; }
|
public Mp4File? AaxFile { get; private set; }
|
||||||
protected Mp4Operation AaxConversion { get; set; }
|
protected Mp4Operation? AaxConversion { get; set; }
|
||||||
|
|
||||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outDirectory, cacheDirectory, dlOptions) { }
|
||||||
|
|
||||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||||
public override void SetCoverArt(byte[] coverArt)
|
public override void SetCoverArt(byte[] coverArt)
|
||||||
@ -31,11 +33,13 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
private Mp4File Open()
|
private Mp4File Open()
|
||||||
{
|
{
|
||||||
if (DownloadOptions.InputType is FileType.Dash)
|
if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0)
|
||||||
|
throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file.");
|
||||||
|
else if (DownloadOptions.InputType is FileType.Dash)
|
||||||
{
|
{
|
||||||
//We may have multiple keys , so use the key whose key ID matches
|
//We may have multiple keys , so use the key whose key ID matches
|
||||||
//the dash files default Key ID.
|
//the dash files default Key ID.
|
||||||
var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
||||||
|
|
||||||
var dash = new DashFile(InputFileStream);
|
var dash = new DashFile(InputFileStream);
|
||||||
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
|
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
|
||||||
@ -43,26 +47,38 @@ namespace AaxDecrypter
|
|||||||
if (kidIndex == -1)
|
if (kidIndex == -1)
|
||||||
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
|
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
|
||||||
|
|
||||||
DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex];
|
keys[0] = keys[kidIndex];
|
||||||
var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1;
|
var keyId = keys[kidIndex].KeyPart1;
|
||||||
var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2;
|
var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
|
||||||
|
|
||||||
dash.SetDecryptionKey(keyId, key);
|
dash.SetDecryptionKey(keyId, key);
|
||||||
|
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
|
||||||
return dash;
|
return dash;
|
||||||
}
|
}
|
||||||
else if (DownloadOptions.InputType is FileType.Aax)
|
else if (DownloadOptions.InputType is FileType.Aax)
|
||||||
{
|
{
|
||||||
var aax = new AaxFile(InputFileStream);
|
var aax = new AaxFile(InputFileStream);
|
||||||
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1);
|
var key = keys[0].KeyPart1;
|
||||||
|
aax.SetDecryptionKey(keys[0].KeyPart1);
|
||||||
|
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
|
||||||
return aax;
|
return aax;
|
||||||
}
|
}
|
||||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||||
{
|
{
|
||||||
var aax = new AaxFile(InputFileStream);
|
var aax = new AaxFile(InputFileStream);
|
||||||
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2);
|
var key = keys[0].KeyPart1;
|
||||||
|
var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
|
||||||
|
aax.SetDecryptionKey(keys[0].KeyPart1, iv);
|
||||||
|
WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}");
|
||||||
return aax;
|
return aax;
|
||||||
}
|
}
|
||||||
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||||
|
|
||||||
|
void WriteKeyFile(string contents)
|
||||||
|
{
|
||||||
|
var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key"));
|
||||||
|
File.WriteAllText(keyFile, contents + Environment.NewLine);
|
||||||
|
OnTempFileCreated(new(keyFile));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_GetMetadata()
|
protected bool Step_GetMetadata()
|
||||||
|
|||||||
@ -5,20 +5,20 @@ using System;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||||
private FileStream workingFileStream;
|
private FileStream? workingFileStream;
|
||||||
|
|
||||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions)
|
: base(outDirectory, cacheDirectory, dlOptions)
|
||||||
{
|
{
|
||||||
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
||||||
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||||
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
@ -59,6 +59,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
*/
|
*/
|
||||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
|
if (AaxFile is null) return false;
|
||||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||||
|
|
||||||
// Ensure split files are at least minChapterLength in duration.
|
// Ensure split files are at least minChapterLength in duration.
|
||||||
@ -83,10 +84,10 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await (AaxConversion = decryptMultiAsync(splitChapters));
|
await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
|
||||||
|
|
||||||
if (AaxConversion.IsCompletedSuccessfully)
|
if (AaxConversion.IsCompletedSuccessfully)
|
||||||
await moveMoovToBeginning(workingFileStream?.Name);
|
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
|
||||||
|
|
||||||
return AaxConversion.IsCompletedSuccessfully;
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
}
|
}
|
||||||
@ -97,17 +98,17 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters)
|
private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters)
|
||||||
{
|
{
|
||||||
var chapterCount = 0;
|
var chapterCount = 0;
|
||||||
return
|
return
|
||||||
DownloadOptions.OutputFormat == OutputFormat.M4b
|
DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||||
? AaxFile.ConvertToMultiMp4aAsync
|
? aaxFile.ConvertToMultiMp4aAsync
|
||||||
(
|
(
|
||||||
splitChapters,
|
splitChapters,
|
||||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
||||||
)
|
)
|
||||||
: AaxFile.ConvertToMultiMp3Async
|
: aaxFile.ConvertToMultiMp3Async
|
||||||
(
|
(
|
||||||
splitChapters,
|
splitChapters,
|
||||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
||||||
@ -116,33 +117,32 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
|
|
||||||
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
|
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
|
||||||
{
|
{
|
||||||
|
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
|
||||||
|
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||||
MultiConvertFileProperties props = new()
|
MultiConvertFileProperties props = new()
|
||||||
{
|
{
|
||||||
OutputFileName = OutputFileName,
|
OutputFileName = newTempFile.FilePath,
|
||||||
PartsPosition = currentChapter,
|
PartsPosition = currentChapter,
|
||||||
PartsTotal = splitChapters.Count,
|
PartsTotal = splitChapters.Count,
|
||||||
Title = newSplitCallback?.Chapter?.Title,
|
Title = newSplitCallback.Chapter?.Title,
|
||||||
};
|
};
|
||||||
|
|
||||||
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
||||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
||||||
newSplitCallback.TrackNumber = currentChapter;
|
newSplitCallback.TrackNumber = currentChapter;
|
||||||
newSplitCallback.TrackCount = splitChapters.Count;
|
newSplitCallback.TrackCount = splitChapters.Count;
|
||||||
|
|
||||||
OnFileCreated(workingFileStream.Name);
|
OnTempFileCreated(newTempFile with { PartProperties = props });
|
||||||
}
|
}
|
||||||
|
|
||||||
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||||
{
|
{
|
||||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
|
||||||
FileUtility.SaferDelete(fileName);
|
return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mp4Operation moveMoovToBeginning(string filename)
|
private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
|
||||||
{
|
{
|
||||||
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||||
&& DownloadOptions.MoveMoovToBeginning
|
&& DownloadOptions.MoveMoovToBeginning
|
||||||
@ -151,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
{
|
{
|
||||||
return Mp4File.RelocateMoovAsync(filename);
|
return Mp4File.RelocateMoovAsync(filename);
|
||||||
}
|
}
|
||||||
else return Mp4Operation.FromCompleted(AaxFile);
|
else return Mp4Operation.FromCompleted(aaxFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,13 +6,16 @@ using System;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
private readonly AverageSpeed averageSpeed = new();
|
private readonly AverageSpeed averageSpeed = new();
|
||||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
private TempFile? outputTempFile;
|
||||||
: base(outFileName, cacheDirectory, dlOptions)
|
|
||||||
|
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
|
: base(outDirectory, cacheDirectory, dlOptions)
|
||||||
{
|
{
|
||||||
var step = 1;
|
var step = 1;
|
||||||
|
|
||||||
@ -21,7 +24,6 @@ namespace AaxDecrypter
|
|||||||
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||||
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
||||||
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
|
||||||
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,14 +41,16 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(OutputFileName);
|
if (AaxFile is null) return false;
|
||||||
|
outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||||
|
FileUtility.SaferDelete(outputTempFile.FilePath);
|
||||||
|
|
||||||
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
OnFileCreated(OutputFileName);
|
OnTempFileCreated(outputTempFile);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await (AaxConversion = decryptAsync(outputFile));
|
await (AaxConversion = decryptAsync(AaxFile, outputFile));
|
||||||
|
|
||||||
return AaxConversion.IsCompletedSuccessfully;
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
}
|
}
|
||||||
@ -58,14 +62,15 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
private async Task<bool> Step_MoveMoov()
|
private async Task<bool> Step_MoveMoov()
|
||||||
{
|
{
|
||||||
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
|
if (outputTempFile is null) return false;
|
||||||
|
AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath);
|
||||||
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
|
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
|
||||||
await AaxConversion;
|
await AaxConversion;
|
||||||
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
||||||
return AaxConversion.IsCompletedSuccessfully;
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
|
private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
|
||||||
{
|
{
|
||||||
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
||||||
|
|
||||||
@ -84,20 +89,20 @@ namespace AaxDecrypter
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mp4Operation decryptAsync(Stream outputFile)
|
private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile)
|
||||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
||||||
? AaxFile.ConvertToMp3Async
|
? aaxFile.ConvertToMp3Async
|
||||||
(
|
(
|
||||||
outputFile,
|
outputFile,
|
||||||
DownloadOptions.LameConfig,
|
DownloadOptions.LameConfig,
|
||||||
DownloadOptions.ChapterInfo
|
DownloadOptions.ChapterInfo
|
||||||
)
|
)
|
||||||
: DownloadOptions.FixupFile
|
: DownloadOptions.FixupFile
|
||||||
? AaxFile.ConvertToMp4aAsync
|
? aaxFile.ConvertToMp4aAsync
|
||||||
(
|
(
|
||||||
outputFile,
|
outputFile,
|
||||||
DownloadOptions.ChapterInfo
|
DownloadOptions.ChapterInfo
|
||||||
)
|
)
|
||||||
: AaxFile.ConvertToMp4aAsync(outputFile);
|
: aaxFile.ConvertToMp4aAsync(outputFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,55 +6,50 @@ using System;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public enum OutputFormat { M4b, Mp3 }
|
public enum OutputFormat { M4b, Mp3 }
|
||||||
|
|
||||||
public abstract class AudiobookDownloadBase
|
public abstract class AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
public event EventHandler<string> RetrievedTitle;
|
public event EventHandler<string?>? RetrievedTitle;
|
||||||
public event EventHandler<string> RetrievedAuthors;
|
public event EventHandler<string?>? RetrievedAuthors;
|
||||||
public event EventHandler<string> RetrievedNarrators;
|
public event EventHandler<string?>? RetrievedNarrators;
|
||||||
public event EventHandler<byte[]> RetrievedCoverArt;
|
public event EventHandler<byte[]?>? RetrievedCoverArt;
|
||||||
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
|
public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
|
||||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
public event EventHandler<TimeSpan>? DecryptTimeRemaining;
|
||||||
public event EventHandler<string> FileCreated;
|
public event EventHandler<TempFile>? TempFileCreated;
|
||||||
|
|
||||||
public bool IsCanceled { get; protected set; }
|
public bool IsCanceled { get; protected set; }
|
||||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||||
protected string OutputFileName { get; }
|
protected string OutputDirectory { get; }
|
||||||
public IDownloadOptions DownloadOptions { get; }
|
public IDownloadOptions DownloadOptions { get; }
|
||||||
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
|
||||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||||
private bool downloadFinished;
|
private bool downloadFinished;
|
||||||
|
|
||||||
private readonly NetworkFileStreamPersister nfsPersister;
|
private NetworkFileStreamPersister? m_nfsPersister;
|
||||||
|
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
|
||||||
private readonly DownloadProgress zeroProgress;
|
private readonly DownloadProgress zeroProgress;
|
||||||
private readonly string jsonDownloadState;
|
private readonly string jsonDownloadState;
|
||||||
private readonly string tempFilePath;
|
private readonly string tempFilePath;
|
||||||
|
|
||||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
{
|
{
|
||||||
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory));
|
||||||
|
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||||
|
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
|
||||||
|
|
||||||
var outDir = Path.GetDirectoryName(OutputFileName);
|
if (!Directory.Exists(OutputDirectory))
|
||||||
if (!Directory.Exists(outDir))
|
Directory.CreateDirectory(OutputDirectory);
|
||||||
Directory.CreateDirectory(outDir);
|
|
||||||
|
|
||||||
if (!Directory.Exists(cacheDirectory))
|
if (!Directory.Exists(cacheDirectory))
|
||||||
Directory.CreateDirectory(cacheDirectory);
|
Directory.CreateDirectory(cacheDirectory);
|
||||||
|
|
||||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json");
|
||||||
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
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
|
zeroProgress = new DownloadProgress
|
||||||
{
|
{
|
||||||
BytesReceived = 0,
|
BytesReceived = 0,
|
||||||
@ -65,24 +60,30 @@ namespace AaxDecrypter
|
|||||||
OnDecryptProgressUpdate(zeroProgress);
|
OnDecryptProgressUpdate(zeroProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected TempFile GetNewTempFilePath(string extension)
|
||||||
|
{
|
||||||
|
extension = FileUtility.GetStandardizedExtension(extension);
|
||||||
|
var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension);
|
||||||
|
return new(path, extension);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> RunAsync()
|
public async Task<bool> RunAsync()
|
||||||
{
|
{
|
||||||
await InputFileStream.BeginDownloadingAsync();
|
await InputFileStream.BeginDownloadingAsync();
|
||||||
var progressTask = Task.Run(reportProgress);
|
var progressTask = Task.Run(reportProgress);
|
||||||
|
|
||||||
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
|
||||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||||
|
|
||||||
//Stop the downloader so it doesn't keep running in the background.
|
//Stop the downloader so it doesn't keep running in the background.
|
||||||
if (!success)
|
if (!success)
|
||||||
nfsPersister.Dispose();
|
NfsPersister.Dispose();
|
||||||
|
|
||||||
await progressTask;
|
await progressTask;
|
||||||
|
|
||||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||||
|
|
||||||
nfsPersister.Dispose();
|
NfsPersister.Dispose();
|
||||||
return success;
|
return success;
|
||||||
|
|
||||||
async Task reportProgress()
|
async Task reportProgress()
|
||||||
@ -129,50 +130,43 @@ namespace AaxDecrypter
|
|||||||
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||||
|
|
||||||
public virtual void SetCoverArt(byte[] coverArt) { }
|
public virtual void SetCoverArt(byte[] coverArt) { }
|
||||||
|
protected void OnRetrievedTitle(string? title)
|
||||||
protected void OnRetrievedTitle(string title)
|
|
||||||
=> RetrievedTitle?.Invoke(this, title);
|
=> RetrievedTitle?.Invoke(this, title);
|
||||||
protected void OnRetrievedAuthors(string authors)
|
protected void OnRetrievedAuthors(string? authors)
|
||||||
=> RetrievedAuthors?.Invoke(this, authors);
|
=> RetrievedAuthors?.Invoke(this, authors);
|
||||||
protected void OnRetrievedNarrators(string narrators)
|
protected void OnRetrievedNarrators(string? narrators)
|
||||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
protected void OnRetrievedCoverArt(byte[]? coverArt)
|
||||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||||
protected void OnFileCreated(string path)
|
public void OnTempFileCreated(TempFile path)
|
||||||
=> FileCreated?.Invoke(this, path);
|
=> TempFileCreated?.Invoke(this, path);
|
||||||
|
|
||||||
protected virtual void FinalizeDownload()
|
protected virtual void FinalizeDownload()
|
||||||
{
|
{
|
||||||
nfsPersister?.Dispose();
|
NfsPersister.Dispose();
|
||||||
downloadFinished = true;
|
downloadFinished = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
|
|
||||||
{
|
|
||||||
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
|
|
||||||
{
|
|
||||||
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
|
|
||||||
|
|
||||||
if (File.Exists(recordsFile))
|
|
||||||
OnFileCreated(recordsFile);
|
|
||||||
}
|
|
||||||
return !IsCanceled;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async Task<bool> Step_CreateCueAsync()
|
protected async Task<bool> Step_CreateCueAsync()
|
||||||
{
|
{
|
||||||
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
||||||
|
|
||||||
|
if (DownloadOptions.ChapterInfo.Count <= 1)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters.");
|
||||||
|
return !IsCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
// not a critical step. its failure should not prevent future steps from running
|
// not a critical step. its failure should not prevent future steps from running
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
var tempFile = GetNewTempFilePath(".cue");
|
||||||
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
|
||||||
OnFileCreated(path);
|
OnTempFileCreated(tempFile);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -181,58 +175,9 @@ namespace AaxDecrypter
|
|||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CleanupAsync()
|
|
||||||
{
|
|
||||||
if (IsCanceled) return false;
|
|
||||||
|
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
|
||||||
|
|
||||||
if (DownloadOptions.DecryptionKeys != null &&
|
|
||||||
DownloadOptions.RetainEncryptedFile &&
|
|
||||||
DownloadOptions.InputType is AAXClean.FileType fileType)
|
|
||||||
{
|
|
||||||
//Write aax decryption key
|
|
||||||
string keyPath = Path.ChangeExtension(tempFilePath, ".key");
|
|
||||||
FileUtility.SaferDelete(keyPath);
|
|
||||||
string aaxPath;
|
|
||||||
|
|
||||||
if (fileType is AAXClean.FileType.Aax)
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}");
|
|
||||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
|
||||||
}
|
|
||||||
else if (fileType is AAXClean.FileType.Aaxc)
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(keyPath,
|
|
||||||
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
|
|
||||||
$"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
|
|
||||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
|
|
||||||
}
|
|
||||||
else if (fileType is AAXClean.FileType.Dash)
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(keyPath,
|
|
||||||
$"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
|
|
||||||
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
|
|
||||||
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
throw new InvalidOperationException($"Unknown file type: {fileType}");
|
|
||||||
|
|
||||||
if (tempFilePath != aaxPath)
|
|
||||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
|
||||||
|
|
||||||
OnFileCreated(aaxPath);
|
|
||||||
OnFileCreated(keyPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
FileUtility.SaferDelete(tempFilePath);
|
|
||||||
|
|
||||||
return !IsCanceled;
|
|
||||||
}
|
|
||||||
|
|
||||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||||
{
|
{
|
||||||
NetworkFileStreamPersister nfsp = default;
|
NetworkFileStreamPersister? nfsp = default;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!File.Exists(jsonDownloadState))
|
if (!File.Exists(jsonDownloadState))
|
||||||
@ -252,9 +197,15 @@ namespace AaxDecrypter
|
|||||||
return nfsp = newNetworkFilePersister();
|
return nfsp = newNetworkFilePersister();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
{
|
||||||
|
//nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
|
||||||
|
if (nfsp is not null)
|
||||||
{
|
{
|
||||||
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
||||||
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||||
|
OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString()));
|
||||||
|
OnTempFileCreated(new(jsonDownloadState));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkFileStreamPersister newNetworkFilePersister()
|
NetworkFileStreamPersister newNetworkFilePersister()
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
@ -33,11 +32,8 @@ namespace AaxDecrypter
|
|||||||
KeyData[]? DecryptionKeys { get; }
|
KeyData[]? DecryptionKeys { get; }
|
||||||
TimeSpan RuntimeLength { get; }
|
TimeSpan RuntimeLength { get; }
|
||||||
OutputFormat OutputFormat { get; }
|
OutputFormat OutputFormat { get; }
|
||||||
bool TrimOutputToChapterLength { get; }
|
|
||||||
bool RetainEncryptedFile { get; }
|
|
||||||
bool StripUnabridged { get; }
|
bool StripUnabridged { get; }
|
||||||
bool CreateCueSheet { get; }
|
bool CreateCueSheet { get; }
|
||||||
bool DownloadClipsBookmarks { get; }
|
|
||||||
long DownloadSpeedBps { get; }
|
long DownloadSpeedBps { get; }
|
||||||
ChapterInfo ChapterInfo { get; }
|
ChapterInfo ChapterInfo { get; }
|
||||||
bool FixupFile { get; }
|
bool FixupFile { get; }
|
||||||
@ -52,9 +48,7 @@ namespace AaxDecrypter
|
|||||||
bool Downsample { get; }
|
bool Downsample { get; }
|
||||||
bool MatchSourceBitrate { get; }
|
bool MatchSourceBitrate { get; }
|
||||||
bool MoveMoovToBeginning { get; }
|
bool MoveMoovToBeginning { get; }
|
||||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
|
||||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||||
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
|
||||||
public FileType? InputType { get; }
|
public FileType? InputType { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,12 @@ namespace AaxDecrypter
|
|||||||
Position = WritePosition
|
Position = WritePosition
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (_writeFile.Length < WritePosition)
|
||||||
|
{
|
||||||
|
_writeFile.Dispose();
|
||||||
|
throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}");
|
||||||
|
}
|
||||||
|
|
||||||
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
|
||||||
SetUriForSameFile(uri);
|
SetUriForSameFile(uri);
|
||||||
|
|||||||
17
Source/AaxDecrypter/TempFile.cs
Normal file
17
Source/AaxDecrypter/TempFile.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using FileManager;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AaxDecrypter;
|
||||||
|
|
||||||
|
public record TempFile
|
||||||
|
{
|
||||||
|
public LongPath FilePath { get; init; }
|
||||||
|
public string Extension { get; }
|
||||||
|
public MultiConvertFileProperties? PartProperties { get; init; }
|
||||||
|
public TempFile(LongPath filePath, string? extension = null)
|
||||||
|
{
|
||||||
|
FilePath = filePath;
|
||||||
|
extension ??= System.IO.Path.GetExtension(filePath);
|
||||||
|
Extension = FileUtility.GetStandardizedExtension(extension).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
using FileManager;
|
using FileManager;
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
@ -8,13 +7,12 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
protected override long InputFilePosition => InputFileStream.WritePosition;
|
protected override long InputFilePosition => InputFileStream.WritePosition;
|
||||||
|
|
||||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
|
||||||
: base(outFileName, cacheDirectory, dlLic)
|
: base(outDirectory, cacheDirectory, dlLic)
|
||||||
{
|
{
|
||||||
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||||
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
|
||||||
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
@ -26,8 +24,9 @@ namespace AaxDecrypter
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
FinalizeDownload();
|
FinalizeDownload();
|
||||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
|
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||||
OnFileCreated(OutputFileName);
|
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
|
||||||
|
OnTempFileCreated(tempFile);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using AaxDecrypter;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using LibationFileManager.Templates;
|
using LibationFileManager.Templates;
|
||||||
@ -34,30 +35,17 @@ namespace FileLiberator
|
|||||||
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DownloadDecryptBook:
|
|
||||||
/// Path: in progress directory.
|
|
||||||
/// File name: final file name.
|
|
||||||
/// </summary>
|
|
||||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension)
|
|
||||||
=> Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PDF: audio file does not exist
|
/// PDF: audio file does not exist
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false)
|
||||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension, returnFirstExisting: returnFirstExisting);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PDF: audio file does not exist
|
|
||||||
/// </summary>
|
|
||||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension)
|
|
||||||
=> Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PDF: audio file already exists
|
/// PDF: audio file already exists
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties partProperties = null, bool returnFirstExisting = false)
|
||||||
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
=> partProperties is null ? Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension, returnFirstExisting: returnFirstExisting)
|
||||||
|
: Templates.ChapterFile.GetFilename(libraryBook.ToDto(), partProperties, dirFullPath, extension, returnFirstExisting: returnFirstExisting);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,52 +1,39 @@
|
|||||||
using System;
|
using AaxDecrypter;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using AaxDecrypter;
|
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
using AudibleApi.Common;
|
using AudibleApi.Common;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
|
using Dinah.Core.Net.Http;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
using LibationFileManager;
|
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
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadDecryptBook : AudioDecodable
|
public class DownloadDecryptBook : AudioDecodable
|
||||||
{
|
{
|
||||||
public override string Name => "Download & Decrypt";
|
public override string Name => "Download & Decrypt";
|
||||||
private AudiobookDownloadBase abDownloader;
|
private CancellationTokenSource? cancellationTokenSource;
|
||||||
private readonly CancellationTokenSource cancellationTokenSource = new();
|
private AudiobookDownloadBase? abDownloader;
|
||||||
|
|
||||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||||
public override async Task CancelAsync()
|
public override async Task CancelAsync()
|
||||||
{
|
{
|
||||||
cancellationTokenSource.Cancel();
|
if (abDownloader is not null) await abDownloader.CancelAsync();
|
||||||
if (abDownloader is not null)
|
if (cancellationTokenSource is not null) await cancellationTokenSource.CancelAsync();
|
||||||
await abDownloader.CancelAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
var entries = new List<FilePathCache.CacheEntry>();
|
|
||||||
// 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);
|
OnBegin(libraryBook);
|
||||||
|
cancellationTokenSource = new CancellationTokenSource();
|
||||||
var cancellationToken = cancellationTokenSource.Token;
|
var cancellationToken = cancellationTokenSource.Token;
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -54,44 +41,36 @@ namespace FileLiberator
|
|||||||
if (libraryBook.Book.Audio_Exists())
|
if (libraryBook.Book.Audio_Exists())
|
||||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||||
|
|
||||||
downloadValidation(libraryBook);
|
DownloadValidation(libraryBook);
|
||||||
|
|
||||||
var api = await libraryBook.GetApiAsync();
|
var api = await libraryBook.GetApiAsync();
|
||||||
var config = Configuration.Instance;
|
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken);
|
||||||
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook, cancellationToken);
|
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
|
||||||
|
|
||||||
bool success = false;
|
if (!result.Success || getFirstAudioFile(result.ResultFiles) == default)
|
||||||
try
|
|
||||||
{
|
{
|
||||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
// decrypt failed. Delete all output entries but leave the cache files.
|
||||||
FilePathCache.Removed += FilePathCache_Removed;
|
result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath));
|
||||||
|
|
||||||
success = await downloadAudiobookAsync(api, config, downloadOptions);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
|
||||||
FilePathCache.Removed -= FilePathCache_Removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// decrypt failed
|
|
||||||
if (!success || getFirstAudioFile(entries) == default)
|
|
||||||
{
|
|
||||||
await Task.WhenAll(
|
|
||||||
entries
|
|
||||||
.Where(f => f.FileType != FileType.AAXC)
|
|
||||||
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return new StatusHandler { "Decrypt failed" };
|
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);
|
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||||
|
|
||||||
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken));
|
//post-download tasks done in parallel.
|
||||||
|
var moveFilesTask = Task.Run(() => MoveFilesToBooksDir(libraryBook, finalStorageDir, result.ResultFiles, cancellationToken));
|
||||||
Task[] finalTasks =
|
Task[] finalTasks =
|
||||||
[
|
[
|
||||||
Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)),
|
|
||||||
moveFilesTask,
|
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))
|
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -101,16 +80,20 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
catch when (!moveFilesTask.IsFaulted)
|
catch when (!moveFilesTask.IsFaulted)
|
||||||
{
|
{
|
||||||
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
|
//Swallow DownloadCoverArt, SetCoverAsFolderIcon, and SaveMetadataAsync exceptions.
|
||||||
//Only fail if the downloaded audio files failed to move to Books directory
|
//Only fail if the downloaded audio files failed to move to Books directory
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
|
libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion!);
|
||||||
|
|
||||||
SetDirectoryTime(libraryBook, finalStorageDir);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,24 +107,31 @@ namespace FileLiberator
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
OnCompleted(libraryBook);
|
OnCompleted(libraryBook);
|
||||||
|
cancellationTokenSource.Dispose();
|
||||||
|
cancellationTokenSource = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions)
|
private record AudiobookDecryptResult(bool Success, List<TempFile> ResultFiles, List<TempFile> CacheFiles);
|
||||||
|
|
||||||
|
private async Task<AudiobookDecryptResult> DownloadAudiobookAsync(AudibleApi.Api api, DownloadOptions dlOptions, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower());
|
var outpoutDir = AudibleFileStorage.DecryptInProgressDirectory;
|
||||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||||
|
var result = new AudiobookDecryptResult(false, [], []);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
|
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
|
||||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
abDownloader = new UnencryptedAudiobookDownloader(outpoutDir, cacheDir, dlOptions);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AaxcDownloadConvertBase converter
|
AaxcDownloadConvertBase converter
|
||||||
= config.SplitFilesByChapter ?
|
= dlOptions.Config.SplitFilesByChapter ?
|
||||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
new AaxcDownloadMultiConverter(outpoutDir, cacheDir, dlOptions) :
|
||||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
new AaxcDownloadSingleConverter(outpoutDir, cacheDir, dlOptions);
|
||||||
|
|
||||||
if (config.AllowLibationFixup)
|
if (dlOptions.Config.AllowLibationFixup)
|
||||||
converter.RetrievedMetadata += Converter_RetrievedMetadata;
|
converter.RetrievedMetadata += Converter_RetrievedMetadata;
|
||||||
|
|
||||||
abDownloader = converter;
|
abDownloader = converter;
|
||||||
@ -153,28 +143,48 @@ namespace FileLiberator
|
|||||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path);
|
abDownloader.TempFileCreated += AbDownloader_TempFileCreated;
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
// REAL WORK DONE HERE
|
||||||
var success = await abDownloader.RunAsync();
|
bool success = await abDownloader.RunAsync();
|
||||||
|
return result with { Success = success };
|
||||||
if (success && config.SaveMetadataToFile)
|
|
||||||
{
|
|
||||||
var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
|
|
||||||
|
|
||||||
var item = await api.GetCatalogProductAsync(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
|
|
||||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
|
|
||||||
|
|
||||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
|
||||||
OnFileCreated(dlOptions.LibraryBook, metadataFile);
|
|
||||||
}
|
}
|
||||||
return 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags)
|
void AbDownloader_TempFileCreated(object? sender, TempFile e)
|
||||||
{
|
{
|
||||||
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
|
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<AAXClean.Chapter> chapters)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
#region Prevent erroneous truncation due to incorrect chapter info
|
#region Prevent erroneous truncation due to incorrect chapter info
|
||||||
@ -185,14 +195,13 @@ namespace FileLiberator
|
|||||||
//the chapter. This is never desirable, so pad the last chapter to match
|
//the chapter. This is never desirable, so pad the last chapter to match
|
||||||
//the original audio length.
|
//the original audio length.
|
||||||
|
|
||||||
var fileDuration = converter.AaxFile.Duration;
|
var fileDuration = aaxFile.Duration;
|
||||||
if (options.Config.StripAudibleBrandAudio)
|
if (options.Config.StripAudibleBrandAudio)
|
||||||
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||||
|
|
||||||
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
|
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
|
||||||
//Remove the last chapter and re-add it with the durationDelta that will
|
//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.
|
//make the chapter's end coincide with the end of the audio file.
|
||||||
var chapters = options.ChapterInfo.Chapters as List<AAXClean.Chapter>;
|
|
||||||
var lastChapter = chapters[^1];
|
var lastChapter = chapters[^1];
|
||||||
|
|
||||||
chapters.Remove(lastChapter);
|
chapters.Remove(lastChapter);
|
||||||
@ -220,7 +229,29 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void downloadValidation(LibraryBook libraryBook)
|
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)
|
string errorString(string field)
|
||||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||||
@ -236,91 +267,93 @@ namespace FileLiberator
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||||
throw new Exception(errorString("Account"));
|
throw new InvalidOperationException(errorString("Account"));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||||
throw new Exception(errorString("Locale"));
|
throw new InvalidOperationException(errorString("Locale"));
|
||||||
}
|
|
||||||
|
|
||||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
|
||||||
{
|
|
||||||
if (Configuration.Instance.AllowLibationFixup)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
e = OnRequestCoverArt();
|
|
||||||
abDownloader.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 Post-success routines
|
||||||
/// <summary>Move new files to 'Books' directory</summary>
|
/// <summary>Move new files to 'Books' directory</summary>
|
||||||
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||||
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries, CancellationToken cancellationToken)
|
private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List<TempFile> entries, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// create final directory. move each file into it
|
|
||||||
var destinationDir = getDestinationDirectory(libraryBook);
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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++)
|
for (var i = 0; i < entries.Count; i++)
|
||||||
{
|
{
|
||||||
var entry = entries[i];
|
var entry = entries[i];
|
||||||
|
|
||||||
|
var destFileName
|
||||||
|
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||||
|
libraryBook,
|
||||||
|
destinationDir,
|
||||||
|
entry.Extension,
|
||||||
|
entry.PartProperties,
|
||||||
|
Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
var realDest
|
var realDest
|
||||||
= FileUtility.SaferMoveToValidPath(
|
= FileUtility.SaferMoveToValidPath(
|
||||||
entry.Path,
|
entry.FilePath,
|
||||||
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
|
destFileName,
|
||||||
Configuration.Instance.ReplacementCharacters,
|
Configuration.Instance.ReplacementCharacters,
|
||||||
overwrite: Configuration.Instance.OverwriteExisting);
|
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);
|
SetFileTime(libraryBook, realDest);
|
||||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
OnFileCreated(libraryBook, 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 };
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
}
|
}
|
||||||
|
|
||||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
if (entries.FirstOrDefault(f => getFileType(f) is FileType.Cue) is TempFile cue
|
||||||
if (cue != default)
|
&& getFirstAudioFile(entries)?.FilePath is LongPath audioFilePath)
|
||||||
{
|
{
|
||||||
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
|
Cue.UpdateFileName(cue.FilePath, audioFilePath);
|
||||||
SetFileTime(libraryBook, cue.Path);
|
SetFileTime(libraryBook, cue.FilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
AudibleFileStorage.Audio.Refresh();
|
AudibleFileStorage.Audio.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string getDestinationDirectory(LibraryBook libraryBook)
|
private void DownloadCoverArt(LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
if (!options.Config.DownloadCoverArt) return;
|
||||||
if (!Directory.Exists(destinationDir))
|
|
||||||
Directory.CreateDirectory(destinationDir);
|
|
||||||
return destinationDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
|
||||||
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
|
||||||
|
|
||||||
private static void downloadCoverArt(DownloadOptions options, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
|
||||||
|
|
||||||
var coverPath = "[null]";
|
var coverPath = "[null]";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var destinationDir = getDestinationDirectory(options.LibraryBook);
|
coverPath
|
||||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg");
|
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
options.LibraryBook,
|
||||||
|
destinationDir,
|
||||||
|
extension: ".jpg",
|
||||||
|
returnFirstExisting: Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
if (File.Exists(coverPath))
|
if (File.Exists(coverPath))
|
||||||
FileUtility.SaferDelete(coverPath);
|
FileUtility.SaferDelete(coverPath);
|
||||||
@ -330,14 +363,119 @@ namespace FileLiberator
|
|||||||
{
|
{
|
||||||
File.WriteAllBytes(coverPath, picBytes);
|
File.WriteAllBytes(coverPath, picBytes);
|
||||||
SetFileTime(options.LibraryBook, coverPath);
|
SetFileTime(options.LibraryBook, coverPath);
|
||||||
|
OnFileCreated(options.LibraryBook, coverPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
//Failure to download cover art should not be considered a failure to download the book
|
//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 {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {@metadataFile}.", options.LibraryBook, coverPath);
|
||||||
throw;
|
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<TempFile> entries)
|
||||||
|
=> entries.FirstOrDefault(f => getFileType(f) is FileType.Audio);
|
||||||
|
private static IEnumerable<TempFile> getAaxcFiles(IEnumerable<TempFile> entries)
|
||||||
|
=> entries.Where(f => getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase));
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,8 @@ using AAXClean;
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using ApplicationServices;
|
|
||||||
using LibationFileManager.Templates;
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
@ -31,12 +29,9 @@ namespace FileLiberator
|
|||||||
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
|
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
|
||||||
public NAudio.Lame.LameConfig? LameConfig { get; }
|
public NAudio.Lame.LameConfig? LameConfig { get; }
|
||||||
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||||
public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio;
|
|
||||||
public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
|
public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
|
||||||
public bool CreateCueSheet => Config.CreateCueSheet;
|
public bool CreateCueSheet => Config.CreateCueSheet;
|
||||||
public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks;
|
|
||||||
public long DownloadSpeedBps => Config.DownloadSpeedLimit;
|
public long DownloadSpeedBps => Config.DownloadSpeedLimit;
|
||||||
public bool RetainEncryptedFile => Config.RetainAaxFile;
|
|
||||||
public bool FixupFile => Config.AllowLibationFixup;
|
public bool FixupFile => Config.AllowLibationFixup;
|
||||||
public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
|
public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
|
||||||
public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
|
public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
|
||||||
@ -45,45 +40,9 @@ namespace FileLiberator
|
|||||||
public AudibleApi.Common.DrmType DrmType { get; }
|
public AudibleApi.Common.DrmType DrmType { get; }
|
||||||
public AudibleApi.Common.ContentMetadata ContentMetadata { get; }
|
public AudibleApi.Common.ContentMetadata ContentMetadata { get; }
|
||||||
|
|
||||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
|
||||||
{
|
|
||||||
var baseDir = Path.GetDirectoryName(props.OutputFileName);
|
|
||||||
var extension = Path.GetExtension(props.OutputFileName);
|
|
||||||
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetMultipartTitle(MultiConvertFileProperties props)
|
public string GetMultipartTitle(MultiConvertFileProperties props)
|
||||||
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
|
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
|
||||||
|
|
||||||
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
|
|
||||||
{
|
|
||||||
if (DownloadClipsBookmarks)
|
|
||||||
{
|
|
||||||
var format = Config.ClipsBookmarksFileFormat;
|
|
||||||
|
|
||||||
var formatExtension = format.ToString().ToLowerInvariant();
|
|
||||||
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
|
||||||
|
|
||||||
var api = await LibraryBook.GetApiAsync();
|
|
||||||
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
|
|
||||||
|
|
||||||
switch(format)
|
|
||||||
{
|
|
||||||
case Configuration.ClipBookmarkFormat.CSV:
|
|
||||||
RecordExporter.ToCsv(filePath, records);
|
|
||||||
break;
|
|
||||||
case Configuration.ClipBookmarkFormat.Xlsx:
|
|
||||||
RecordExporter.ToXlsx(filePath, records);
|
|
||||||
break;
|
|
||||||
case Configuration.ClipBookmarkFormat.Json:
|
|
||||||
RecordExporter.ToJson(filePath, LibraryBook, records);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Configuration Config { get; }
|
public Configuration Config { get; }
|
||||||
private readonly IDisposable cancellation;
|
private readonly IDisposable cancellation;
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@ -46,7 +46,9 @@ namespace LibationFileManager
|
|||||||
|
|
||||||
public static List<(FileType fileType, LongPath path)> GetFiles(string id)
|
public static List<(FileType fileType, LongPath path)> GetFiles(string id)
|
||||||
{
|
{
|
||||||
var matchingFiles = Cache.GetIdEntries(id);
|
List<CacheEntry> matchingFiles;
|
||||||
|
lock(locker)
|
||||||
|
matchingFiles = Cache.GetIdEntries(id);
|
||||||
|
|
||||||
bool cacheChanged = false;
|
bool cacheChanged = false;
|
||||||
|
|
||||||
@ -68,7 +70,9 @@ namespace LibationFileManager
|
|||||||
|
|
||||||
public static LongPath? GetFirstPath(string id, FileType type)
|
public static LongPath? GetFirstPath(string id, FileType type)
|
||||||
{
|
{
|
||||||
var matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList();
|
List<CacheEntry> matchingFiles;
|
||||||
|
lock (locker)
|
||||||
|
matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList();
|
||||||
|
|
||||||
bool cacheChanged = false;
|
bool cacheChanged = false;
|
||||||
try
|
try
|
||||||
@ -96,7 +100,10 @@ namespace LibationFileManager
|
|||||||
|
|
||||||
private static bool Remove(CacheEntry entry)
|
private static bool Remove(CacheEntry entry)
|
||||||
{
|
{
|
||||||
if (Cache.Remove(entry.Id, entry))
|
bool removed;
|
||||||
|
lock (locker)
|
||||||
|
removed = Cache.Remove(entry.Id, entry);
|
||||||
|
if (removed)
|
||||||
{
|
{
|
||||||
Removed?.Invoke(null, entry);
|
Removed?.Invoke(null, entry);
|
||||||
return true;
|
return true;
|
||||||
@ -112,6 +119,7 @@ namespace LibationFileManager
|
|||||||
|
|
||||||
public static void Insert(CacheEntry entry)
|
public static void Insert(CacheEntry entry)
|
||||||
{
|
{
|
||||||
|
lock(locker)
|
||||||
Cache.Add(entry.Id, entry);
|
Cache.Add(entry.Id, entry);
|
||||||
Inserted?.Invoke(null, entry);
|
Inserted?.Invoke(null, entry);
|
||||||
save();
|
save();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user