mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-24 06:28:02 -05:00
Compare commits
281 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a986238bc | ||
|
|
d636ceed8e | ||
|
|
e4fc104afe | ||
|
|
87e3075fb3 | ||
|
|
ab44823c05 | ||
|
|
2767f04621 | ||
|
|
0f1ff0aa10 | ||
|
|
c1af253300 | ||
|
|
d08962cffa | ||
|
|
7720110460 | ||
|
|
dfa5829cbd | ||
|
|
648b84ee55 | ||
|
|
6a81b9b02d | ||
|
|
c43e03b228 | ||
|
|
1de7edd9df | ||
|
|
df90094884 | ||
|
|
c9a6c8fd35 | ||
|
|
d0b78cc501 | ||
|
|
0b7bc4d938 | ||
|
|
18cca53968 | ||
|
|
ef9c60cc4f | ||
|
|
fa24831693 | ||
|
|
24370e9804 | ||
|
|
d3f82b162e | ||
|
|
5a40c7370f | ||
|
|
2d22855b93 | ||
|
|
b870d562ff | ||
|
|
f1c87308ea | ||
|
|
a3fac3441c | ||
|
|
5f8c672361 | ||
|
|
40520b89d1 | ||
|
|
0ac90f5a30 | ||
|
|
4d6544d828 | ||
|
|
8098564926 | ||
|
|
07c96c4994 | ||
|
|
aa8491f205 | ||
|
|
5c535478d1 | ||
|
|
f0541b498f | ||
|
|
e466d63e76 | ||
|
|
6e66314605 | ||
|
|
be5e18d977 | ||
|
|
c437a39a82 | ||
|
|
7b55158148 | ||
|
|
5772d9c31e | ||
|
|
2a1f02b095 | ||
|
|
5b7cde2a9e | ||
|
|
5e349c6662 | ||
|
|
4b78b757aa | ||
|
|
22548dc8ae | ||
|
|
1165f81203 | ||
|
|
13294d3414 | ||
|
|
8a74a29700 | ||
|
|
36f58b64d6 | ||
|
|
19369a21ef | ||
|
|
611fb4d6d8 | ||
|
|
c77ec54035 | ||
|
|
c9c28c7826 | ||
|
|
30e2caaff5 | ||
|
|
fd56017af5 | ||
|
|
d2eaf26117 | ||
|
|
7c38e18435 | ||
|
|
bfb1dbc69a | ||
|
|
d2ff19e309 | ||
|
|
aa3a7dce06 | ||
|
|
71075838eb | ||
|
|
803a0b7ccf | ||
|
|
d9f3fa825c | ||
|
|
df42ba584e | ||
|
|
9f09a62a1e | ||
|
|
e714179c30 | ||
|
|
db84c9a7d9 | ||
|
|
937bd56fcc | ||
|
|
f29968f379 | ||
|
|
14e14ba9bd | ||
|
|
613c97524a | ||
|
|
4fd16f04e0 | ||
|
|
61385f0f0b | ||
|
|
7647882344 | ||
|
|
96ffa619ec | ||
|
|
de1147ac1b | ||
|
|
926a7a1148 | ||
|
|
51020ef99e | ||
|
|
5a1303c33a | ||
|
|
a0e2d78b9b | ||
|
|
6b711190c3 | ||
|
|
b4a6342513 | ||
|
|
988b137d67 | ||
|
|
dae9c9c9b6 | ||
|
|
420b7529c6 | ||
|
|
4cf999c84d | ||
|
|
8fe3896d76 | ||
|
|
adcba34560 | ||
|
|
8e09d7e617 | ||
|
|
197b50e3ac | ||
|
|
ac2114e270 | ||
|
|
29461701cd | ||
|
|
0f130c70f5 | ||
|
|
995637e843 | ||
|
|
9501687f86 | ||
|
|
248dea3402 | ||
|
|
1d420f5430 | ||
|
|
5f0a6b8526 | ||
|
|
c337c0b44e | ||
|
|
89207866f3 | ||
|
|
9e11086d49 | ||
|
|
58b172f816 | ||
|
|
0b8084bc03 | ||
|
|
37970222f3 | ||
|
|
bcab2dd440 | ||
|
|
d402128d1d | ||
|
|
3ae0f2daa2 | ||
|
|
126919d578 | ||
|
|
437e85fd12 | ||
|
|
de34e5c795 | ||
|
|
8ffcefd6ae | ||
|
|
e59ab9b483 | ||
|
|
57fa1bd763 | ||
|
|
dccb2d73d6 | ||
|
|
77fc865636 | ||
|
|
1040a347c6 | ||
|
|
6ed1307443 | ||
|
|
c2c732b2b1 | ||
|
|
9e0caf34d6 | ||
|
|
802763a4fb | ||
|
|
b4803c42a5 | ||
|
|
62c98c66a3 | ||
|
|
6b289445e2 | ||
|
|
52bf91f8aa | ||
|
|
6d2dff1a98 | ||
|
|
7c9970c0cb | ||
|
|
d2892f9076 | ||
|
|
89f60a7ca3 | ||
|
|
ea37c09081 | ||
|
|
76cb280933 | ||
|
|
0a54a8104c | ||
|
|
7464336535 | ||
|
|
dc0dd3474b | ||
|
|
7b9c5c0f4f | ||
|
|
ad87f1851e | ||
|
|
e8423341ef | ||
|
|
a9d3494af1 | ||
|
|
90731a8948 | ||
|
|
e723467ca6 | ||
|
|
722c33bf61 | ||
|
|
f080215cbb | ||
|
|
d5c74d629f | ||
|
|
d12c246f6d | ||
|
|
8969c216af | ||
|
|
9a4903f0dd | ||
|
|
3eda498a5e | ||
|
|
8af7f28f04 | ||
|
|
d9d7dfe1f7 | ||
|
|
b9c4d11946 | ||
|
|
68a5d7a58d | ||
|
|
4d69b222c5 | ||
|
|
42f94e7f6c | ||
|
|
381d52be72 | ||
|
|
f16ad30891 | ||
|
|
ef53a6a8cb | ||
|
|
9a37d434f1 | ||
|
|
d7eb190f69 | ||
|
|
f19c46ee45 | ||
|
|
343c3b62d6 | ||
|
|
b1de10a71a | ||
|
|
6beb5cc74a | ||
|
|
3767c3574a | ||
|
|
4ceb4f9c03 | ||
|
|
0f5149f7b4 | ||
|
|
673451dc11 | ||
|
|
e4257afc14 | ||
|
|
2a7e185dc3 | ||
|
|
9e06c343c1 | ||
|
|
40b3a9990d | ||
|
|
d66c112a1e | ||
|
|
d826885728 | ||
|
|
263222d8cc | ||
|
|
f25734334d | ||
|
|
ede8397f13 | ||
|
|
1369ee575a | ||
|
|
c8e2418af7 | ||
|
|
2da25edafd | ||
|
|
f60964f4c7 | ||
|
|
3183f99153 | ||
|
|
2a22cff67c | ||
|
|
7fbe8ae769 | ||
|
|
f9df466ad8 | ||
|
|
0b129fcf7c | ||
|
|
2be5fd5af3 | ||
|
|
c9727f84ab | ||
|
|
aa56bb74a1 | ||
|
|
85a6e21dcf | ||
|
|
8c620c25ab | ||
|
|
813d91dfa4 | ||
|
|
d0d66c6135 | ||
|
|
a8d609676e | ||
|
|
8386da5ec6 | ||
|
|
f5089e7e29 | ||
|
|
a639857ec6 | ||
|
|
35b5d7370c | ||
|
|
c9f988acf8 | ||
|
|
6dfef09ea3 | ||
|
|
7e288c0c08 | ||
|
|
dbcf6f25db | ||
|
|
88133652e9 | ||
|
|
e768466943 | ||
|
|
0cc55fd1e8 | ||
|
|
e36ea70cd1 | ||
|
|
a86185e644 | ||
|
|
64a8f007a5 | ||
|
|
215a626c92 | ||
|
|
de93047192 | ||
|
|
79c9a094b5 | ||
|
|
012a92ea30 | ||
|
|
2e60d2accf | ||
|
|
565d34cec9 | ||
|
|
dd6967e88b | ||
|
|
fb7f57ab69 | ||
|
|
88253cdb55 | ||
|
|
560880b53d | ||
|
|
27ae5facbe | ||
|
|
7a90d9fba9 | ||
|
|
f74b0d78db | ||
|
|
52fb0a27ce | ||
|
|
7bdcf4eef0 | ||
|
|
a44c46333f | ||
|
|
766d427b19 | ||
|
|
0e7930f2b6 | ||
|
|
081878b6f7 | ||
|
|
f925d10d2b | ||
|
|
e37a2ccca9 | ||
|
|
3e2d69606b | ||
|
|
2c20d03506 | ||
|
|
97730d1793 | ||
|
|
5ab4183f9b | ||
|
|
7acaac7bd3 | ||
|
|
448fd78b8f | ||
|
|
56a48c04bf | ||
|
|
65027fd001 | ||
|
|
f57a46c772 | ||
|
|
a45ab61929 | ||
|
|
cd67e7136b | ||
|
|
265ad3a782 | ||
|
|
9f49a88000 | ||
|
|
b5d941d479 | ||
|
|
79ed92f303 | ||
|
|
1c239dc546 | ||
|
|
687591e08e | ||
|
|
0045cf05ef | ||
|
|
963d632208 | ||
|
|
9c1f620223 | ||
|
|
de75543b33 | ||
|
|
689ffc71a2 | ||
|
|
d795244247 | ||
|
|
4989cda93c | ||
|
|
2f3c0e8a95 | ||
|
|
560523b99d | ||
|
|
d5e9e49517 | ||
|
|
54d24a7b09 | ||
|
|
19a710e080 | ||
|
|
7bdf71a29b | ||
|
|
ef35c2aee9 | ||
|
|
95766a43c5 | ||
|
|
e1dfefbadf | ||
|
|
f81552565a | ||
|
|
957bec1c7f | ||
|
|
5c8ad72a5e | ||
|
|
0b1d513f50 | ||
|
|
d770109d86 | ||
|
|
235d0acede | ||
|
|
6f184273b8 | ||
|
|
a4cb934611 | ||
|
|
6aefdfca9d | ||
|
|
c7454ea5d2 | ||
|
|
2ef746a94c | ||
|
|
ab82e7c99c | ||
|
|
5f8ca9a0b5 | ||
|
|
d48bd5ad07 | ||
|
|
af48641281 | ||
|
|
f621ca63e8 | ||
|
|
35f54779f0 | ||
|
|
f68f374b78 |
@@ -5,8 +5,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\AAXClean\AAXClean.csproj" />
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
||||
<PackageReference Include="AAXClean" Version="0.1.9" />
|
||||
<PackageReference Include="Dinah.Core" Version="2.0.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
91
AaxDecrypter/AaxcDownloadConvertBase.cs
Normal file
91
AaxDecrypter/AaxcDownloadConvertBase.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using AAXClean;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||
{
|
||||
protected OutputFormat OutputFormat { get; }
|
||||
|
||||
protected AaxFile AaxFile;
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
OutputFormat = outputFormat;
|
||||
}
|
||||
|
||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
base.SetCoverArt(coverArt);
|
||||
if (coverArt is not null)
|
||||
AaxFile?.AppleTags.SetCoverArt(coverArt);
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
AaxFile = new AaxFile(InputFileStream);
|
||||
|
||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected DownloadProgress Step_DownloadAudiobook_Start()
|
||||
{
|
||||
var zeroProgress = new DownloadProgress
|
||||
{
|
||||
BytesReceived = 0,
|
||||
ProgressPercentage = 0,
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
};
|
||||
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
|
||||
AaxFile.SetDecryptionKey(DownloadLicense.AudibleKey, DownloadLicense.AudibleIV);
|
||||
return zeroProgress;
|
||||
}
|
||||
|
||||
protected void Step_DownloadAudiobook_End(DownloadProgress zeroProgress)
|
||||
{
|
||||
AaxFile.Close();
|
||||
|
||||
CloseInputFileStream();
|
||||
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
}
|
||||
|
||||
protected void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = AaxFile.Duration;
|
||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
var progressPercent = e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = 100 * progressPercent,
|
||||
BytesReceived = (long)(InputFileStream.Length * progressPercent),
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
}
|
||||
|
||||
public override void Cancel()
|
||||
{
|
||||
IsCanceled = true;
|
||||
AaxFile?.Cancel();
|
||||
AaxFile?.Dispose();
|
||||
CloseInputFileStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Diagnostics;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Logging;
|
||||
using Dinah.Core.StepRunner;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public enum OutputFormat
|
||||
{
|
||||
Mp4a,
|
||||
Mp3
|
||||
}
|
||||
public class AaxcDownloadConverter
|
||||
{
|
||||
public event EventHandler<AppleTags> RetrievedTags;
|
||||
public event EventHandler<byte[]> RetrievedCoverArt;
|
||||
public event EventHandler<int> DecryptProgressUpdate;
|
||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
||||
public string AppName { get; set; } = nameof(AaxcDownloadConverter);
|
||||
|
||||
private string outputFileName { get; }
|
||||
private string cacheDir { get; }
|
||||
private DownloadLicense downloadLicense { get; }
|
||||
private AaxFile aaxFile;
|
||||
private byte[] coverArt;
|
||||
private OutputFormat OutputFormat;
|
||||
|
||||
private StepSequence steps { get; }
|
||||
private NetworkFileStreamPersister nfsPersister;
|
||||
private bool isCanceled { get; set; }
|
||||
private string jsonDownloadState => Path.Combine(cacheDir, Path.GetFileNameWithoutExtension(outputFileName) + ".json");
|
||||
private string tempFile => PathLib.ReplaceExtension(jsonDownloadState, ".aaxc");
|
||||
|
||||
public AaxcDownloadConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
||||
outputFileName = outFileName;
|
||||
|
||||
var outDir = Path.GetDirectoryName(outputFileName);
|
||||
if (!Directory.Exists(outDir))
|
||||
throw new ArgumentNullException(nameof(outDir), "Directory does not exist");
|
||||
if (File.Exists(outputFileName))
|
||||
File.Delete(outputFileName);
|
||||
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
throw new ArgumentNullException(nameof(cacheDirectory), "Directory does not exist");
|
||||
cacheDir = cacheDirectory;
|
||||
|
||||
downloadLicense = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
|
||||
OutputFormat = outputFormat;
|
||||
|
||||
steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + (outputFormat == OutputFormat.Mp4a ? "M4b" : "Mp3"),
|
||||
|
||||
["Step 1: Get Aaxc Metadata"] = Step1_GetMetadata,
|
||||
["Step 2: Download Decrypted Audiobook"] = Step2_DownloadAndCombine,
|
||||
["Step 3: Create Cue"] = Step3_CreateCue,
|
||||
["Step 4: Create Nfo"] = Step4_CreateNfo,
|
||||
["Step 5: Cleanup"] = Step5_Cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
public void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
if (coverArt is null) return;
|
||||
|
||||
this.coverArt = coverArt;
|
||||
RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
}
|
||||
|
||||
public bool Run()
|
||||
{
|
||||
var (IsSuccess, Elapsed) = steps.Run();
|
||||
|
||||
if (!IsSuccess)
|
||||
{
|
||||
Console.WriteLine("WARNING-Conversion failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
var speedup = (int)(aaxFile.Duration.TotalSeconds / (long)Elapsed.TotalSeconds);
|
||||
Console.WriteLine("Speedup is " + speedup + "x realtime.");
|
||||
Console.WriteLine("Done");
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Step1_GetMetadata()
|
||||
{
|
||||
//Get metadata from the file over http
|
||||
|
||||
if (File.Exists(jsonDownloadState))
|
||||
{
|
||||
try
|
||||
{
|
||||
nfsPersister = new NetworkFileStreamPersister(jsonDownloadState);
|
||||
//If More thaan ~1 hour has elapsed since getting the download url, it will expire.
|
||||
//The new url will be to the same file.
|
||||
nfsPersister.NetworkFileStream.SetUriForSameFile(new Uri(downloadLicense.DownloadUrl));
|
||||
}
|
||||
catch
|
||||
{
|
||||
FileExt.SafeDelete(jsonDownloadState);
|
||||
FileExt.SafeDelete(tempFile);
|
||||
nfsPersister = NewNetworkFilePersister();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
nfsPersister = NewNetworkFilePersister();
|
||||
}
|
||||
nfsPersister.NetworkFileStream.BeginDownloading();
|
||||
|
||||
aaxFile = new AaxFile(nfsPersister.NetworkFileStream);
|
||||
coverArt = aaxFile.AppleTags.Cover;
|
||||
|
||||
RetrievedTags?.Invoke(this, aaxFile.AppleTags);
|
||||
RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
|
||||
return !isCanceled;
|
||||
}
|
||||
private NetworkFileStreamPersister NewNetworkFilePersister()
|
||||
{
|
||||
var headers = new System.Net.WebHeaderCollection
|
||||
{
|
||||
{ "User-Agent", downloadLicense.UserAgent }
|
||||
};
|
||||
|
||||
var networkFileStream = new NetworkFileStream(tempFile, new Uri(downloadLicense.DownloadUrl), 0, headers);
|
||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||
}
|
||||
|
||||
public bool Step2_DownloadAndCombine()
|
||||
{
|
||||
|
||||
DecryptProgressUpdate?.Invoke(this, 0);
|
||||
|
||||
if (File.Exists(outputFileName))
|
||||
FileExt.SafeDelete(outputFileName);
|
||||
|
||||
FileStream outFile = File.OpenWrite(outputFileName);
|
||||
|
||||
aaxFile.SetDecryptionKey(downloadLicense.AudibleKey, downloadLicense.AudibleIV);
|
||||
|
||||
aaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
|
||||
var decryptionResult = OutputFormat == OutputFormat.Mp4a ? aaxFile.ConvertToMp4a(outFile, downloadLicense.ChapterInfo) : aaxFile.ConvertToMp3(outFile);
|
||||
aaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
aaxFile.Close();
|
||||
|
||||
downloadLicense.ChapterInfo = aaxFile.Chapters;
|
||||
|
||||
if (decryptionResult == ConversionResult.NoErrorsDetected
|
||||
&& coverArt is not null
|
||||
&& OutputFormat == OutputFormat.Mp4a)
|
||||
{
|
||||
//This handles a special case where the aaxc file doesn't contain cover art and
|
||||
//Libation downloaded it instead (Animal Farm). Currently only works for Mp4a files.
|
||||
using var decryptedBook = new Mp4File(outputFileName, FileAccess.ReadWrite);
|
||||
decryptedBook.AppleTags?.SetCoverArt(coverArt);
|
||||
decryptedBook.Save();
|
||||
decryptedBook.Close();
|
||||
}
|
||||
|
||||
nfsPersister.Dispose();
|
||||
|
||||
DecryptProgressUpdate?.Invoke(this, 0);
|
||||
|
||||
return decryptionResult == ConversionResult.NoErrorsDetected && !isCanceled;
|
||||
}
|
||||
|
||||
private void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = aaxFile.Duration;
|
||||
double remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
DecryptTimeRemaining?.Invoke(this, TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
|
||||
DecryptProgressUpdate?.Invoke(this, (int)progressPercent);
|
||||
}
|
||||
|
||||
public bool Step3_CreateCue()
|
||||
{
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".cue"), Cue.CreateContents(Path.GetFileName(outputFileName), downloadLicense.ChapterInfo));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step3_CreateCue)}. FAILED");
|
||||
}
|
||||
return !isCanceled;
|
||||
}
|
||||
|
||||
public bool Step4_CreateNfo()
|
||||
{
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".nfo"), NFO.CreateContents(AppName, aaxFile, downloadLicense.ChapterInfo));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step4_CreateNfo)}. FAILED");
|
||||
}
|
||||
return !isCanceled;
|
||||
}
|
||||
|
||||
public bool Step5_Cleanup()
|
||||
{
|
||||
FileExt.SafeDelete(jsonDownloadState);
|
||||
FileExt.SafeDelete(tempFile);
|
||||
return !isCanceled;
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
isCanceled = true;
|
||||
aaxFile?.Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
146
AaxDecrypter/AaxcDownloadMultiConverter.cs
Normal file
146
AaxDecrypter/AaxcDownloadMultiConverter.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AAXClean;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
private Func<string, int, int, NewSplitCallback, string> multipartFileNameCallback { get; }
|
||||
private static string DefaultMultipartFilename(string outputFileName, int partsPosition, int partsTotal, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var template = Path.ChangeExtension(outputFileName, null) + " - <chapter> - <title>" + Path.GetExtension(outputFileName);
|
||||
|
||||
var fileTemplate = new FileTemplate(template) { IllegalCharacterReplacements = " " };
|
||||
fileTemplate.AddParameterReplacement("chapter", FileUtility.GetSequenceFormatted(partsPosition, partsTotal));
|
||||
fileTemplate.AddParameterReplacement("title", newSplitCallback?.Chapter?.Title ?? "");
|
||||
|
||||
return fileTemplate.GetFilePath();
|
||||
}
|
||||
|
||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
||||
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat,
|
||||
Func<string, int, int, NewSplitCallback, string> multipartFileNameCallback = null)
|
||||
: base(outFileName, cacheDirectory, dlLic, outputFormat)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + OutputFormat,
|
||||
|
||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
|
||||
["Step 3: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
this.multipartFileNameCallback = multipartFileNameCallback ?? DefaultMultipartFilename;
|
||||
}
|
||||
|
||||
/*
|
||||
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
|
||||
|
||||
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
|
||||
If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file.
|
||||
|
||||
I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters:
|
||||
|
||||
00:00:00 - 00:00:02 | Part 1
|
||||
00:00:02 - 00:35:00 | Chapter 1
|
||||
00:35:02 - 01:02:00 | Chapter 2
|
||||
01:02:00 - 01:02:02 | Part 2
|
||||
01:02:02 - 01:41:00 | Chapter 3
|
||||
01:41:00 - 02:05:00 | Chapter 4
|
||||
|
||||
The book will be split into the following files:
|
||||
|
||||
00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b
|
||||
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
|
||||
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
|
||||
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
|
||||
|
||||
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
|
||||
*/
|
||||
private bool Step_DownloadAudiobookAsMultipleFilesPerChapter()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
var chapters = DownloadLicense.ChapterInfo.Chapters.ToList();
|
||||
|
||||
// Ensure split files are at least minChapterLength in duration.
|
||||
var splitChapters = new ChapterInfo();
|
||||
|
||||
var runningTotal = TimeSpan.Zero;
|
||||
string title = "";
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
if (runningTotal == TimeSpan.Zero)
|
||||
title = chapters[i].Title;
|
||||
|
||||
runningTotal += chapters[i].Duration;
|
||||
|
||||
if (runningTotal >= minChapterLength)
|
||||
{
|
||||
splitChapters.AddChapter(title, runningTotal);
|
||||
runningTotal = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// reset, just in case
|
||||
multiPartFilePaths.Clear();
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
if (OutputFormat == OutputFormat.M4b)
|
||||
ConvertToMultiMp4a(splitChapters);
|
||||
else
|
||||
ConvertToMultiMp3(splitChapters);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
|
||||
var success = !IsCanceled;
|
||||
|
||||
if (success)
|
||||
foreach (var path in multiPartFilePaths)
|
||||
OnFileCreated(path);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private void ConvertToMultiMp4a(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
AaxFile.ConvertToMultiMp4a(splitChapters, newSplitCallback =>
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback)
|
||||
);
|
||||
}
|
||||
|
||||
private void ConvertToMultiMp3(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
AaxFile.ConvertToMultiMp3(splitChapters, newSplitCallback =>
|
||||
{
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback);
|
||||
newSplitCallback.LameConfig.ID3.Track = chapterCount.ToString();
|
||||
});
|
||||
}
|
||||
|
||||
private void createOutputFileStream(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var fileName = multipartFileNameCallback(OutputFileName, currentChapter, splitChapters.Count, newSplitCallback);
|
||||
fileName = FileUtility.GetValidFilename(fileName);
|
||||
|
||||
multiPartFilePaths.Add(fileName);
|
||||
|
||||
FileUtility.SaferDelete(fileName);
|
||||
|
||||
newSplitCallback.OutputFile = File.Open(fileName, FileMode.OpenOrCreate);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
AaxDecrypter/AaxcDownloadSingleConverter.cs
Normal file
53
AaxDecrypter/AaxcDownloadSingleConverter.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AAXClean;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
|
||||
: base(outFileName, cacheDirectory, dlLic, outputFormat)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + OutputFormat,
|
||||
|
||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsSingleFile,
|
||||
["Step 3: Create Cue"] = Step_CreateCue,
|
||||
["Step 4: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
private bool Step_DownloadAudiobookAsSingleFile()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
|
||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
var decryptionResult
|
||||
= OutputFormat == OutputFormat.M4b
|
||||
? AaxFile.ConvertToMp4a(outputFile, DownloadLicense.ChapterInfo)
|
||||
: AaxFile.ConvertToMp3(outputFile);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
DownloadLicense.ChapterInfo = AaxFile.Chapters;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
|
||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
||||
if (success)
|
||||
base.OnFileCreated(OutputFileName);
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
||||
149
AaxDecrypter/AudiobookDownloadBase.cs
Normal file
149
AaxDecrypter/AudiobookDownloadBase.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public enum OutputFormat { M4b, Mp3 }
|
||||
|
||||
public abstract class AudiobookDownloadBase
|
||||
{
|
||||
public event EventHandler<string> RetrievedTitle;
|
||||
public event EventHandler<string> RetrievedAuthors;
|
||||
public event EventHandler<string> RetrievedNarrators;
|
||||
public event EventHandler<byte[]> RetrievedCoverArt;
|
||||
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
|
||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
||||
public event EventHandler<string> FileCreated;
|
||||
|
||||
protected bool IsCanceled { get; set; }
|
||||
protected string OutputFileName { get; private set; }
|
||||
protected DownloadLicense DownloadLicense { get; }
|
||||
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
|
||||
|
||||
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
|
||||
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
|
||||
|
||||
protected abstract StepSequence Steps { get; }
|
||||
private NetworkFileStreamPersister nfsPersister;
|
||||
|
||||
private string jsonDownloadState { get; }
|
||||
private string tempFile => Path.ChangeExtension(jsonDownloadState, ".tmp");
|
||||
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, DownloadLicense dlLic)
|
||||
{
|
||||
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
||||
|
||||
var outDir = Path.GetDirectoryName(OutputFileName);
|
||||
if (!Directory.Exists(outDir))
|
||||
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(outDir)}");
|
||||
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(cacheDirectory)}");
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.ChangeExtension(OutputFileName, ".json"));
|
||||
|
||||
DownloadLicense = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
|
||||
|
||||
// delete file after validation is complete
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
}
|
||||
|
||||
public abstract void Cancel();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
if (coverArt is not null)
|
||||
OnRetrievedCoverArt(coverArt);
|
||||
}
|
||||
|
||||
public bool Run()
|
||||
{
|
||||
var (IsSuccess, Elapsed) = Steps.Run();
|
||||
|
||||
if (!IsSuccess)
|
||||
Serilog.Log.Logger.Error("Conversion failed");
|
||||
|
||||
return IsSuccess;
|
||||
}
|
||||
|
||||
protected void OnRetrievedTitle(string title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
protected void OnRetrievedAuthors(string authors)
|
||||
=> RetrievedAuthors?.Invoke(this, authors);
|
||||
protected void OnRetrievedNarrators(string narrators)
|
||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||
protected void OnFileCreated(string path)
|
||||
=> FileCreated?.Invoke(this, path);
|
||||
|
||||
protected void CloseInputFileStream()
|
||||
{
|
||||
nfsPersister?.NetworkFileStream?.Close();
|
||||
nfsPersister?.Dispose();
|
||||
}
|
||||
|
||||
protected bool Step_CreateCue()
|
||||
{
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||
path = FileUtility.GetValidFilename(path);
|
||||
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadLicense.ChapterInfo));
|
||||
OnFileCreated(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
|
||||
}
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected bool Step_Cleanup()
|
||||
{
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFile);
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||
{
|
||||
if (!File.Exists(jsonDownloadState))
|
||||
return NewNetworkFilePersister();
|
||||
|
||||
try
|
||||
{
|
||||
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
||||
// If More than ~1 hour has elapsed since getting the download url, it will expire.
|
||||
// The new url will be to the same file.
|
||||
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadLicense.DownloadUrl));
|
||||
return nfsp;
|
||||
}
|
||||
catch
|
||||
{
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFile);
|
||||
return NewNetworkFilePersister();
|
||||
}
|
||||
}
|
||||
|
||||
private NetworkFileStreamPersister NewNetworkFilePersister()
|
||||
{
|
||||
var headers = new System.Net.WebHeaderCollection
|
||||
{
|
||||
{ "User-Agent", DownloadLicense.UserAgent }
|
||||
};
|
||||
|
||||
var networkFileStream = new NetworkFileStream(tempFile, new Uri(DownloadLicense.DownloadUrl), 0, headers);
|
||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,12 @@ namespace AaxDecrypter
|
||||
|
||||
public DownloadLicense(string downloadUrl, string audibleKey, string audibleIV, string userAgent)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
ArgumentValidator.EnsureNotNullOrEmpty(audibleKey, nameof(audibleKey));
|
||||
ArgumentValidator.EnsureNotNullOrEmpty(audibleIV, nameof(audibleIV));
|
||||
ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||
|
||||
DownloadUrl = downloadUrl;
|
||||
// no null/empty check. unencrypted files do not have these
|
||||
AudibleKey = audibleKey;
|
||||
AudibleIV = audibleIV;
|
||||
UserAgent = userAgent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class NFO
|
||||
{
|
||||
public static string CreateContents(string ripper, AAXClean.Mp4File aaxcTagLib, ChapterInfo chapters)
|
||||
{
|
||||
var _hours = (int)aaxcTagLib.Duration.TotalHours;
|
||||
var myDuration
|
||||
= (_hours > 0 ? _hours + " hours, " : string.Empty)
|
||||
+ aaxcTagLib.Duration.Minutes + " minutes, "
|
||||
+ aaxcTagLib.Duration.Seconds + " seconds";
|
||||
|
||||
var nfoString
|
||||
= "General Information\r\n"
|
||||
+ "======================\r\n"
|
||||
+ $" Title: {aaxcTagLib.AppleTags.TitleSansUnabridged?.UnicodeToAscii() ?? "[unknown]"}\r\n"
|
||||
+ $" Author: {aaxcTagLib.AppleTags.FirstAuthor?.UnicodeToAscii() ?? "[unknown]"}\r\n"
|
||||
+ $" Read By: {aaxcTagLib.AppleTags.Narrator?.UnicodeToAscii() ?? "[unknown]"}\r\n"
|
||||
+ $" Release Date: {aaxcTagLib.AppleTags.ReleaseDate ?? "[unknown]"}\r\n"
|
||||
+ $" Book Copyright: {aaxcTagLib.AppleTags.BookCopyright ?? "[unknown]"}\r\n"
|
||||
+ $" Recording Copyright: {aaxcTagLib.AppleTags.RecordingCopyright ?? "[unknown]"}\r\n"
|
||||
+ $" Genre: {aaxcTagLib.AppleTags.Generes ?? "[unknown]"}\r\n"
|
||||
+ $" Publisher: {aaxcTagLib.AppleTags.Publisher ?? "[unknown]"}\r\n"
|
||||
+ $" Duration: {myDuration}\r\n"
|
||||
+ $" Chapters: {chapters.Count}\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Media Information\r\n"
|
||||
+ "======================\r\n"
|
||||
+ " Source Format: Audible AAX\r\n"
|
||||
+ $" Source Sample Rate: {aaxcTagLib.TimeScale} Hz\r\n"
|
||||
+ $" Source Channels: {aaxcTagLib.AudioChannels}\r\n"
|
||||
+ $" Source Bitrate: {aaxcTagLib.AverageBitrate} Kbps\r\n"
|
||||
+ "\r\n"
|
||||
+ " Lossless Encode: Yes\r\n"
|
||||
+ " Encoded Codec: AAC / M4B\r\n"
|
||||
+ $" Encoded Sample Rate: {aaxcTagLib.TimeScale} Hz\r\n"
|
||||
+ $" Encoded Channels: {aaxcTagLib.AudioChannels}\r\n"
|
||||
+ $" Encoded Bitrate: {aaxcTagLib.AverageBitrate} Kbps\r\n"
|
||||
+ "\r\n"
|
||||
+ $" Ripper: {ripper}\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Book Description\r\n"
|
||||
+ "================\r\n"
|
||||
+ (!string.IsNullOrWhiteSpace(aaxcTagLib.AppleTags.LongDescription) ? aaxcTagLib.AppleTags.LongDescription.UnicodeToAscii() : aaxcTagLib.AppleTags.Comment?.UnicodeToAscii());
|
||||
|
||||
return nfoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
@@ -27,7 +26,7 @@ namespace AaxDecrypter
|
||||
|
||||
public CookieCollection GetCookies()
|
||||
{
|
||||
return base.GetCookies(Uri);
|
||||
return GetCookies(Uri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,15 +78,14 @@ namespace AaxDecrypter
|
||||
#endregion
|
||||
|
||||
#region Private Properties
|
||||
|
||||
private HttpWebRequest HttpRequest { get; set; }
|
||||
private FileStream _writeFile { get; }
|
||||
private FileStream _readFile { get; }
|
||||
private Stream _networkStream { get; set; }
|
||||
private bool hasBegunDownloading { get; set; }
|
||||
private bool isCancelled { get; set; }
|
||||
private bool finishedDownloading { get; set; }
|
||||
private Action downloadThreadCompleteCallback { get; set; }
|
||||
public bool IsCancelled { get; private set; }
|
||||
private EventWaitHandle downloadEnded { get; set; }
|
||||
private EventWaitHandle downloadedPiece { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -147,7 +145,7 @@ namespace AaxDecrypter
|
||||
private void Update()
|
||||
{
|
||||
RequestHeaders = HttpRequest.Headers;
|
||||
Updated?.Invoke(this, new EventArgs());
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -160,8 +158,8 @@ namespace AaxDecrypter
|
||||
|
||||
if (uriToSameFile.Host != Uri.Host)
|
||||
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
|
||||
if (hasBegunDownloading && !finishedDownloading)
|
||||
throw new Exception("Cannot change Uri during a download operation.");
|
||||
if (hasBegunDownloading)
|
||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||
|
||||
Uri = uriToSameFile;
|
||||
HttpRequest = WebRequest.CreateHttp(Uri);
|
||||
@@ -176,25 +174,27 @@ namespace AaxDecrypter
|
||||
/// <summary>
|
||||
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
|
||||
/// </summary>
|
||||
public void BeginDownloading()
|
||||
private void BeginDownloading()
|
||||
{
|
||||
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
|
||||
|
||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||
{
|
||||
hasBegunDownloading = true;
|
||||
finishedDownloading = true;
|
||||
downloadEnded.Set();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||
throw new Exception($"Specified write position (0x{WritePosition:X10}) is larger than the file size.");
|
||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
var response = HttpRequest.GetResponse() as HttpWebResponse;
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
throw new Exception($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||
|
||||
if (response.Headers.GetValues("Accept-Ranges").FirstOrDefault(r => r.EqualsInsensitive("bytes")) is null)
|
||||
throw new Exception($"Server at {Uri.Host} does not support Http ranges");
|
||||
throw new WebException($"Server at {Uri.Host} does not support Http ranges");
|
||||
|
||||
//Content length is the length of the range request, and it is only equal
|
||||
//to the complete file length if requesting Range: bytes=0-
|
||||
@@ -202,10 +202,12 @@ namespace AaxDecrypter
|
||||
ContentLength = response.ContentLength;
|
||||
|
||||
_networkStream = response.GetResponseStream();
|
||||
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
|
||||
//Download the file in the background.
|
||||
Thread downloadThread = new Thread(() => DownloadFile()) { IsBackground = true };
|
||||
downloadThread.Start();
|
||||
new Thread(() => DownloadFile())
|
||||
{ IsBackground = true }
|
||||
.Start();
|
||||
|
||||
hasBegunDownloading = true;
|
||||
return;
|
||||
@@ -216,13 +218,13 @@ namespace AaxDecrypter
|
||||
/// </summary>
|
||||
private void DownloadFile()
|
||||
{
|
||||
long downloadPosition = WritePosition;
|
||||
long nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
var downloadPosition = WritePosition;
|
||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
|
||||
byte[] buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
do
|
||||
{
|
||||
int bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
|
||||
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
|
||||
_writeFile.Write(buff, 0, bytesRead);
|
||||
|
||||
downloadPosition += bytesRead;
|
||||
@@ -233,23 +235,25 @@ namespace AaxDecrypter
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
downloadedPiece.Set();
|
||||
}
|
||||
|
||||
} while (downloadPosition < ContentLength && !isCancelled);
|
||||
} while (downloadPosition < ContentLength && !IsCancelled);
|
||||
|
||||
_writeFile.Close();
|
||||
_networkStream.Close();
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
_networkStream.Close();
|
||||
|
||||
if (!isCancelled && WritePosition < ContentLength)
|
||||
throw new Exception("File download ended before finishing.");
|
||||
downloadedPiece.Set();
|
||||
downloadEnded.Set();
|
||||
|
||||
if (!IsCancelled && WritePosition < ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
if (WritePosition > ContentLength)
|
||||
throw new Exception("Downloaded file is larger than expected.");
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
finishedDownloading = true;
|
||||
downloadThreadCompleteCallback?.Invoke();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -330,9 +334,7 @@ namespace AaxDecrypter
|
||||
var result = new WebHeaderCollection();
|
||||
|
||||
foreach (var kvp in jObj)
|
||||
{
|
||||
result.Add(kvp.Key, kvp.Value.Value<string>());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -341,8 +343,8 @@ namespace AaxDecrypter
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
JObject jObj = new JObject();
|
||||
Type type = value.GetType();
|
||||
var jObj = new JObject();
|
||||
var type = value.GetType();
|
||||
var headers = value as WebHeaderCollection;
|
||||
var jHeaders = headers.AllKeys.Select(k => new JProperty(k, headers[k]));
|
||||
jObj.Add(jHeaders);
|
||||
@@ -364,13 +366,21 @@ namespace AaxDecrypter
|
||||
public override bool CanWrite => false;
|
||||
|
||||
[JsonIgnore]
|
||||
public override long Length => ContentLength;
|
||||
public override long Length
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!hasBegunDownloading)
|
||||
BeginDownloading();
|
||||
return ContentLength;
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); }
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool CanTimeout => base.CanTimeout;
|
||||
public override bool CanTimeout => false;
|
||||
|
||||
[JsonIgnore]
|
||||
public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; }
|
||||
@@ -387,63 +397,39 @@ namespace AaxDecrypter
|
||||
if (!hasBegunDownloading)
|
||||
BeginDownloading();
|
||||
|
||||
long toRead = Math.Min(count, Length - Position);
|
||||
long requiredPosition = Position + toRead;
|
||||
|
||||
//read operation will block until file contains enough data
|
||||
//to fulfil the request, or until cancelled.
|
||||
while (requiredPosition > WritePosition && !isCancelled)
|
||||
Thread.Sleep(2);
|
||||
|
||||
var toRead = Math.Min(count, Length - Position);
|
||||
WaitToPosition(Position + toRead);
|
||||
return _readFile.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long newPosition;
|
||||
var newPosition = origin switch
|
||||
{
|
||||
SeekOrigin.Current => Position + offset,
|
||||
SeekOrigin.End => ContentLength + offset,
|
||||
_ => offset,
|
||||
};
|
||||
|
||||
switch (origin)
|
||||
{
|
||||
case SeekOrigin.Current:
|
||||
newPosition = Position + offset;
|
||||
break;
|
||||
case SeekOrigin.End:
|
||||
newPosition = ContentLength + offset;
|
||||
break;
|
||||
default:
|
||||
newPosition = offset;
|
||||
break;
|
||||
}
|
||||
ReadToPosition(newPosition);
|
||||
|
||||
_readFile.Position = newPosition;
|
||||
return newPosition;
|
||||
WaitToPosition(newPosition);
|
||||
return _readFile.Position = newPosition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the file has downloaded to at least <paramref name="neededPosition"/>, then returns.
|
||||
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
|
||||
/// </summary>
|
||||
/// <param name="neededPosition">The minimum required data length in <see cref="SaveFilePath"/>.</param>
|
||||
private void ReadToPosition(long neededPosition)
|
||||
{
|
||||
byte[] buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
do
|
||||
{
|
||||
Read(buff, 0, DOWNLOAD_BUFF_SZ);
|
||||
} while (neededPosition > WritePosition);
|
||||
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
||||
private void WaitToPosition(long requiredPosition)
|
||||
{
|
||||
while (requiredPosition > WritePosition && !IsCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
|
||||
}
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
isCancelled = true;
|
||||
downloadThreadCompleteCallback = CloseAction;
|
||||
IsCancelled = true;
|
||||
|
||||
//ensure that close will run even if called after callback was fired.
|
||||
if (finishedDownloading)
|
||||
CloseAction();
|
||||
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
|
||||
|
||||
}
|
||||
private void CloseAction()
|
||||
{
|
||||
_readFile.Close();
|
||||
_writeFile.Close();
|
||||
_networkStream?.Close();
|
||||
@@ -451,5 +437,10 @@ namespace AaxDecrypter
|
||||
}
|
||||
|
||||
#endregion
|
||||
~NetworkFileStream()
|
||||
{
|
||||
downloadEnded?.Close();
|
||||
downloadedPiece?.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
AaxDecrypter/UnencryptedAudiobookDownloader.cs
Normal file
76
AaxDecrypter/UnencryptedAudiobookDownloader.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, DownloadLicense dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download Mp3 Audiobook",
|
||||
|
||||
["Step 1: Get Mp3 Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Audiobook"] = Step_DownloadAudiobookAsSingleFile,
|
||||
["Step 3: Create Cue"] = Step_CreateCue,
|
||||
["Step 4: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
public override void Cancel()
|
||||
{
|
||||
IsCanceled = true;
|
||||
CloseInputFileStream();
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
OnRetrievedCoverArt(null);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private bool Step_DownloadAudiobookAsSingleFile()
|
||||
{
|
||||
DateTime startTime = DateTime.Now;
|
||||
|
||||
// MUST put InputFileStream.Length first, because it starts background downloader.
|
||||
|
||||
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
|
||||
{
|
||||
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
|
||||
|
||||
var estTimeRemaining = (InputFileStream.Length - InputFileStream.WritePosition) / rate;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
var progressPercent = (double)InputFileStream.WritePosition / InputFileStream.Length;
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = 100 * progressPercent,
|
||||
BytesReceived = (long)(InputFileStream.Length * progressPercent),
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
|
||||
CloseInputFileStream();
|
||||
|
||||
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName);
|
||||
SetOutputFileName(realOutputFileName);
|
||||
OnFileCreated(realOutputFileName);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
AppScaffolding/AppScaffolding.csproj
Normal file
21
AppScaffolding/AppScaffolding.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<Version>6.2.8.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSBump" Version="2.3.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Octokit" Version="0.50.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
349
AppScaffolding/LibationScaffolding.cs
Normal file
349
AppScaffolding/LibationScaffolding.cs
Normal file
@@ -0,0 +1,349 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Logging;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
|
||||
namespace AppScaffolding
|
||||
{
|
||||
public static class LibationScaffolding
|
||||
{
|
||||
// AppScaffolding
|
||||
private static Assembly _executingAssembly;
|
||||
private static Assembly ExecutingAssembly
|
||||
=> _executingAssembly ??= Assembly.GetExecutingAssembly();
|
||||
|
||||
// LibationWinForms or LibationCli
|
||||
private static Assembly _entryAssembly;
|
||||
private static Assembly EntryAssembly
|
||||
=> _entryAssembly ??= Assembly.GetEntryAssembly();
|
||||
|
||||
// previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
||||
private static Version _buildVersion;
|
||||
public static Version BuildVersion
|
||||
=> _buildVersion
|
||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||
.Max(a => a.Version);
|
||||
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
{
|
||||
// must occur before access to Configuration instance
|
||||
Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
// //
|
||||
//***********************************************//
|
||||
return Configuration.Instance;
|
||||
}
|
||||
|
||||
/// <summary>most migrations go in here</summary>
|
||||
public static void RunPostConfigMigrations(Configuration config)
|
||||
{
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
|
||||
//
|
||||
// migrations go below here
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v5_2_0__post_config(config);
|
||||
Migrations.migrate_to_v5_7_1(config);
|
||||
Migrations.migrate_to_v6_1_2(config);
|
||||
Migrations.migrate_to_v6_2_0(config);
|
||||
}
|
||||
|
||||
/// <summary>Initialize logging. Run after migration</summary>
|
||||
public static void RunPostMigrationScaffolding(Configuration config)
|
||||
{
|
||||
ensureSerilogConfig(config);
|
||||
configureLogging(config);
|
||||
logStartupState(config);
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
{
|
||||
if (config.GetObject("Serilog") != null)
|
||||
return;
|
||||
|
||||
// "Serilog": {
|
||||
// "MinimumLevel": "Information"
|
||||
// "WriteTo": [
|
||||
// {
|
||||
// "Name": "Console"
|
||||
// },
|
||||
// {
|
||||
// "Name": "File",
|
||||
// "Args": {
|
||||
// "rollingInterval": "Day",
|
||||
// "outputTemplate": ...
|
||||
// }
|
||||
// }
|
||||
// ],
|
||||
// "Using": [ "Dinah.Core" ],
|
||||
// "Enrich": [ "WithCaller" ]
|
||||
// }
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
new JObject { {"Name", "Console" } },
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "File" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles, "_Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller" } },
|
||||
};
|
||||
config.SetObject("Serilog", serilogObj);
|
||||
}
|
||||
|
||||
// to restore original: Console.SetOut(origOut);
|
||||
private static TextWriter origOut { get; } = Console.Out;
|
||||
|
||||
private static void configureLogging(Configuration config)
|
||||
{
|
||||
config.ConfigureLogging();
|
||||
|
||||
// capture most Console.WriteLine() and write to serilog. See below tests for details.
|
||||
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
|
||||
//
|
||||
// Serilog also writes to Console so this might be asking for trouble. ie: infinite loops.
|
||||
// SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
|
||||
// Empirical testing so far has shown no issues.
|
||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||
|
||||
#region Console => Serilog tests
|
||||
/*
|
||||
// all below apply to "Console." and "Console.Out."
|
||||
|
||||
// captured
|
||||
Console.WriteLine("str");
|
||||
Console.WriteLine(new { a = "anon" });
|
||||
Console.WriteLine("{0}", "format");
|
||||
Console.WriteLine("{0}{1}", "zero|", "one");
|
||||
Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.WriteLine("{0}", new object[] { "arr" });
|
||||
|
||||
// not captured
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(true);
|
||||
Console.WriteLine('0');
|
||||
Console.WriteLine(1);
|
||||
Console.WriteLine(2m);
|
||||
Console.WriteLine(3f);
|
||||
Console.WriteLine(4d);
|
||||
Console.WriteLine(5L);
|
||||
Console.WriteLine((uint)6);
|
||||
Console.WriteLine((ulong)7);
|
||||
|
||||
Console.Write("str");
|
||||
Console.Write(true);
|
||||
Console.Write('0');
|
||||
Console.Write(1);
|
||||
Console.Write(2m);
|
||||
Console.Write(3f);
|
||||
Console.Write(4d);
|
||||
Console.Write(5L);
|
||||
Console.Write((uint)6);
|
||||
Console.Write((ulong)7);
|
||||
Console.Write(new { a = "anon" });
|
||||
Console.Write("{0}", "format");
|
||||
Console.Write("{0}{1}", "zero|", "one");
|
||||
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.Write("{0}", new object[] { "arr" });
|
||||
*/
|
||||
#endregion
|
||||
|
||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly.GetName().Name,
|
||||
Version = BuildVersion.ToString(),
|
||||
#if DEBUG
|
||||
Mode = "Debug",
|
||||
#else
|
||||
Mode = "Release",
|
||||
#endif
|
||||
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
|
||||
config.InProgress,
|
||||
|
||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||
DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
|
||||
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||
});
|
||||
}
|
||||
|
||||
public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease()
|
||||
{
|
||||
(bool, string, string, string) isFalse = (false, null, null, null);
|
||||
|
||||
// timed out
|
||||
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||
if (latest is null)
|
||||
return isFalse;
|
||||
|
||||
var latestVersionString = latest.TagName.Trim('v');
|
||||
if (!Version.TryParse(latestVersionString, out var latestRelease))
|
||||
return isFalse;
|
||||
|
||||
// we're up to date
|
||||
if (latestRelease <= BuildVersion)
|
||||
return isFalse;
|
||||
|
||||
// we have an update
|
||||
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
|
||||
var zipUrl = zip?.BrowserDownloadUrl;
|
||||
|
||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||
{
|
||||
latestRelease = latestRelease.ToString(),
|
||||
latest.HtmlUrl,
|
||||
zipUrl
|
||||
});
|
||||
|
||||
return (true, zipUrl, latest.HtmlUrl, zip.Name);
|
||||
}
|
||||
private static Octokit.Release getLatestRelease(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
|
||||
if (task.Wait(timeout))
|
||||
return task.Result;
|
||||
|
||||
Log.Logger.Information("Timed out");
|
||||
}
|
||||
catch (AggregateException aggEx)
|
||||
{
|
||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static Octokit.Release getLatestRelease()
|
||||
{
|
||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation"));
|
||||
|
||||
// https://octokitnet.readthedocs.io/en/latest/releases/
|
||||
var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult();
|
||||
var latest = releases.First(r => !r.Draft && !r.Prerelease);
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Migrations
|
||||
{
|
||||
#region migrate to v5.2.0
|
||||
// get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress
|
||||
public static void migrate_to_v5_2_0__pre_config()
|
||||
{
|
||||
{
|
||||
var settingsKey = "DownloadsInProgressEnum";
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value))
|
||||
{
|
||||
UNSAFE_MigrationHelper.Settings_Delete(settingsKey);
|
||||
UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum");
|
||||
}
|
||||
|
||||
{ // appsettings.json
|
||||
var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY;
|
||||
if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value))
|
||||
UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static string translatePath(string path)
|
||||
=> path switch
|
||||
{
|
||||
"AppDir" => @".\LibationFiles",
|
||||
"MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")),
|
||||
"UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")),
|
||||
"WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")),
|
||||
_ => path
|
||||
};
|
||||
|
||||
public static void migrate_to_v5_2_0__post_config(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||
config.AllowLibationFixup = true;
|
||||
|
||||
if (!config.Exists(nameof(config.DecryptToLossy)))
|
||||
config.DecryptToLossy = false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
// add config.BadBook
|
||||
public static void migrate_to_v5_7_1(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.BadBook)))
|
||||
config.BadBook = Configuration.BadBookAction.Ask;
|
||||
}
|
||||
|
||||
// add config.DownloadEpisodes , config.ImportEpisodes
|
||||
public static void migrate_to_v6_1_2(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
||||
config.DownloadEpisodes = true;
|
||||
|
||||
if (!config.Exists(nameof(config.ImportEpisodes)))
|
||||
config.ImportEpisodes = true;
|
||||
}
|
||||
|
||||
// add config.SplitFilesByChapter
|
||||
public static void migrate_to_v6_2_0(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.SplitFilesByChapter)))
|
||||
config.SplitFilesByChapter = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,21 @@ using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace LibationLauncher
|
||||
namespace AppScaffolding
|
||||
{
|
||||
/// <summary>for migrations only. directly manipulatings settings files without going through domain logic</summary>
|
||||
/// <summary>
|
||||
///
|
||||
///
|
||||
/// directly manipulates settings files without going through domain logic.
|
||||
///
|
||||
/// for migrations only. use with caution.
|
||||
///
|
||||
///
|
||||
/// </summary>
|
||||
internal static class UNSAFE_MigrationHelper
|
||||
{
|
||||
#region appsettings.json
|
||||
private const string APPSETTINGS_JSON = "appsettings.json";
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
|
||||
|
||||
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
|
||||
|
||||
@@ -53,7 +61,16 @@ namespace LibationLauncher
|
||||
return;
|
||||
|
||||
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
||||
var jObj = JObject.Parse(startingContents);
|
||||
|
||||
JObject jObj;
|
||||
try
|
||||
{
|
||||
jObj = JObject.Parse(startingContents);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
action(jObj);
|
||||
|
||||
@@ -122,7 +139,16 @@ namespace LibationLauncher
|
||||
return;
|
||||
|
||||
var startingContents = File.ReadAllText(SettingsJsonPath);
|
||||
var jObj = JObject.Parse(startingContents);
|
||||
|
||||
JObject jObj;
|
||||
try
|
||||
{
|
||||
jObj = JObject.Parse(startingContents);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
action(jObj);
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="27.1.1" />
|
||||
<PackageReference Include="NPOI" Version="2.5.3" />
|
||||
<PackageReference Include="NPOI" Version="2.5.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
|
||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
|
||||
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
|
||||
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
|
||||
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DataLayer;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class DbContexts
|
||||
{
|
||||
//// idea for future command/query separation
|
||||
// public static LibationContext GetCommandContext() { }
|
||||
// public static LibationContext GetQueryContext() { }
|
||||
|
||||
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
|
||||
public static LibationContext GetContext()
|
||||
=> LibationContext.Create(SqliteStorage.ConnectionString);
|
||||
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibrary_Flat_NoTracking();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,47 +3,103 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using DtoImporterService;
|
||||
using InternalUtilities;
|
||||
using LibationFileManager;
|
||||
using Serilog;
|
||||
using static DtoImporterService.PerfLogger;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
// subtly different from DataLayer.LiberatedStatus
|
||||
// - DataLayer.LiberatedStatus: has no concept of partially downloaded
|
||||
// - ApplicationServices.LiberatedState: has no concept of Error/skipped
|
||||
public enum LiberatedState { NotDownloaded, PartialDownload, Liberated }
|
||||
|
||||
public enum PdfState { NoPdf, Downloaded, NotDownloaded }
|
||||
|
||||
public static class LibraryCommands
|
||||
{
|
||||
#region FULL LIBRARY scan and import
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts)
|
||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryResponseGroups =
|
||||
LibraryOptions.ResponseGroupOptions.ProductAttrs |
|
||||
LibraryOptions.ResponseGroupOptions.ProductDesc |
|
||||
LibraryOptions.ResponseGroupOptions.Relationships;
|
||||
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return new List<LibraryBook>();
|
||||
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryResponseGroups);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
|
||||
return missingBookList;
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error scanning library");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
}
|
||||
}
|
||||
|
||||
#region FULL LIBRARY scan and import
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return (0, 0);
|
||||
|
||||
try
|
||||
{
|
||||
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||
var newCount = await importIntoDbAsync(importItems);
|
||||
Log.Logger.Information($"Import: New count {newCount}");
|
||||
|
||||
await Task.Run(() => SearchEngineCommands.FullReIndex());
|
||||
Log.Logger.Information("FullReIndex: success");
|
||||
logTime($"post {nameof(importIntoDbAsync)}");
|
||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(FileManager.Configuration.Instance.LibationFiles);
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
@@ -64,20 +120,23 @@ namespace ApplicationServices
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, Account[] accounts)
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions.ResponseGroupOptions libraryResponseGroups)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
var callback = loginCallbackFactoryFunc(account);
|
||||
|
||||
// get APIs in serial, esp b/c of logins
|
||||
var api = await AudibleApiActions.GetApiAsync(callback, account);
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await apiExtendedfunc(account);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(api, account));
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryResponseGroups));
|
||||
}
|
||||
|
||||
// import library in parallel
|
||||
@@ -86,7 +145,7 @@ namespace ApplicationServices
|
||||
return importItems;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(Api api, Account account)
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions.ResponseGroupOptions libraryResponseGroups)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
@@ -95,142 +154,133 @@ namespace ApplicationServices
|
||||
Account = account?.MaskedLogEntry ?? "[null]"
|
||||
});
|
||||
|
||||
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api);
|
||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||
|
||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryResponseGroups, Configuration.Instance.ImportEpisodes);
|
||||
|
||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||
|
||||
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
||||
}
|
||||
|
||||
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
||||
{
|
||||
logTime("importIntoDbAsync -- pre db");
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryImporter = new LibraryImporter(context);
|
||||
var libraryImporter = new LibraryBookImporter(context);
|
||||
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
|
||||
context.SaveChanges();
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
var qtyChanges = context.SaveChanges();
|
||||
logTime("importIntoDbAsync -- post SaveChanges");
|
||||
|
||||
if (qtyChanges > 0)
|
||||
await Task.Run(() => finalizeLibrarySizeChange());
|
||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||
|
||||
return newCount;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region remove books
|
||||
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var libBooks = context.GetLibrary_Flat_NoTracking();
|
||||
|
||||
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
|
||||
context.LibraryBooks.RemoveRange(removeLibraryBooks);
|
||||
context.Books.RemoveRange(removeLibraryBooks.Select(lb => lb.Book));
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
finalizeLibrarySizeChange();
|
||||
|
||||
return removeLibraryBooks;
|
||||
}
|
||||
#endregion
|
||||
|
||||
// call this whenever books are added or removed from library
|
||||
private static void finalizeLibrarySizeChange()
|
||||
{
|
||||
SearchEngineCommands.FullReIndex();
|
||||
LibrarySizeChanged?.Invoke(null, null);
|
||||
}
|
||||
|
||||
/// <summary>Occurs when books are added or removed from library</summary>
|
||||
public static event EventHandler LibrarySizeChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/>
|
||||
/// changed values are successfully persisted.
|
||||
/// </summary>
|
||||
public static event EventHandler<string> BookUserDefinedItemCommitted;
|
||||
|
||||
#region Update book details
|
||||
public static int UpdateTags(Book book, string newTags)
|
||||
public static int UpdateUserDefinedItem(Book book)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
var udi = book.UserDefinedItem;
|
||||
|
||||
if (udi.Tags == newTags)
|
||||
return 0;
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
udi.Tags = newTags;
|
||||
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
var qtyChanges = context.SaveChanges();
|
||||
|
||||
if (qtyChanges > 0)
|
||||
{
|
||||
SearchEngineCommands.UpdateLiberatedStatus(book);
|
||||
SearchEngineCommands.UpdateBookTags(book);
|
||||
BookUserDefinedItemCommitted?.Invoke(null, book.AudibleProductId);
|
||||
}
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error updating tags");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static int UpdateBook(LibraryBook libraryBook, LiberatedStatus liberatedStatus, string finalAudioPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
var udi = libraryBook.Book.UserDefinedItem;
|
||||
|
||||
if (udi.BookStatus == liberatedStatus && udi.BookLocation == finalAudioPath)
|
||||
return 0;
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
udi.BookStatus = liberatedStatus;
|
||||
udi.BookLocation = finalAudioPath;
|
||||
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
SearchEngineCommands.UpdateLiberatedStatus(libraryBook.Book);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error updating tags");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static int UpdatePdf(LibraryBook libraryBook, LiberatedStatus liberatedStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
var udi = libraryBook.Book.UserDefinedItem;
|
||||
|
||||
if (udi.PdfStatus == liberatedStatus)
|
||||
return 0;
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
udi.PdfStatus = liberatedStatus;
|
||||
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
var qtyChanges = context.SaveChanges();
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error updating tags");
|
||||
Log.Logger.Error(ex, $"Error updating {nameof(book.UserDefinedItem)}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
// must be here instead of in db layer due to AaxcExists
|
||||
public static LiberatedStatus Liberated_Status(Book book)
|
||||
=> book.Audio_Exists ? book.UserDefinedItem.BookStatus
|
||||
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
||||
: LiberatedStatus.NotLiberated;
|
||||
|
||||
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
|
||||
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
|
||||
|
||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
||||
|
||||
public static LiberatedState Liberated_Status(Book book)
|
||||
=> TransitionalFileLocator.Audio_Exists(book) ? LiberatedState.Liberated
|
||||
: TransitionalFileLocator.AAXC_Exists(book) ? LiberatedState.PartialDownload
|
||||
: LiberatedState.NotDownloaded;
|
||||
|
||||
public static PdfState Pdf_Status(Book book)
|
||||
=> !book.Supplements.Any() ? PdfState.NoPdf
|
||||
: TransitionalFileLocator.PDF_Exists(book) ? PdfState.Downloaded
|
||||
: PdfState.NotDownloaded;
|
||||
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int pdfsDownloaded, int pdfsNotDownloaded) { }
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded) { }
|
||||
public static LibraryStats GetCounts()
|
||||
{
|
||||
var libraryBooks = DbContexts.GetContext().GetLibrary_Flat_NoTracking();
|
||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
|
||||
var results = libraryBooks
|
||||
.AsParallel()
|
||||
.Select(lb => Liberated_Status(lb.Book))
|
||||
.ToList();
|
||||
var booksFullyBackedUp = results.Count(r => r == LiberatedState.Liberated);
|
||||
var booksDownloadedOnly = results.Count(r => r == LiberatedState.PartialDownload);
|
||||
var booksNoProgress = results.Count(r => r == LiberatedState.NotDownloaded);
|
||||
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
|
||||
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
|
||||
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
|
||||
var booksError = results.Count(r => r == LiberatedStatus.Error);
|
||||
|
||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress });
|
||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
|
||||
|
||||
var boolResults = libraryBooks
|
||||
.AsParallel()
|
||||
.Where(lb => lb.Book.Supplements.Any())
|
||||
.Where(lb => lb.Book.HasPdf)
|
||||
.Select(lb => Pdf_Status(lb.Book))
|
||||
.ToList();
|
||||
var pdfsDownloaded = boolResults.Count(r => r == PdfState.Downloaded);
|
||||
var pdfsNotDownloaded = boolResults.Count(r => r == PdfState.NotDownloaded);
|
||||
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
|
||||
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
|
||||
|
||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
|
||||
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, pdfsDownloaded, pdfsNotDownloaded);
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ namespace ApplicationServices
|
||||
[Name("Publisher")]
|
||||
public string Publisher { get; set; }
|
||||
|
||||
[Name("Pdf url")]
|
||||
public string PdfUrl { get; set; }
|
||||
[Name("Has PDF")]
|
||||
public bool HasPdf { get; set; }
|
||||
|
||||
[Name("Series Names")]
|
||||
public string SeriesNames { get; set; }
|
||||
@@ -89,14 +89,14 @@ namespace ApplicationServices
|
||||
[Name("My Libation Tags")]
|
||||
public string MyLibationTags { get; set; }
|
||||
|
||||
[Name("Book Liberation Status")]
|
||||
[Name("Book Liberated Status")]
|
||||
public string BookStatus { get; set; }
|
||||
|
||||
[Name("Book File Location")]
|
||||
public string BookLocation { get; set; }
|
||||
|
||||
[Name("PDF Liberation Status")]
|
||||
[Name("PDF Liberated Status")]
|
||||
public string PdfStatus { get; set; }
|
||||
|
||||
[Name("Content Type")]
|
||||
public string ContentType { get; set; }
|
||||
}
|
||||
public static class LibToDtos
|
||||
{
|
||||
@@ -112,9 +112,9 @@ namespace ApplicationServices
|
||||
NarratorNames = a.Book.NarratorNames,
|
||||
LengthInMinutes = a.Book.LengthInMinutes,
|
||||
Publisher = a.Book.Publisher,
|
||||
PdfUrl = a.Book.Supplements?.FirstOrDefault()?.Url,
|
||||
HasPdf = a.Book.HasPdf,
|
||||
SeriesNames = a.Book.SeriesNames,
|
||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Index} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
CommunityRatingOverall = a.Book.Rating?.OverallRating,
|
||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
||||
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
||||
@@ -127,17 +127,15 @@ namespace ApplicationServices
|
||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||
BookLocation = a.Book.UserDefinedItem.BookLocation,
|
||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString()
|
||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||
ContentType = a.Book.ContentType.ToString()
|
||||
}).ToList();
|
||||
}
|
||||
public static class LibraryExporter
|
||||
{
|
||||
public static void ToCsv(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
if (!dtos.Any())
|
||||
return;
|
||||
|
||||
@@ -151,17 +149,14 @@ namespace ApplicationServices
|
||||
|
||||
public static void ToJson(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
|
||||
System.IO.File.WriteAllText(saveFilePath, json);
|
||||
}
|
||||
|
||||
public static void ToXlsx(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var workbook = new XSSFWorkbook();
|
||||
var sheet = workbook.CreateSheet("Library");
|
||||
@@ -186,7 +181,7 @@ namespace ApplicationServices
|
||||
nameof (ExportDto.NarratorNames),
|
||||
nameof (ExportDto.LengthInMinutes),
|
||||
nameof (ExportDto.Publisher),
|
||||
nameof (ExportDto.PdfUrl),
|
||||
nameof (ExportDto.HasPdf),
|
||||
nameof (ExportDto.SeriesNames),
|
||||
nameof (ExportDto.SeriesOrder),
|
||||
nameof (ExportDto.CommunityRatingOverall),
|
||||
@@ -201,8 +196,8 @@ namespace ApplicationServices
|
||||
nameof (ExportDto.MyRatingStory),
|
||||
nameof (ExportDto.MyLibationTags),
|
||||
nameof (ExportDto.BookStatus),
|
||||
nameof (ExportDto.BookLocation),
|
||||
nameof (ExportDto.PdfStatus)
|
||||
nameof (ExportDto.PdfStatus),
|
||||
nameof (ExportDto.ContentType)
|
||||
};
|
||||
var col = 0;
|
||||
foreach (var c in columns)
|
||||
@@ -239,7 +234,7 @@ namespace ApplicationServices
|
||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||
row.CreateCell(col++).SetCellValue(dto.Publisher);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfUrl);
|
||||
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||
|
||||
@@ -265,8 +260,8 @@ namespace ApplicationServices
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.BookLocation);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ namespace ApplicationServices
|
||||
public static void FullReIndex(SearchEngine engine = null)
|
||||
{
|
||||
engine ??= new SearchEngine();
|
||||
engine.CreateNewIndex(DbContexts.GetContext());
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
engine.CreateNewIndex(library);
|
||||
}
|
||||
|
||||
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
|
||||
@@ -39,17 +40,17 @@ namespace ApplicationServices
|
||||
}
|
||||
}
|
||||
|
||||
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> action)
|
||||
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> func)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
try
|
||||
{
|
||||
return action(engine);
|
||||
return func(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex(engine);
|
||||
return action(engine);
|
||||
return func(engine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using FileManager;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class TransitionalFileLocator
|
||||
{
|
||||
public static string Audio_GetPath(Book book)
|
||||
{
|
||||
var loc = book?.UserDefinedItem?.BookLocation ?? "";
|
||||
if (File.Exists(loc))
|
||||
return loc;
|
||||
|
||||
return AudibleFileStorage.Audio.GetPath(book.AudibleProductId);
|
||||
}
|
||||
|
||||
public static bool PDF_Exists(Book book)
|
||||
{
|
||||
var status = book?.UserDefinedItem?.PdfStatus;
|
||||
if (status.HasValue && status.Value == LiberatedStatus.Liberated)
|
||||
return true;
|
||||
|
||||
return AudibleFileStorage.PDF.Exists(book.AudibleProductId);
|
||||
}
|
||||
|
||||
public static bool Audio_Exists(Book book)
|
||||
{
|
||||
var status = book?.UserDefinedItem?.BookStatus;
|
||||
// true since Error == libhack
|
||||
if (status.HasValue && status.Value != LiberatedStatus.NotLiberated)
|
||||
return true;
|
||||
|
||||
return AudibleFileStorage.Audio.Exists(book.AudibleProductId);
|
||||
}
|
||||
|
||||
public static bool AAXC_Exists(Book book)
|
||||
{
|
||||
// this one will actually stay the same. centralizing helps with organization in the interim though
|
||||
return AudibleFileStorage.AAXC.Exists(book.AudibleProductId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InternalUtilities
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
public class Account : IUpdatable
|
||||
{
|
||||
@@ -6,7 +6,7 @@ using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InternalUtilities
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
|
||||
// from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm):
|
||||
@@ -3,7 +3,7 @@ using AudibleApi.Authorization;
|
||||
using Dinah.Core.IO;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InternalUtilities
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
public class AccountsSettingsPersister : JsonFilePersister<AccountsSettings>
|
||||
{
|
||||
301
AudibleUtilities/ApiExtended.cs
Normal file
301
AudibleUtilities/ApiExtended.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using Dinah.Core;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public class ApiExtended
|
||||
{
|
||||
public Api Api { get; private set; }
|
||||
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account?.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = account?.Locale?.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
loginChoiceEager,
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens else login with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginCallback loginCallback)
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginCallback),
|
||||
Account = account?.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = account?.Locale?.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
loginCallback,
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens else login with external browser</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginExternal loginExternal)
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginExternal),
|
||||
Account = account?.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = account?.Locale?.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
loginExternal,
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
||||
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry
|
||||
});
|
||||
|
||||
return await CreateAsync(account.AccountId, account.Locale.Name);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
Username = username.ToMask(),
|
||||
LocaleName = localeName,
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
Localization.Get(localeName),
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
private static AsyncRetryPolicy policy { get; }
|
||||
= Policy.Handle<Exception>()
|
||||
// 2 retries == 3 total
|
||||
.RetryAsync(2);
|
||||
|
||||
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions.ResponseGroupOptions responseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS, bool importEpisodes = true)
|
||||
{
|
||||
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed
|
||||
// worse, this 1st dummy call doesn't seem to help:
|
||||
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
|
||||
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
|
||||
return policy.ExecuteAsync(() => getItemsAsync(responseGroups, importEpisodes));
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions.ResponseGroupOptions responseGroups, bool importEpisodes)
|
||||
{
|
||||
var items = new List<Item>();
|
||||
#if DEBUG
|
||||
//// this will not work for multi accounts
|
||||
//var library_json = "library.json";
|
||||
//library_json = System.IO.Path.GetFullPath(library_json);
|
||||
//if (System.IO.File.Exists(library_json))
|
||||
//{
|
||||
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
|
||||
//}
|
||||
#endif
|
||||
if (!items.Any())
|
||||
items = await Api.GetAllLibraryItemsAsync(responseGroups);
|
||||
|
||||
await manageEpisodesAsync(items, importEpisodes);
|
||||
|
||||
#if DEBUG
|
||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
||||
#endif
|
||||
|
||||
var validators = new List<IValidator>();
|
||||
validators.AddRange(getValidators());
|
||||
foreach (var v in validators)
|
||||
{
|
||||
var exceptions = v.Validate(items);
|
||||
if (exceptions != null && exceptions.Any())
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
#region episodes and podcasts
|
||||
private async Task manageEpisodesAsync(List<Item> items, bool importEpisodes)
|
||||
{
|
||||
// add podcasts and episodes to list. If fail, don't let it de-rail the rest of the import
|
||||
try
|
||||
{
|
||||
// get parents
|
||||
var parents = items.Where(i => i.IsEpisodes).ToList();
|
||||
#if DEBUG
|
||||
//var parentsDebug = parents.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
||||
//System.IO.File.WriteAllText("parents.json", parentsDebug);
|
||||
#endif
|
||||
|
||||
if (!parents.Any())
|
||||
return;
|
||||
|
||||
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
|
||||
|
||||
// remove episode parents. even if the following stuff fails, these will still be removed from the collection.
|
||||
// also must happen before processing children because children abuses this flag
|
||||
items.RemoveAll(i => i.IsEpisodes);
|
||||
|
||||
if (importEpisodes)
|
||||
{
|
||||
// add children
|
||||
var children = await getEpisodesAsync(parents);
|
||||
Serilog.Log.Logger.Information($"{children.Count} episodes of shows/podcasts found");
|
||||
items.AddRange(children);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error adding podcasts and episodes");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodesAsync(List<Item> parents)
|
||||
{
|
||||
var results = new List<Item>();
|
||||
|
||||
foreach (var parent in parents)
|
||||
{
|
||||
var children = await getEpisodeChildrenAsync(parent);
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||
child.PurchaseDate = parent.PurchaseDate;
|
||||
// parent is essentially a series
|
||||
child.Series = new Series[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
Sequence = parent.Relationships.Single(r => r.Asin == child.Asin).Sort.ToString(),
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
// overload (read: abuse) IsEpisodes flag
|
||||
child.Relationships = new Relationship[]
|
||||
{
|
||||
new Relationship
|
||||
{
|
||||
RelationshipToProduct = RelationshipToProduct.Child,
|
||||
RelationshipType = RelationshipType.Episode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
results.AddRange(children);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
|
||||
{
|
||||
var childrenIds = parent.Relationships
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
||||
.Select(r => r.Asin)
|
||||
.ToList();
|
||||
|
||||
// fetch children in batches
|
||||
const int batchSize = 20;
|
||||
|
||||
var results = new List<Item>();
|
||||
|
||||
for (var i = 1; ; i++)
|
||||
{
|
||||
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
|
||||
if (!idBatch.Any())
|
||||
break;
|
||||
|
||||
List<Item> childrenBatch;
|
||||
try
|
||||
{
|
||||
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
#if DEBUG
|
||||
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
||||
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
|
||||
{
|
||||
ParentId = parent.Asin,
|
||||
ParentTitle = parent.Title,
|
||||
BatchNumber = i,
|
||||
ChildIdBatch = idBatch
|
||||
});
|
||||
throw;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results");
|
||||
// the service returned no results. probably indicates an error. stop running batches
|
||||
if (!childrenBatch.Any())
|
||||
break;
|
||||
|
||||
results.AddRange(childrenBatch);
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
|
||||
{
|
||||
ParentId = parent.Asin,
|
||||
ParentTitle = parent.Title,
|
||||
ChildCount = childrenIds.Count
|
||||
});
|
||||
|
||||
if (childrenIds.Count != results.Count)
|
||||
{
|
||||
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
|
||||
Serilog.Log.Logger.Error(ex, "Quantity of series episodes defined by parent does not match quantity returned by batch fetching.");
|
||||
throw ex;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
#endregion
|
||||
|
||||
private static List<IValidator> getValidators()
|
||||
{
|
||||
var type = typeof(IValidator);
|
||||
var types = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(s => s.GetTypes())
|
||||
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
|
||||
|
||||
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InternalUtilities
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
public static class AudibleApiStorage
|
||||
{
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using AudibleApi.Common;
|
||||
|
||||
namespace InternalUtilities
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
public interface IValidator
|
||||
{
|
||||
@@ -33,8 +33,10 @@ namespace InternalUtilities
|
||||
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items)));
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.Title)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.Title)}", nameof(items)));
|
||||
|
||||
// this can happen with podcast episodes
|
||||
foreach (var i in items.Where(i => string.IsNullOrWhiteSpace(i.Title)))
|
||||
i.Title = "[blank title]";
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
@@ -76,9 +78,9 @@ namespace InternalUtilities
|
||||
|
||||
var distinct = items.GetSeriesDistinct();
|
||||
if (distinct.Any(s => s.SeriesId is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(AudibleApiDTOs.Series.SeriesId)}", nameof(items)));
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
|
||||
if (distinct.Any(s => s.SeriesName is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(AudibleApiDTOs.Series.SeriesName)}", nameof(items)));
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
15
AudibleUtilities/AudibleUtilities.csproj
Normal file
15
AudibleUtilities/AudibleUtilities.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="2.3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -7,6 +7,20 @@ namespace DataLayer.Configurations
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LibraryBook> entity)
|
||||
{
|
||||
// to allow same book (incl region) with diff acct.s:
|
||||
//
|
||||
// this file:
|
||||
// - composite key:
|
||||
// entity.HasKey(b => new { b.BookId, b.Account });
|
||||
// entity.HasIndex(b => b.BookId);
|
||||
// entity.HasIndex(b => b.Account);
|
||||
// - change the below relationship since Book+LibraryBook would no longer be 1:1
|
||||
//
|
||||
// other files:
|
||||
// - change Book class since Book+LibraryBook would no longer be 1:1
|
||||
// - update LibraryBook import code
|
||||
// - would likely challenge assumptions throughout Libation which have been true up until now
|
||||
|
||||
entity.HasKey(b => b.BookId);
|
||||
|
||||
entity
|
||||
|
||||
@@ -12,20 +12,20 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.5">
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="1.0.7.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.5">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.EntityFrameworkCore\Dinah.EntityFrameworkCore.csproj" />
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -15,6 +15,10 @@ namespace DataLayer
|
||||
Id = id;
|
||||
}
|
||||
}
|
||||
|
||||
// enum will be easier than bool to extend later
|
||||
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 }
|
||||
|
||||
public class Book
|
||||
{
|
||||
// implementation detail. set by db only. only used by data layer
|
||||
@@ -25,8 +29,7 @@ namespace DataLayer
|
||||
public string Title { get; private set; }
|
||||
public string Description { get; private set; }
|
||||
public int LengthInMinutes { get; private set; }
|
||||
|
||||
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
|
||||
public ContentType ContentType { get; private set; }
|
||||
public string Locale { get; private set; }
|
||||
|
||||
// mutable
|
||||
@@ -51,6 +54,12 @@ namespace DataLayer
|
||||
// is owned, not optional 1:1
|
||||
public UserDefinedItem UserDefinedItem { get; private set; }
|
||||
|
||||
// UserDefinedItem convenience properties
|
||||
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
|
||||
public bool Audio_Exists => UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated;
|
||||
/// <summary>True if exists and IsLiberated. Else false</summary>
|
||||
public bool PDF_Exists => UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
|
||||
|
||||
// is owned, not optional 1:1
|
||||
/// <summary>The product's aggregate community rating</summary>
|
||||
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
||||
@@ -64,9 +73,11 @@ namespace DataLayer
|
||||
string title,
|
||||
string description,
|
||||
int lengthInMinutes,
|
||||
ContentType contentType,
|
||||
IEnumerable<Contributor> authors,
|
||||
IEnumerable<Contributor> narrators,
|
||||
Category category, string localeName)
|
||||
Category category,
|
||||
string localeName)
|
||||
{
|
||||
// validate
|
||||
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
|
||||
@@ -88,9 +99,10 @@ namespace DataLayer
|
||||
Category = category;
|
||||
|
||||
// simple assigns
|
||||
Title = title.Trim();
|
||||
Description = description.Trim();
|
||||
Title = title.Trim() ?? "";
|
||||
Description = description?.Trim() ?? "";
|
||||
LengthInMinutes = lengthInMinutes;
|
||||
ContentType = contentType;
|
||||
|
||||
// assigns with biz logic
|
||||
ReplaceAuthors(authors);
|
||||
@@ -191,7 +203,7 @@ namespace DataLayer
|
||||
}
|
||||
}
|
||||
|
||||
public void UpsertSeries(Series series, float? index = null, DbContext context = null)
|
||||
public void UpsertSeries(Series series, string order, DbContext context = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(series, nameof(series));
|
||||
|
||||
@@ -202,9 +214,9 @@ namespace DataLayer
|
||||
|
||||
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
|
||||
if (singleSeriesBook == null)
|
||||
_seriesLink.Add(new SeriesBook(series, this, index));
|
||||
_seriesLink.Add(new SeriesBook(series, this, order));
|
||||
else
|
||||
singleSeriesBook.UpdateIndex(index);
|
||||
singleSeriesBook.UpdateOrder(order);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -247,10 +259,6 @@ namespace DataLayer
|
||||
Category = category;
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
public void UpdateLocale(string localeName)
|
||||
=> Locale ??= localeName;
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ namespace DataLayer
|
||||
public Book Book { get; private set; }
|
||||
|
||||
public DateTime DateAdded { get; private set; }
|
||||
|
||||
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
|
||||
public string Account { get; private set; }
|
||||
|
||||
private LibraryBook() { }
|
||||
@@ -24,10 +22,6 @@ namespace DataLayer
|
||||
Account = account;
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
public void UpdateAccount(string account)
|
||||
=> Account ??= account;
|
||||
|
||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,25 +48,6 @@ namespace DataLayer
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public void AddBook(Book book, float? index = null, DbContext context = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
|
||||
// our add() is conditional upon what's already included in the collection.
|
||||
// therefore if not loaded, a trip is required. might as well just load it
|
||||
if (_booksLink == null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
if (!context.Entry(this).IsKeySet)
|
||||
throw new InvalidOperationException("Could not add series");
|
||||
|
||||
context.Entry(this).Collection(s => s.BooksLink).Load();
|
||||
}
|
||||
|
||||
if (_booksLink.SingleOrDefault(sb => sb.Book == book) == null)
|
||||
_booksLink.Add(new SeriesBook(this, book, index));
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,32 +7,27 @@ namespace DataLayer
|
||||
internal int SeriesId { get; private set; }
|
||||
internal int BookId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// <para>"index" not "order". This is both for sequence and display</para>
|
||||
/// <para>Float allows for in-between books. eg: 2.5</para>
|
||||
/// <para>To show 2 editions as the same book in a series, give them the same index</para>
|
||||
/// <para>null IS NOT the same as 0. Some series call a book "book 0"</para>
|
||||
/// </summary>
|
||||
public float? Index { get; private set; }
|
||||
public string Order { get; private set; }
|
||||
public float Index => StringLib.ExtractFirstNumber(Order);
|
||||
|
||||
public Series Series { get; private set; }
|
||||
public Book Book { get; private set; }
|
||||
|
||||
private SeriesBook() { }
|
||||
internal SeriesBook(Series series, Book book, float? index = null)
|
||||
internal SeriesBook(Series series, Book book, string order)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(series, nameof(series));
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
|
||||
Series = series;
|
||||
Book = book;
|
||||
Index = index;
|
||||
Order = order;
|
||||
}
|
||||
|
||||
public void UpdateIndex(float? index)
|
||||
public void UpdateOrder(string order)
|
||||
{
|
||||
if (index.HasValue)
|
||||
Index = index.Value;
|
||||
if (!string.IsNullOrWhiteSpace(order))
|
||||
Order = order;
|
||||
}
|
||||
|
||||
public override string ToString() => $"Series={Series} Book={Book}";
|
||||
|
||||
@@ -14,7 +14,10 @@ namespace DataLayer
|
||||
NotLiberated = 0,
|
||||
Liberated = 1,
|
||||
/// <summary>Error occurred during liberation. Don't retry</summary>
|
||||
Error = 2
|
||||
Error = 2,
|
||||
|
||||
/// <summary>Application-state only. Not a valid persistence state.</summary>
|
||||
PartialDownload = 0x1000
|
||||
}
|
||||
|
||||
public class UserDefinedItem
|
||||
@@ -30,7 +33,7 @@ namespace DataLayer
|
||||
|
||||
// import previously saved tags
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
|
||||
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
||||
Tags = LibationFileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
||||
}
|
||||
|
||||
#region Tags
|
||||
@@ -38,7 +41,15 @@ namespace DataLayer
|
||||
public string Tags
|
||||
{
|
||||
get => _tags;
|
||||
set => _tags = sanitize(value);
|
||||
set
|
||||
{
|
||||
var newTags = sanitize(value);
|
||||
if (_tags != newTags)
|
||||
{
|
||||
_tags = newTags;
|
||||
OnItemChanged(nameof(Tags));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
||||
@@ -94,10 +105,93 @@ namespace DataLayer
|
||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||
#endregion
|
||||
|
||||
#region LiberatedStatuses and book file location
|
||||
public LiberatedStatus BookStatus { get; set; }
|
||||
public string BookLocation { get; set; }
|
||||
public LiberatedStatus? PdfStatus { get; set; }
|
||||
#region LiberatedStatuses
|
||||
/// <summary>
|
||||
/// Occurs when <see cref="Tags"/>, <see cref="BookStatus"/>, or <see cref="PdfStatus"/> values change.
|
||||
/// This signals the change of the in-memory value; it does not ensure that the new value has been persisted.
|
||||
/// </summary>
|
||||
public static event EventHandler<string> ItemChanged;
|
||||
|
||||
private void OnItemChanged(string e)
|
||||
{
|
||||
// HACK
|
||||
// must not fire during initial import.
|
||||
//
|
||||
// these checks are necessary because current architecture attaches to this instead of attaching to an event *after* fully committed to db. the attached delegate/action sometimes calls commit:
|
||||
//
|
||||
// desired:
|
||||
// - importing new book
|
||||
// - update pdf status
|
||||
// - initial book commit
|
||||
//
|
||||
// actual without these checks [BAD]:
|
||||
// - importing new book
|
||||
// - update pdf status
|
||||
// - invoke event
|
||||
// - commit UserDefinedItem
|
||||
// - initial book commit
|
||||
if (BookId > 0 && Book is not null && Book.BookId > 0)
|
||||
ItemChanged?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private LiberatedStatus _bookStatus;
|
||||
private LiberatedStatus? _pdfStatus;
|
||||
public LiberatedStatus BookStatus
|
||||
{
|
||||
get => _bookStatus;
|
||||
set
|
||||
{
|
||||
if (_bookStatus != value)
|
||||
{
|
||||
_bookStatus = value;
|
||||
OnItemChanged(nameof(BookStatus));
|
||||
}
|
||||
}
|
||||
}
|
||||
public LiberatedStatus? PdfStatus
|
||||
{
|
||||
get => _pdfStatus;
|
||||
set
|
||||
{
|
||||
if (_pdfStatus != value)
|
||||
{
|
||||
_pdfStatus = value;
|
||||
OnItemChanged(nameof(PdfStatus));
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region batch changes
|
||||
public static event EventHandler<string> Batch_ItemChanged;
|
||||
public void BatchMode_UpdateBookStatus(LiberatedStatus value)
|
||||
{
|
||||
if (_bookStatus != value)
|
||||
{
|
||||
_bookStatus = value;
|
||||
batchFlag = true;
|
||||
}
|
||||
}
|
||||
|
||||
// don't overwrite current with null. Therefore input is "LiberatedStatus" not "LiberatedStatus?"
|
||||
public void BatchMode_UpdatePdfStatus(LiberatedStatus value)
|
||||
{
|
||||
if (_pdfStatus != value)
|
||||
{
|
||||
_pdfStatus = value;
|
||||
batchFlag = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool batchFlag = false;
|
||||
|
||||
public static void BatchMode_Finalize()
|
||||
{
|
||||
if (batchFlag)
|
||||
Batch_ItemChanged?.Invoke(null, null);
|
||||
|
||||
batchFlag = false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public override string ToString() => $"{Book} {Rating} {Tags}";
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace DataLayer
|
||||
// // overwrite collection
|
||||
// Entry(product).Collection(x => x.Narrators).Load();
|
||||
// product.Narrators = narrators;
|
||||
public DbSet<LibraryBook> Library { get; private set; }
|
||||
public DbSet<LibraryBook> LibraryBooks { get; private set; }
|
||||
public DbSet<Book> Books { get; private set; }
|
||||
public DbSet<Contributor> Contributors { get; private set; }
|
||||
public DbSet<Series> Series { get; private set; }
|
||||
|
||||
387
DataLayer/Migrations/20210821012137_RemoveUdiBookLocation.Designer.cs
generated
Normal file
387
DataLayer/Migrations/20210821012137_RemoveUdiBookLocation.Designer.cs
generated
Normal file
@@ -0,0 +1,387 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20210821012137_RemoveUdiBookLocation")]
|
||||
partial class RemoveUdiBookLocation
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.5");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("Library");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
23
DataLayer/Migrations/20210821012137_RemoveUdiBookLocation.cs
Normal file
23
DataLayer/Migrations/20210821012137_RemoveUdiBookLocation.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class RemoveUdiBookLocation : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BookLocation",
|
||||
table: "UserDefinedItem");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BookLocation",
|
||||
table: "UserDefinedItem",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
390
DataLayer/Migrations/20210901205042_BookIsEpisode.Designer.cs
generated
Normal file
390
DataLayer/Migrations/20210901205042_BookIsEpisode.Designer.cs
generated
Normal file
@@ -0,0 +1,390 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20210901205042_BookIsEpisode")]
|
||||
partial class BookIsEpisode
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.9");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("Library");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
24
DataLayer/Migrations/20210901205042_BookIsEpisode.cs
Normal file
24
DataLayer/Migrations/20210901205042_BookIsEpisode.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class BookIsEpisode : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ContentType",
|
||||
table: "Books",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ContentType",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
390
DataLayer/Migrations/20210902192153_RenameLibraryBooks.Designer.cs
generated
Normal file
390
DataLayer/Migrations/20210902192153_RenameLibraryBooks.Designer.cs
generated
Normal file
@@ -0,0 +1,390 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20210902192153_RenameLibraryBooks")]
|
||||
partial class RenameLibraryBooks
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.9");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
63
DataLayer/Migrations/20210902192153_RenameLibraryBooks.cs
Normal file
63
DataLayer/Migrations/20210902192153_RenameLibraryBooks.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class RenameLibraryBooks : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Library_Books_BookId",
|
||||
table: "Library");
|
||||
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_Library",
|
||||
table: "Library");
|
||||
|
||||
migrationBuilder.RenameTable(
|
||||
name: "Library",
|
||||
newName: "LibraryBooks");
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_LibraryBooks",
|
||||
table: "LibraryBooks",
|
||||
column: "BookId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_LibraryBooks_Books_BookId",
|
||||
table: "LibraryBooks",
|
||||
column: "BookId",
|
||||
principalTable: "Books",
|
||||
principalColumn: "BookId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_LibraryBooks_Books_BookId",
|
||||
table: "LibraryBooks");
|
||||
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_LibraryBooks",
|
||||
table: "LibraryBooks");
|
||||
|
||||
migrationBuilder.RenameTable(
|
||||
name: "LibraryBooks",
|
||||
newName: "Library");
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_Library",
|
||||
table: "Library",
|
||||
column: "BookId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Library_Books_BookId",
|
||||
table: "Library",
|
||||
column: "BookId",
|
||||
principalTable: "Books",
|
||||
principalColumn: "BookId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
390
DataLayer/Migrations/20210922154900_AddSeriesOrderString.Designer.cs
generated
Normal file
390
DataLayer/Migrations/20210922154900_AddSeriesOrderString.Designer.cs
generated
Normal file
@@ -0,0 +1,390 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20210922154900_AddSeriesOrderString")]
|
||||
partial class AddSeriesOrderString
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.10");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
33
DataLayer/Migrations/20210922154900_AddSeriesOrderString.cs
Normal file
33
DataLayer/Migrations/20210922154900_AddSeriesOrderString.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class AddSeriesOrderString : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Index",
|
||||
table: "SeriesBook");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Order",
|
||||
table: "SeriesBook",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Order",
|
||||
table: "SeriesBook");
|
||||
|
||||
migrationBuilder.AddColumn<float>(
|
||||
name: "Index",
|
||||
table: "SeriesBook",
|
||||
type: "REAL",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.5");
|
||||
.HasAnnotation("ProductVersion", "5.0.10");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
@@ -28,6 +28,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -152,7 +155,7 @@ namespace DataLayer.Migrations
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("Library");
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
@@ -182,8 +185,8 @@ namespace DataLayer.Migrations
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("REAL");
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
@@ -253,9 +256,6 @@ namespace DataLayer.Migrations
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("BookLocation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public static class GenericPaging
|
||||
{
|
||||
public static IQueryable<T> Page<T>(this IQueryable<T> query, int pageNumZeroStart, int pageSize)
|
||||
{
|
||||
if (pageSize < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be at least 1");
|
||||
|
||||
if (pageNumZeroStart > 0)
|
||||
query = query.Skip(pageNumZeroStart * pageSize);
|
||||
|
||||
return query.Take(pageSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace DataLayer
|
||||
{
|
||||
// only library importing should use tracking. All else should be NoTracking.
|
||||
// only library importing should directly query Book. All else should use LibraryBook
|
||||
public static class LibraryQueries
|
||||
public static class LibraryBookQueries
|
||||
{
|
||||
//// tracking is a bad idea for main grid. it prevents anything else from updating entities unless getting them from the grid
|
||||
//public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
|
||||
@@ -17,14 +17,14 @@ namespace DataLayer
|
||||
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
|
||||
=> context
|
||||
.Library
|
||||
.LibraryBooks
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibrary()
|
||||
.ToList();
|
||||
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
=> context
|
||||
.Library
|
||||
.LibraryBooks
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibraryBook(productId);
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace DataLayer
|
||||
.Select(t => (t.Book.AudibleProductId, t.Tags))
|
||||
.ToList();
|
||||
|
||||
FileManager.TagsPersistence.Save(tagsCollection);
|
||||
LibationFileManager.TagsPersistence.Save(tagsCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DataLayer.Utilities
|
||||
{
|
||||
public static class LocalDatabaseInfo
|
||||
{
|
||||
public static List<string> GetLocalDBInstances()
|
||||
{
|
||||
// Start the child process.
|
||||
using var p = new System.Diagnostics.Process
|
||||
{
|
||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
FileName = "cmd.exe",
|
||||
Arguments = "/C sqllocaldb info",
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden
|
||||
}
|
||||
};
|
||||
p.Start();
|
||||
var output = p.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
|
||||
// if LocalDb is not installed then it will return that 'sqllocaldb' is not recognized as an internal or external command operable program or batch file
|
||||
return string.IsNullOrWhiteSpace(output) || output.Contains("not recognized")
|
||||
? new List<string>()
|
||||
: output
|
||||
.Split(new string[] { Environment.NewLine }, StringSplitOptions.None)
|
||||
.Select(i => i.Trim())
|
||||
.Where(i => !string.IsNullOrEmpty(i))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
FOR QUICK MIGRATION INSTRUCTIONS:
|
||||
_DB_NOTES.txt
|
||||
|
||||
|
||||
HOW TO CREATE: EF CORE PROJECT
|
||||
==============================
|
||||
example is for sqlite but the same works with MsSql
|
||||
|
||||
|
||||
nuget
|
||||
Microsoft.EntityFrameworkCore.Tools (needed for using Package Manager Console)
|
||||
Microsoft.EntityFrameworkCore.Sqlite
|
||||
|
||||
MIGRATIONS
|
||||
require core, not standard
|
||||
this can be a problem b/c standard and framework can only reference standard, not core
|
||||
TO USE MIGRATIONS (core and/or standard)
|
||||
add to csproj
|
||||
<PropertyGroup>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
</PropertyGroup>
|
||||
TO USE MIGRATIONS AS *BOTH* CORE AND STANDARD
|
||||
edit csproj
|
||||
pluralize this xml tag
|
||||
from: TargetFramework
|
||||
to: TargetFrameworks
|
||||
inside of TargetFrameworks
|
||||
from: netstandard2.1
|
||||
to: netcoreapp3.1;netstandard2.1
|
||||
|
||||
run. error
|
||||
SQLite Error 1: 'no such table: Blogs'.
|
||||
|
||||
set project "Set as StartUp Project"
|
||||
|
||||
Tools >> Nuget Package Manager >> Package Manager Console
|
||||
default project: Examples\SQLite_NETCore2_0
|
||||
|
||||
PM> add-migration InitialCreate
|
||||
PM> Update-Database
|
||||
|
||||
if add-migration xyz throws and error, don't take the error msg at face value. try again with add-migration xyz -verbose
|
||||
|
||||
new sqlite .db file created: Copy always/Copy if newer
|
||||
or copy .db file to destination
|
||||
|
||||
relative:
|
||||
optionsBuilder.UseSqlite("Data Source=blogging.db");
|
||||
absolute (use fwd slashes):
|
||||
optionsBuilder.UseSqlite("Data Source=C:/foo/bar/blogging.db");
|
||||
|
||||
|
||||
REFERENCE ARTICLES
|
||||
------------------
|
||||
https://docs.microsoft.com/en-us/ef/core/get-started/netcore/new-db-sqlite
|
||||
https://carlos.mendible.com/2016/07/11/step-by-step-dotnet-core-and-entity-framework-core/
|
||||
https://www.benday.com/2017/12/19/ef-core-2-0-migrations-without-hard-coded-connection-strings/
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
@@ -19,13 +19,13 @@ namespace DtoImporterService
|
||||
new ContributorImporter(DbContext).Import(importItems);
|
||||
new SeriesImporter(DbContext).Import(importItems);
|
||||
new CategoryImporter(DbContext).Import(importItems);
|
||||
|
||||
|
||||
// get distinct
|
||||
var productIds = importItems.Select(i => i.DtoItem.ProductId).ToList();
|
||||
|
||||
var productIds = importItems.Select(i => i.DtoItem.ProductId).Distinct().ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_books(productIds);
|
||||
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertBooks(importItems);
|
||||
return qtyNew;
|
||||
@@ -33,12 +33,30 @@ namespace DtoImporterService
|
||||
|
||||
private void loadLocal_books(List<string> productIds)
|
||||
{
|
||||
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId);
|
||||
// if this context has already loaded books, don't need to reload them. vestige from when context was long-lived. in practice, we now typically use a fresh context. this is quick though so no harm in leaving it.
|
||||
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId).ToList();
|
||||
var remainingProductIds = productIds
|
||||
.Distinct()
|
||||
.Except(localProductIds)
|
||||
.ToList();
|
||||
|
||||
#region // explanation of DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
/*
|
||||
articles suggest loading to Local with
|
||||
context.Books.Load();
|
||||
we want Books and associated fields
|
||||
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
this is emulating Load() but with also getting associated fields
|
||||
|
||||
from: Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
||||
// Summary:
|
||||
// Enumerates the query. When using Entity Framework, this causes the results of
|
||||
// the query to be loaded into the associated context. This is equivalent to calling
|
||||
// ToList and then throwing away the list (without the overhead of actually creating
|
||||
// the list).
|
||||
public static void Load<TSource>([NotNullAttribute] this IQueryable<TSource> source);
|
||||
*/
|
||||
#endregion
|
||||
|
||||
// GetBooks() eager loads Series, category, et al
|
||||
if (remainingProductIds.Any())
|
||||
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
@@ -50,7 +68,7 @@ namespace DtoImporterService
|
||||
|
||||
foreach (var item in importItems)
|
||||
{
|
||||
var book = DbContext.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
|
||||
var book = DbContext.Books.Local.FirstOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
|
||||
if (book is null)
|
||||
{
|
||||
book = createNewBook(item);
|
||||
@@ -67,6 +85,8 @@ namespace DtoImporterService
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
|
||||
|
||||
// absence of authors is very rare, but possible
|
||||
if (!item.Authors?.Any() ?? true)
|
||||
item.Authors = new[] { new Person { Name = "", Asin = null } };
|
||||
@@ -105,6 +125,7 @@ namespace DtoImporterService
|
||||
item.TitleWithSubtitle,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
authors,
|
||||
narrators,
|
||||
category,
|
||||
@@ -120,8 +141,8 @@ namespace DtoImporterService
|
||||
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
|
||||
book.AddSupplementDownloadUrl(item.SupplementUrl);
|
||||
if (item.PdfUrl is not null)
|
||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||
|
||||
return book;
|
||||
}
|
||||
@@ -134,9 +155,6 @@ namespace DtoImporterService
|
||||
book.PictureId = item.PictureId;
|
||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||
|
||||
// needed during v3 => v4 migration
|
||||
book.UpdateLocale(importItem.LocaleName);
|
||||
|
||||
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
||||
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
||||
|
||||
@@ -146,19 +164,8 @@ namespace DtoImporterService
|
||||
{
|
||||
foreach (var seriesEntry in item.Series)
|
||||
{
|
||||
var series = DbContext.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||
|
||||
var index = 0f;
|
||||
try
|
||||
{
|
||||
index = seriesEntry.Index;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Error parsing series index. Title: {item.Title}. ASIN: {item.Asin}. Series index: {seriesEntry.Sequence}");
|
||||
}
|
||||
|
||||
book.UpsertSeries(series, index);
|
||||
var series = DbContext.Series.Local.FirstOrDefault(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||
book.UpsertSeries(series, seriesEntry.Sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
@@ -35,7 +35,7 @@ namespace DtoImporterService
|
||||
|
||||
private void loadLocal_categories(List<string> categoryIds)
|
||||
{
|
||||
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId);
|
||||
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId).ToList();
|
||||
var remainingCategoryIds = categoryIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
@@ -66,9 +66,11 @@ namespace DtoImporterService
|
||||
|
||||
Category parentCategory = null;
|
||||
if (i == 1)
|
||||
parentCategory = DbContext.Categories.Local.Single(c => c.AudibleCategoryId == pair[0].CategoryId);
|
||||
// should be "Single()" but user is getting a strange error
|
||||
parentCategory = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == pair[0].CategoryId);
|
||||
|
||||
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == id);
|
||||
// should be "SingleOrDefault()" but user is getting a strange error
|
||||
var category = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == id);
|
||||
if (category is null)
|
||||
{
|
||||
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
@@ -47,57 +47,61 @@ namespace DtoImporterService
|
||||
|
||||
private void loadLocal_contributors(List<string> contributorNames)
|
||||
{
|
||||
// must include default/empty/missing
|
||||
contributorNames.Add(Contributor.GetEmpty().Name);
|
||||
|
||||
//// BAD: very inefficient
|
||||
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
|
||||
|
||||
// GOOD: Except() is efficient. Due to hashing, it's close to O(n)
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name);
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
|
||||
var remainingContribNames = contributorNames
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
|
||||
// load existing => local
|
||||
// remember to include default/empty/missing
|
||||
var emptyName = Contributor.GetEmpty().Name;
|
||||
if (remainingContribNames.Any())
|
||||
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name) || c.Name == emptyName).ToList();
|
||||
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList();
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPeople(List<Person> people)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
|
||||
var newPeople = people
|
||||
.Select(p => p.Name)
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
|
||||
foreach (var p in people)
|
||||
var groupby = people.GroupBy(
|
||||
p => p.Name,
|
||||
p => p,
|
||||
(key, g) => new { Name = key, People = g.ToList() }
|
||||
);
|
||||
foreach (var name in newPeople)
|
||||
{
|
||||
// Should be 'Single' not 'First'. A user had a duplicate get in somehow though so I'm now using 'First' defensively
|
||||
var person = DbContext.Contributors.Local.FirstOrDefault(c => c.Name == p.Name);
|
||||
if (person == null)
|
||||
{
|
||||
person = DbContext.Contributors.Add(new Contributor(p.Name, p.Asin)).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
var p = groupby.Single(g => g.Name == name).People.First();
|
||||
DbContext.Contributors.Add(new Contributor(p.Name, p.Asin));
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
return newPeople.Count;
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPublishers(List<string> publishers)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
|
||||
var newPublishers = publishers
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
|
||||
foreach (var publisherName in publishers)
|
||||
{
|
||||
if (DbContext.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
|
||||
{
|
||||
DbContext.Contributors.Add(new Contributor(publisherName));
|
||||
qtyNew++;
|
||||
}
|
||||
}
|
||||
foreach (var pub in newPublishers)
|
||||
DbContext.Contributors.Add(new Contributor(pub));
|
||||
|
||||
return qtyNew;
|
||||
return newPublishers.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using AudibleApiDTOs;
|
||||
using AudibleApi.Common;
|
||||
|
||||
namespace InternalUtilities
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class ImportItem
|
||||
{
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class LibraryImporter : ItemsImporterBase
|
||||
public class LibraryBookImporter : ItemsImporterBase
|
||||
{
|
||||
public LibraryImporter(LibationContext context) : base(context) { }
|
||||
public LibraryBookImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
@@ -28,13 +27,14 @@ namespace DtoImporterService
|
||||
// - what to show in the grid
|
||||
// - which to consider liberated
|
||||
//
|
||||
// sqlite cannot alter pk. the work around is an extensive headache. it'll be fixed in pre .net5/efcore5
|
||||
// sqlite cannot alter pk. the work around is an extensive headache
|
||||
// - update: now possible in .net5/efcore5
|
||||
//
|
||||
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
|
||||
//
|
||||
// CURRENT SOLUTION: don't re-insert
|
||||
|
||||
var currentLibraryProductIds = DbContext.Library.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
|
||||
|
||||
foreach (var newItem in newItems)
|
||||
@@ -43,16 +43,7 @@ namespace DtoImporterService
|
||||
DbContext.Books.Local.Single(b => b.AudibleProductId == newItem.DtoItem.ProductId),
|
||||
newItem.DtoItem.DateAdded,
|
||||
newItem.AccountId);
|
||||
DbContext.Library.Add(libraryBook);
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
var toUpdate = DbContext.Library.Where(l => l.Account == null);
|
||||
foreach (var u in toUpdate)
|
||||
{
|
||||
var item = importItems.FirstOrDefault(ii => ii.DtoItem.ProductId == u.Book.AudibleProductId);
|
||||
if (item != null)
|
||||
u.UpdateAccount(item.AccountId);
|
||||
DbContext.LibraryBooks.Add(libraryBook);
|
||||
}
|
||||
|
||||
var qtyNew = newItems.Count;
|
||||
34
DtoImporterService/PerfLogger.cs
Normal file
34
DtoImporterService/PerfLogger.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public record timeLogEntry(string msg, long totalElapsed, long delta);
|
||||
public static class PerfLogger
|
||||
{
|
||||
private static Stopwatch sw = new Stopwatch();
|
||||
private static List<timeLogEntry> __log { get; } = new List<timeLogEntry> { new("begin", 0, 0) };
|
||||
|
||||
public static void logTime(string s)
|
||||
{
|
||||
var totalElapsed = sw.ElapsedMilliseconds;
|
||||
|
||||
var prev = __log.Last().totalElapsed;
|
||||
var delta = totalElapsed - prev;
|
||||
|
||||
__log.Add(new(s, totalElapsed, delta));
|
||||
}
|
||||
public static void logRestart()
|
||||
{
|
||||
__log.Clear();
|
||||
__log.Add(new("begin", 0, 0));
|
||||
sw.Restart();
|
||||
}
|
||||
public static void stop() => sw.Stop();
|
||||
public static string logOutput =>
|
||||
$"{nameof(timeLogEntry.msg)}\t{nameof(timeLogEntry.totalElapsed)}\t{nameof(timeLogEntry.delta)}\r\n"
|
||||
+ __log.Select(t => $"{t.msg}\t{t.totalElapsed}\t{t.delta}").Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
@@ -29,7 +29,7 @@ namespace DtoImporterService
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_series(List<AudibleApiDTOs.Series> series)
|
||||
private void loadLocal_series(List<AudibleApi.Common.Series> series)
|
||||
{
|
||||
var seriesIds = series.Select(s => s.SeriesId).ToList();
|
||||
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
|
||||
@@ -42,13 +42,13 @@ namespace DtoImporterService
|
||||
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries)
|
||||
private int upsertSeries(List<AudibleApi.Common.Series> requestedSeries)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var s in requestedSeries)
|
||||
{
|
||||
var series = DbContext.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == s.SeriesId);
|
||||
var series = DbContext.Series.Local.FirstOrDefault(c => c.AudibleSeriesId == s.SeriesId);
|
||||
if (series is null)
|
||||
{
|
||||
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
||||
|
||||
44
FileLiberator/AudioDecodable.cs
Normal file
44
FileLiberator/AudioDecodable.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class AudioDecodable : Processable
|
||||
{
|
||||
public event EventHandler<Action<byte[]>> RequestCoverArt;
|
||||
public event EventHandler<string> TitleDiscovered;
|
||||
public event EventHandler<string> AuthorsDiscovered;
|
||||
public event EventHandler<string> NarratorsDiscovered;
|
||||
public event EventHandler<byte[]> CoverImageDiscovered;
|
||||
public abstract void Cancel();
|
||||
|
||||
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
|
||||
RequestCoverArt?.Invoke(this, setCoverArtDel);
|
||||
}
|
||||
|
||||
protected void OnTitleDiscovered(string title)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
|
||||
TitleDiscovered?.Invoke(this, title);
|
||||
}
|
||||
|
||||
protected void OnAuthorsDiscovered(string authors)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
|
||||
AuthorsDiscovered?.Invoke(this, authors);
|
||||
}
|
||||
|
||||
protected void OnNarratorsDiscovered(string narrators)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
|
||||
NarratorsDiscovered?.Invoke(this, narrators);
|
||||
}
|
||||
|
||||
protected void OnCoverImageDiscovered(byte[] coverImage)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(CoverImageDiscovered), CoverImageBytes = coverImage?.Length });
|
||||
CoverImageDiscovered?.Invoke(this, coverImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
FileLiberator/AudioFileStorageExt.cs
Normal file
69
FileLiberator/AudioFileStorageExt.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class AudioFileStorageExt
|
||||
{
|
||||
public static string MultipartFilename(this AudioFileStorage _, string outputFileName, int partsPosition, int partsTotal, AAXClean.NewSplitCallback newSplitCallback)
|
||||
=> MultipartFilename(outputFileName, partsPosition, partsTotal, newSplitCallback);
|
||||
public static string MultipartFilename(string outputFileName, int partsPosition, int partsTotal, AAXClean.NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var template = Path.ChangeExtension(outputFileName, null) + " - <chapter> - <title>" + Path.GetExtension(outputFileName);
|
||||
|
||||
var fileTemplate = new FileTemplate(template) { IllegalCharacterReplacements = " " };
|
||||
fileTemplate.AddParameterReplacement("chapter", FileUtility.GetSequenceFormatted(partsPosition, partsTotal));
|
||||
fileTemplate.AddParameterReplacement("title", newSplitCallback?.Chapter?.Title ?? "");
|
||||
|
||||
return fileTemplate.GetFilePath();
|
||||
}
|
||||
|
||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> GetInProgressFilename(libraryBook, extension);
|
||||
public static string GetInProgressFilename(LibraryBook libraryBook, string extension)
|
||||
=> GetValidFilename(AudibleFileStorage.DecryptInProgressDirectory, libraryBook.Book.Title, extension, libraryBook);
|
||||
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> GetBooksDirectoryFilename(libraryBook, extension);
|
||||
public static string GetBooksDirectoryFilename(LibraryBook libraryBook, string extension)
|
||||
=> GetValidFilename(AudibleFileStorage.BooksDirectory, libraryBook.Book.Title, extension, libraryBook);
|
||||
|
||||
public static string CreateDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> CreateDestinationDirectory(libraryBook);
|
||||
public static string CreateDestinationDirectory(LibraryBook libraryBook)
|
||||
{
|
||||
var title = libraryBook.Book.Title;
|
||||
|
||||
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
|
||||
var underscoreIndex = title.IndexOf(':');
|
||||
var titleDir
|
||||
= underscoreIndex < 4
|
||||
? title
|
||||
: title.Substring(0, underscoreIndex);
|
||||
var destinationDir = GetValidFilename(AudibleFileStorage.BooksDirectory, titleDir, null, libraryBook);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
return destinationDir;
|
||||
}
|
||||
|
||||
public static string GetValidFilename(string dirFullPath, string filename, string extension, LibraryBook libraryBook)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(dirFullPath, nameof(dirFullPath));
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(filename, nameof(filename));
|
||||
|
||||
var template = $"<title> [<id>]";
|
||||
|
||||
var fullfilename = Path.Combine(dirFullPath, template + FileUtility.GetStandardizedExtension(extension));
|
||||
|
||||
var fileTemplate = new FileTemplate(fullfilename) { IllegalCharacterReplacements = "_" };
|
||||
fileTemplate.AddParameterReplacement("title", filename);
|
||||
fileTemplate.AddParameterReplacement("id", libraryBook.Book.AudibleProductId);
|
||||
return fileTemplate.GetFilePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
/// <summary>
|
||||
/// Download DRM book and decrypt audiobook files
|
||||
///
|
||||
/// Processes:
|
||||
/// Download: download aax file: the DRM encrypted audiobook
|
||||
/// Decrypt: remove DRM encryption from audiobook. Store final book
|
||||
/// Backup: perform all steps (downloaded, decrypt) still needed to get final book
|
||||
/// </summary>
|
||||
public class BackupBook : IProcessable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public DownloadDecryptBook DownloadDecryptBook { get; } = new DownloadDecryptBook();
|
||||
public DownloadPdf DownloadPdf { get; } = new DownloadPdf();
|
||||
|
||||
public bool Validate(LibraryBook libraryBook)
|
||||
=> !ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book);
|
||||
|
||||
// do NOT use ConfigureAwait(false) on ProcessAsync()
|
||||
// often calls events which prints to forms in the UI context
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
{
|
||||
var statusHandler = await DownloadDecryptBook.TryProcessAsync(libraryBook);
|
||||
if (statusHandler.HasErrors)
|
||||
return statusHandler;
|
||||
}
|
||||
|
||||
{
|
||||
var statusHandler = await DownloadPdf.TryProcessAsync(libraryBook);
|
||||
if (statusHandler.HasErrors)
|
||||
return statusHandler;
|
||||
}
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,49 @@
|
||||
using AAXClean;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class ConvertToMp3 : IDecryptable
|
||||
public class ConvertToMp3 : AudioDecodable
|
||||
{
|
||||
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<TimeSpan> UpdateRemainingTime;
|
||||
public event EventHandler<string> DecryptCompleted;
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
public event EventHandler<Action<byte[]>> RequestCoverArt;
|
||||
|
||||
private Mp4File m4bBook;
|
||||
|
||||
private string Mp3FileName(string m4bPath) => m4bPath is null ? string.Empty : PathLib.ReplaceExtension(m4bPath, ".mp3");
|
||||
private long fileSize;
|
||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||
|
||||
public void Cancel() => m4bBook?.Cancel();
|
||||
public override void Cancel() => m4bBook?.Cancel();
|
||||
|
||||
public bool Validate(LibraryBook libraryBook)
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
{
|
||||
var path = ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book);
|
||||
var path = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
|
||||
return path?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path));
|
||||
}
|
||||
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
OnBegin(libraryBook);
|
||||
|
||||
DecryptBegin?.Invoke(this, $"Begin converting {libraryBook} to mp3");
|
||||
OnStreamingBegin($"Begin converting {libraryBook} to mp3");
|
||||
|
||||
try
|
||||
{
|
||||
var m4bPath = ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book);
|
||||
|
||||
var m4bPath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
|
||||
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
|
||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
|
||||
TitleDiscovered?.Invoke(this, m4bBook.AppleTags.Title);
|
||||
AuthorsDiscovered?.Invoke(this, m4bBook.AppleTags.FirstAuthor);
|
||||
NarratorsDiscovered?.Invoke(this, m4bBook.AppleTags.Narrator);
|
||||
CoverImageFilepathDiscovered?.Invoke(this, m4bBook.AppleTags.Cover);
|
||||
fileSize = m4bBook.InputStream.Length;
|
||||
|
||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||
|
||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||
|
||||
@@ -63,9 +51,9 @@ namespace FileLiberator
|
||||
m4bBook.InputStream.Close();
|
||||
mp3File.Close();
|
||||
|
||||
var mp3Path = Mp3FileName(m4bPath);
|
||||
|
||||
FileExt.SafeMove(mp3File.Name, mp3Path);
|
||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path);
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
|
||||
var statusHandler = new StatusHandler();
|
||||
|
||||
@@ -76,8 +64,8 @@ namespace FileLiberator
|
||||
}
|
||||
finally
|
||||
{
|
||||
DecryptCompleted?.Invoke(this, $"Completed converting to mp3: {libraryBook.Book.Title}");
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
OnStreamingCompleted($"Completed converting to mp3: {libraryBook.Book.Title}");
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +76,17 @@ namespace FileLiberator
|
||||
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
UpdateRemainingTime?.Invoke(this, TimeSpan.FromSeconds(estTimeRemaining));
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
|
||||
UpdateProgress?.Invoke(this, (int)progressPercent);
|
||||
OnStreamingProgressChanged(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = (long)(fileSize * progressPercent),
|
||||
TotalBytesToReceive = fileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,221 +9,198 @@ using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadDecryptBook : IDecryptable
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
public event EventHandler<Action<byte[]>> RequestCoverArt;
|
||||
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<TimeSpan> UpdateRemainingTime;
|
||||
public event EventHandler<string> DecryptCompleted;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
private AudiobookDownloadBase abDownloader;
|
||||
|
||||
private AaxcDownloadConverter aaxcDownloader;
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists;
|
||||
|
||||
public override void Cancel() => abDownloader?.Cancel();
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, 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);
|
||||
|
||||
try
|
||||
{
|
||||
if (ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book))
|
||||
if (libraryBook.Book.Audio_Exists)
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
var outputAudioFilename = await aaxToM4bConverterDecryptAsync(AudibleFileStorage.DownloadsInProgress, AudibleFileStorage.DecryptInProgress, libraryBook);
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||
FilePathCache.Removed += FilePathCache_Removed;
|
||||
|
||||
success = await downloadAudiobookAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||
FilePathCache.Removed -= FilePathCache_Removed;
|
||||
}
|
||||
|
||||
// decrypt failed
|
||||
if (outputAudioFilename is null)
|
||||
if (!success)
|
||||
return new StatusHandler { "Decrypt failed" };
|
||||
|
||||
// moves files and returns dest dir
|
||||
_ = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
|
||||
// moves new files from temp dir to final dest
|
||||
var movedAudioFile = moveFilesToBooksDir(libraryBook, entries);
|
||||
|
||||
var finalAudioExists = ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book);
|
||||
if (!finalAudioExists)
|
||||
// decrypt failed
|
||||
if (!movedAudioFile)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
|
||||
// only need to update if success. if failure, it will remain at 0 == NotLiberated
|
||||
ApplicationServices.LibraryCommands.UpdateBook(libraryBook, LiberatedStatus.Liberated, outputAudioFilename);
|
||||
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> aaxToM4bConverterDecryptAsync(string cacheDir, string destinationDir, LibraryBook libraryBook)
|
||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||
{
|
||||
DecryptBegin?.Invoke(this, $"Begin decrypting {libraryBook}");
|
||||
OnStreamingBegin($"Begin decrypting {libraryBook}");
|
||||
|
||||
try
|
||||
{
|
||||
validate(libraryBook);
|
||||
|
||||
var api = await InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
downloadValidation(libraryBook);
|
||||
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
var aaxcDecryptDlLic = new DownloadLicense
|
||||
var audiobookDlLic = new DownloadLicense
|
||||
(
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
contentLic?.Voucher?.Key,
|
||||
contentLic?.Voucher?.Iv,
|
||||
"Audible/671 CFNetwork/1240.0.4 Darwin/20.6.0"
|
||||
Resources.USER_AGENT
|
||||
);
|
||||
|
||||
if (Configuration.Instance.AllowLibationFixup)
|
||||
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
||||
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
||||
//These assumptions may be wrong, and only time and bug reports will tell.
|
||||
var outputFormat =
|
||||
contentLic.ContentMetadata.ContentReference.ContentFormat == "MPEG" ||
|
||||
(Configuration.Instance.AllowLibationFixup && Configuration.Instance.DecryptToLossy) ?
|
||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||
|
||||
if (Configuration.Instance.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
|
||||
{
|
||||
aaxcDecryptDlLic.ChapterInfo = new AAXClean.ChapterInfo();
|
||||
audiobookDlLic.ChapterInfo = new AAXClean.ChapterInfo();
|
||||
|
||||
foreach (var chap in contentLic.ContentMetadata?.ChapterInfo?.Chapters)
|
||||
aaxcDecryptDlLic.ChapterInfo.AddChapter(chap.Title, TimeSpan.FromMilliseconds(chap.LengthMs));
|
||||
audiobookDlLic.ChapterInfo.AddChapter(chap.Title, TimeSpan.FromMilliseconds(chap.LengthMs));
|
||||
}
|
||||
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, outputFormat.ToString().ToLower());
|
||||
|
||||
var format = Configuration.Instance.DecryptToLossy ? OutputFormat.Mp3 : OutputFormat.Mp4a;
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
var extension = format switch
|
||||
{
|
||||
OutputFormat.Mp4a => "m4b",
|
||||
OutputFormat.Mp3 => "mp3",
|
||||
_ => throw new NotImplementedException(),
|
||||
};
|
||||
|
||||
var outFileName = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].{extension}");
|
||||
|
||||
|
||||
aaxcDownloader = new AaxcDownloadConverter(outFileName, cacheDir, aaxcDecryptDlLic, format) { AppName = "Libation" };
|
||||
aaxcDownloader.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress);
|
||||
aaxcDownloader.DecryptTimeRemaining += (s, remaining) => UpdateRemainingTime?.Invoke(this, remaining);
|
||||
aaxcDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
aaxcDownloader.RetrievedTags += aaxcDownloader_RetrievedTags;
|
||||
abDownloader
|
||||
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm ? new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic)
|
||||
: Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(outFileName, cacheDir, audiobookDlLic, outputFormat, AudibleFileStorage.Audio.MultipartFilename)
|
||||
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic, outputFormat);
|
||||
abDownloader.DecryptProgressUpdate += (_, progress) => OnStreamingProgressChanged(progress);
|
||||
abDownloader.DecryptTimeRemaining += (_, remaining) => OnStreamingTimeRemaining(remaining);
|
||||
abDownloader.RetrievedTitle += (_, title) => OnTitleDiscovered(title);
|
||||
abDownloader.RetrievedAuthors += (_, authors) => OnAuthorsDiscovered(authors);
|
||||
abDownloader.RetrievedNarrators += (_, narrators) => OnNarratorsDiscovered(narrators);
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
var success = await Task.Run(() => aaxcDownloader.Run());
|
||||
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
return null;
|
||||
|
||||
return outFileName;
|
||||
var success = await Task.Run(abDownloader.Run);
|
||||
return success;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DecryptCompleted?.Invoke(this, $"Completed downloading and decrypting {libraryBook.Book.Title}");
|
||||
OnStreamingCompleted($"Completed downloading and decrypting {libraryBook.Book.Title}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void downloadValidation(LibraryBook libraryBook)
|
||||
{
|
||||
string errorString(string field)
|
||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new Exception(errorString("Account"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new Exception(errorString("Locale"));
|
||||
}
|
||||
|
||||
private void AaxcDownloader_RetrievedCoverArt(object sender, byte[] e)
|
||||
{
|
||||
if (e is null && Configuration.Instance.AllowLibationFixup)
|
||||
{
|
||||
RequestCoverArt?.Invoke(this, aaxcDownloader.SetCoverArt);
|
||||
}
|
||||
|
||||
if (e is not null)
|
||||
{
|
||||
CoverImageFilepathDiscovered?.Invoke(this, e);
|
||||
}
|
||||
OnCoverImageDiscovered(e);
|
||||
else if (Configuration.Instance.AllowLibationFixup)
|
||||
OnRequestCoverArt(abDownloader.SetCoverArt);
|
||||
}
|
||||
|
||||
private void aaxcDownloader_RetrievedTags(object sender, AAXClean.AppleTags e)
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
|
||||
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
{
|
||||
TitleDiscovered?.Invoke(this, e.TitleSansUnabridged);
|
||||
AuthorsDiscovered?.Invoke(this, e.FirstAuthor ?? "[unknown]");
|
||||
NarratorsDiscovered?.Invoke(this, e.Narrator ?? "[unknown]");
|
||||
}
|
||||
// create final directory. move each file into it
|
||||
var destinationDir = AudibleFileStorage.Audio.CreateDestinationDirectory(libraryBook);
|
||||
|
||||
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 + "]"
|
||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestDir(product.Title, product.AudibleProductId);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
if (getFirstAudio() == default)
|
||||
return false;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
|
||||
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()
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
var entry = entries[i];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new Exception(errorString("Account"));
|
||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)));
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new Exception(errorString("Locale"));
|
||||
// propogate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||
entries[i] = entry with { Path = realDest };
|
||||
}
|
||||
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
||||
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Validate(LibraryBook libraryBook)
|
||||
=> !ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book);
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
aaxcDownloader?.Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,29 +6,26 @@ using Dinah.Core.Net.Http;
|
||||
namespace FileLiberator
|
||||
{
|
||||
// currently only used to download the .zip flies for upgrade
|
||||
public class DownloadFile : IDownloadable
|
||||
public class DownloadFile : Streamable
|
||||
{
|
||||
public event EventHandler<string> DownloadBegin;
|
||||
public event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
public event EventHandler<string> DownloadCompleted;
|
||||
|
||||
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
|
||||
progress.ProgressChanged += (_, e) => OnStreamingProgressChanged(e);
|
||||
|
||||
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
|
||||
OnStreamingBegin(proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated("Upgrade", actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
|
||||
OnStreamingCompleted(proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,60 +8,79 @@ using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadPdf : DownloadableBase
|
||||
public class DownloadPdf : Processable
|
||||
{
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
|
||||
&& !ApplicationServices.TransitionalFileLocator.PDF_Exists(libraryBook.Book);
|
||||
&& !libraryBook.Book.PDF_Exists;
|
||||
|
||||
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
||||
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
var result = verifyDownload(libraryBook);
|
||||
OnBegin(libraryBook);
|
||||
|
||||
var liberatedStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
|
||||
ApplicationServices.LibraryCommands.UpdatePdf(libraryBook, liberatedStatus);
|
||||
try
|
||||
{
|
||||
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
var result = verifyDownload(actualDownloadedFilePath);
|
||||
|
||||
return result;
|
||||
libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
||||
{
|
||||
// if audio file exists, get it's dir. else return base Book dir
|
||||
var existingPath = Path.GetDirectoryName(ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book));
|
||||
var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId));
|
||||
var file = getdownloadUrl(libraryBook);
|
||||
|
||||
if (existingPath != null)
|
||||
return Path.Combine(existingPath, Path.GetFileName(file));
|
||||
|
||||
var full = FileUtility.GetValidFilename(
|
||||
AudibleFileStorage.PDF.StorageDirectory,
|
||||
libraryBook.Book.Title,
|
||||
Path.GetExtension(file),
|
||||
libraryBook.Book.AudibleProductId);
|
||||
return full;
|
||||
return AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, Path.GetExtension(file));
|
||||
}
|
||||
|
||||
private static string getdownloadUrl(LibraryBook libraryBook)
|
||||
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
|
||||
|
||||
private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
{
|
||||
var api = await GetApiAsync(libraryBook);
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
OnStreamingBegin(proposedDownloadFilePath);
|
||||
|
||||
var client = new HttpClient();
|
||||
var actualDownloadedFilePath = await PerformDownloadAsync(
|
||||
proposedDownloadFilePath,
|
||||
(p) => client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, p));
|
||||
try
|
||||
{
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => OnStreamingProgressChanged(e);
|
||||
|
||||
var client = new HttpClient();
|
||||
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
|
||||
OnStatusUpdate(actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted(proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static StatusHandler verifyDownload(LibraryBook libraryBook)
|
||||
=> !ApplicationServices.TransitionalFileLocator.PDF_Exists(libraryBook.Book)
|
||||
private static StatusHandler verifyDownload(string actualDownloadedFilePath)
|
||||
=> !File.Exists(actualDownloadedFilePath)
|
||||
? new StatusHandler { "Downloaded PDF cannot be found" }
|
||||
: new StatusHandler();
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class DownloadableBase : IDownloadableProcessable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public event EventHandler<string> DownloadBegin;
|
||||
public event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
public event EventHandler<string> DownloadCompleted;
|
||||
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
protected void Invoke_StatusUpdate(string message) => StatusUpdate?.Invoke(this, message);
|
||||
|
||||
public abstract bool Validate(LibraryBook libraryBook);
|
||||
|
||||
public abstract Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook);
|
||||
|
||||
// do NOT use ConfigureAwait(false) on ProcessAsync()
|
||||
// often calls events which prints to forms in the UI context
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
return await ProcessItemAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
protected static Task<AudibleApi.Api> GetApiAsync(LibraryBook libraryBook)
|
||||
=> InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
protected async Task<string> PerformDownloadAsync(string proposedDownloadFilePath, Func<Progress<DownloadProgress>, Task<string>> func)
|
||||
{
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
|
||||
|
||||
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await func(progress);
|
||||
StatusUpdate?.Invoke(this, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
||||
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IDecryptable : IProcessable
|
||||
{
|
||||
event EventHandler<string> DecryptBegin;
|
||||
|
||||
event EventHandler<Action<byte[]>> RequestCoverArt;
|
||||
event EventHandler<string> TitleDiscovered;
|
||||
event EventHandler<string> AuthorsDiscovered;
|
||||
event EventHandler<string> NarratorsDiscovered;
|
||||
event EventHandler<byte[]> CoverImageFilepathDiscovered;
|
||||
event EventHandler<int> UpdateProgress;
|
||||
event EventHandler<TimeSpan> UpdateRemainingTime;
|
||||
|
||||
event EventHandler<string> DecryptCompleted;
|
||||
void Cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IDownloadable
|
||||
{
|
||||
event EventHandler<string> DownloadBegin;
|
||||
event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
event EventHandler<string> DownloadCompleted;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IDownloadableProcessable : IDownloadable, IProcessable { }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IProcessable
|
||||
{
|
||||
event EventHandler<LibraryBook> Begin;
|
||||
|
||||
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
|
||||
event EventHandler<string> StatusUpdate;
|
||||
|
||||
event EventHandler<LibraryBook> Completed;
|
||||
|
||||
/// <returns>True == Valid</returns>
|
||||
bool Validate(LibraryBook libraryBook);
|
||||
|
||||
/// <returns>True == success</returns>
|
||||
Task<StatusHandler> ProcessAsync(LibraryBook libraryBook);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class IProcessableExt
|
||||
{
|
||||
//
|
||||
// DO NOT USE ConfigureAwait(false) WITH ProcessAsync() unless ensuring ProcessAsync() implementation is cross-thread compatible
|
||||
// ProcessAsync() often does a lot with forms in the UI context
|
||||
//
|
||||
|
||||
|
||||
// when used in foreach: stateful. deferred execution
|
||||
public static IEnumerable<LibraryBook> GetValidLibraryBooks(this IProcessable processable)
|
||||
=> DbContexts.GetContext()
|
||||
.GetLibrary_Flat_NoTracking()
|
||||
.Where(libraryBook => processable.Validate(libraryBook));
|
||||
|
||||
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, LibraryBook libraryBook)
|
||||
{
|
||||
if (!processable.Validate(libraryBook))
|
||||
return new StatusHandler { "Validation failed" };
|
||||
|
||||
return await processable.ProcessBookAsync_NoValidation(libraryBook);
|
||||
}
|
||||
|
||||
public static async Task<StatusHandler> ProcessBookAsync_NoValidation(this IProcessable processable, LibraryBook libraryBook)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin " + nameof(ProcessBookAsync_NoValidation) + " {@DebugInfo}", new
|
||||
{
|
||||
libraryBook.Book.Title,
|
||||
libraryBook.Book.AudibleProductId,
|
||||
libraryBook.Book.Locale,
|
||||
Account = libraryBook.Account?.ToMask() ?? "[empty]"
|
||||
});
|
||||
|
||||
var status
|
||||
= (await processable.ProcessAsync(libraryBook))
|
||||
?? new StatusHandler { "Processable should never return a null status" };
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public static async Task<StatusHandler> TryProcessAsync(this IProcessable processable, LibraryBook libraryBook)
|
||||
=> processable.Validate(libraryBook)
|
||||
? await processable.ProcessAsync(libraryBook)
|
||||
: new StatusHandler();
|
||||
}
|
||||
}
|
||||
76
FileLiberator/Processable.cs
Normal file
76
FileLiberator/Processable.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class Processable : Streamable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
|
||||
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
/// <returns>True == Valid</returns>
|
||||
public abstract bool Validate(LibraryBook libraryBook);
|
||||
|
||||
/// <returns>True == success</returns>
|
||||
public abstract Task<StatusHandler> ProcessAsync(LibraryBook libraryBook);
|
||||
|
||||
// when used in foreach: stateful. deferred execution
|
||||
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
|
||||
=> library.Where(libraryBook =>
|
||||
Validate(libraryBook)
|
||||
&& (libraryBook.Book.ContentType != ContentType.Episode || LibationFileManager.Configuration.Instance.DownloadEpisodes)
|
||||
);
|
||||
|
||||
public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate)
|
||||
{
|
||||
if (validate && !Validate(libraryBook))
|
||||
return new StatusHandler { "Validation failed" };
|
||||
|
||||
Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new
|
||||
{
|
||||
libraryBook.Book.Title,
|
||||
libraryBook.Book.AudibleProductId,
|
||||
libraryBook.Book.Locale,
|
||||
Account = libraryBook.Account?.ToMask() ?? "[empty]"
|
||||
});
|
||||
|
||||
var status
|
||||
= (await ProcessAsync(libraryBook))
|
||||
?? new StatusHandler { "Processable should never return a null status" };
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<StatusHandler> TryProcessAsync(LibraryBook libraryBook)
|
||||
=> Validate(libraryBook)
|
||||
? await ProcessAsync(libraryBook)
|
||||
: new StatusHandler();
|
||||
|
||||
protected void OnBegin(LibraryBook libraryBook)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Begin), Book = libraryBook.LogFriendly() });
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
}
|
||||
|
||||
protected void OnStatusUpdate(string statusUpdate)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StatusUpdate), Status = statusUpdate });
|
||||
StatusUpdate?.Invoke(this, statusUpdate);
|
||||
}
|
||||
|
||||
protected void OnCompleted(LibraryBook libraryBook)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
FileLiberator/Streamable.cs
Normal file
45
FileLiberator/Streamable.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class Streamable
|
||||
{
|
||||
public event EventHandler<string> StreamingBegin;
|
||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
||||
public event EventHandler<string> StreamingCompleted;
|
||||
/// <summary>Fired when a file is successfully saved to disk</summary>
|
||||
public event EventHandler<(string id, string path)> FileCreated;
|
||||
|
||||
protected void OnStreamingBegin(string filePath)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StreamingBegin), Message = filePath });
|
||||
StreamingBegin?.Invoke(this, filePath);
|
||||
}
|
||||
|
||||
protected void OnStreamingProgressChanged(DownloadProgress progress)
|
||||
{
|
||||
StreamingProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
|
||||
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining)
|
||||
{
|
||||
StreamingTimeRemaining?.Invoke(this, timeRemaining);
|
||||
}
|
||||
|
||||
protected void OnStreamingCompleted(string filePath)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StreamingCompleted), Message = filePath });
|
||||
StreamingCompleted?.Invoke(this, filePath);
|
||||
}
|
||||
|
||||
protected void OnFileCreated(DataLayer.LibraryBook libraryBook, string path) => OnFileCreated(libraryBook.Book.AudibleProductId, path);
|
||||
protected void OnFileCreated(string id, string path)
|
||||
{
|
||||
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), id, path });
|
||||
LibationFileManager.FilePathCache.Insert(id, path);
|
||||
FileCreated?.Invoke(this, (id, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
26
FileLiberator/UtilityExtensions.cs
Normal file
26
FileLiberator/UtilityExtensions.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class UtilityExtensions
|
||||
{
|
||||
public static (string id, string title, string locale, string account) LogFriendly(this LibraryBook libraryBook)
|
||||
=> (
|
||||
id: libraryBook.Book.AudibleProductId,
|
||||
title: libraryBook.Book.Title,
|
||||
locale: libraryBook.Book.Locale,
|
||||
account: libraryBook.Account.ToMask()
|
||||
);
|
||||
|
||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||
{
|
||||
var apiExtended = await AudibleUtilities.ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
return apiExtended.Api;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public enum FileType { Unknown, Audio, AAXC, PDF }
|
||||
|
||||
public abstract class AudibleFileStorage : Enumeration<AudibleFileStorage>
|
||||
{
|
||||
protected abstract string[] Extensions { get; }
|
||||
public abstract string StorageDirectory { get; }
|
||||
|
||||
#region static
|
||||
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
|
||||
public static AudibleFileStorage AAXC { get; } = new AaxcFileStorage();
|
||||
public static AudibleFileStorage PDF { get; } = new PdfFileStorage();
|
||||
|
||||
public static string DownloadsInProgress => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
|
||||
|
||||
public static string DecryptInProgress => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
|
||||
|
||||
public static string BooksDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
|
||||
Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books");
|
||||
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
|
||||
}
|
||||
}
|
||||
|
||||
private static BackgroundFileSystem BookDirectoryFiles { get; } = new BackgroundFileSystem();
|
||||
#endregion
|
||||
|
||||
#region instance
|
||||
public FileType FileType => (FileType)Value;
|
||||
|
||||
private IEnumerable<string> extensions_noDots { get; }
|
||||
private string extAggr { get; }
|
||||
|
||||
protected AudibleFileStorage(FileType fileType) : base((int)fileType, fileType.ToString())
|
||||
{
|
||||
extensions_noDots = Extensions.Select(ext => ext.Trim('.')).ToList();
|
||||
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
BookDirectoryFiles.RefreshFiles();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example for full books:
|
||||
/// Search recursively in _books directory. Full book exists if either are true
|
||||
/// - a directory name has the product id and an audio file is immediately inside
|
||||
/// - any audio filename contains the product id
|
||||
/// </summary>
|
||||
public bool Exists(string productId) => GetPath(productId) != null;
|
||||
|
||||
public string GetPath(string productId)
|
||||
{
|
||||
var cachedFile = FilePathCache.GetPath(productId, FileType);
|
||||
if (cachedFile != null)
|
||||
return cachedFile;
|
||||
|
||||
string storageDir = StorageDirectory;
|
||||
string regexPattern = $@"{productId}.*?\.({extAggr})$";
|
||||
string firstOrNull;
|
||||
|
||||
if (storageDir == BooksDirectory)
|
||||
{
|
||||
//If user changed the BooksDirectory, reinitialize.
|
||||
if (storageDir != BookDirectoryFiles.RootDirectory)
|
||||
BookDirectoryFiles.Init(storageDir, "*.*", SearchOption.AllDirectories);
|
||||
|
||||
firstOrNull = BookDirectoryFiles.FindFile(regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
else
|
||||
{
|
||||
firstOrNull =
|
||||
Directory
|
||||
.EnumerateFiles(storageDir, "*.*", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, RegexOptions.IgnoreCase));
|
||||
}
|
||||
|
||||
if (firstOrNull is null)
|
||||
return null;
|
||||
|
||||
FilePathCache.Upsert(productId, FileType, firstOrNull);
|
||||
return firstOrNull;
|
||||
}
|
||||
|
||||
public string GetDestDir(string title, string asin)
|
||||
{
|
||||
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
|
||||
var underscoreIndex = title.IndexOf(':');
|
||||
var titleDir
|
||||
= underscoreIndex < 4
|
||||
? title
|
||||
: title.Substring(0, underscoreIndex);
|
||||
var finalDir = FileUtility.GetValidFilename(StorageDirectory, titleDir, null, asin);
|
||||
return finalDir;
|
||||
}
|
||||
|
||||
public bool IsFileTypeMatch(FileInfo fileInfo)
|
||||
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class AudioFileStorage : AudibleFileStorage
|
||||
{
|
||||
public const string SKIP_FILE_EXT = "libhack";
|
||||
|
||||
protected override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac", SKIP_FILE_EXT };
|
||||
|
||||
// we always want to use the latest config value, therefore
|
||||
// - DO use 'get' arrow "=>"
|
||||
// - do NOT use assign "="
|
||||
public override string StorageDirectory => BooksDirectory;
|
||||
|
||||
public AudioFileStorage() : base(FileType.Audio) { }
|
||||
|
||||
public string CreateSkipFile(string title, string asin, string contents = null)
|
||||
{
|
||||
var destinationDir = GetDestDir(title, asin);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
var path = FileUtility.GetValidFilename(destinationDir, title, SKIP_FILE_EXT, asin);
|
||||
File.WriteAllText(path, contents ?? string.Empty);
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public class AaxcFileStorage : AudibleFileStorage
|
||||
{
|
||||
protected override string[] Extensions { get; } = new[] { "aaxc" };
|
||||
|
||||
// we always want to use the latest config value, therefore
|
||||
// - DO use 'get' arrow "=>"
|
||||
// - do NOT use assign "="
|
||||
public override string StorageDirectory => DownloadsInProgress;
|
||||
|
||||
public AaxcFileStorage() : base(FileType.AAXC) { }
|
||||
}
|
||||
|
||||
public class PdfFileStorage : AudibleFileStorage
|
||||
{
|
||||
protected override string[] Extensions { get; } = new[] { "pdf", "zip" };
|
||||
|
||||
// we always want to use the latest config value, therefore
|
||||
// - DO use 'get' arrow "=>"
|
||||
// - do NOT use assign "="
|
||||
public override string StorageDirectory => BooksDirectory;
|
||||
|
||||
public PdfFileStorage() : base(FileType.PDF) { }
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,14 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
class BackgroundFileSystem
|
||||
/// <summary>
|
||||
/// Tracks actual locations of files.
|
||||
/// </summary>
|
||||
public class BackgroundFileSystem
|
||||
{
|
||||
public string RootDirectory { get; private set; }
|
||||
public string SearchPattern { get; private set; }
|
||||
@@ -18,74 +19,77 @@ namespace FileManager
|
||||
private FileSystemWatcher fileSystemWatcher { get; set; }
|
||||
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
|
||||
private Task backgroundScanner { get; set; }
|
||||
private List<string> fsCache { get; set; }
|
||||
|
||||
public string FindFile(string regexPattern, RegexOptions options)
|
||||
private object fsCacheLocker { get; } = new();
|
||||
private List<string> fsCache { get; } = new();
|
||||
|
||||
public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions)
|
||||
{
|
||||
if (fsCache is null) return null;
|
||||
RootDirectory = rootDirectory;
|
||||
SearchPattern = searchPattern;
|
||||
SearchOption = searchOptions;
|
||||
|
||||
lock (fsCache)
|
||||
{
|
||||
return fsCache.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, options));
|
||||
}
|
||||
Init();
|
||||
}
|
||||
|
||||
public string FindFile(System.Text.RegularExpressions.Regex regex)
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
|
||||
}
|
||||
|
||||
public void RefreshFiles()
|
||||
{
|
||||
if (fsCache is null) return;
|
||||
|
||||
lock (fsCache)
|
||||
lock (fsCacheLocker)
|
||||
{
|
||||
fsCache.Clear();
|
||||
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
||||
}
|
||||
}
|
||||
|
||||
public void Init(string rootDirectory, string searchPattern, SearchOption searchOptions)
|
||||
private void Init()
|
||||
{
|
||||
RootDirectory = rootDirectory;
|
||||
SearchPattern = searchPattern;
|
||||
SearchOption = searchOptions;
|
||||
Stop();
|
||||
|
||||
//Calling CompleteAdding() will cause background scanner to terminate.
|
||||
directoryChangesEvents?.CompleteAdding();
|
||||
fsCache?.Clear();
|
||||
directoryChangesEvents?.Dispose();
|
||||
fileSystemWatcher?.Dispose();
|
||||
|
||||
fsCache = Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption).ToList();
|
||||
lock (fsCacheLocker)
|
||||
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
||||
|
||||
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory);
|
||||
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Error += FileSystemWatcher_Error;
|
||||
fileSystemWatcher.IncludeSubdirectories = true;
|
||||
fileSystemWatcher.EnableRaisingEvents = true;
|
||||
|
||||
//Wait for background scanner to terminate before reinitializing.
|
||||
backgroundScanner?.Wait();
|
||||
backgroundScanner = new Task(BackgroundScanner);
|
||||
backgroundScanner.Start();
|
||||
}
|
||||
private void Stop()
|
||||
{
|
||||
//Stop raising events
|
||||
fileSystemWatcher?.Dispose();
|
||||
|
||||
private void AddUniqueFiles(IEnumerable<string> newFiles)
|
||||
{
|
||||
foreach (var file in newFiles)
|
||||
{
|
||||
AddUniqueFile(file);
|
||||
}
|
||||
}
|
||||
private void AddUniqueFile(string newFile)
|
||||
{
|
||||
if (!fsCache.Contains(newFile))
|
||||
fsCache.Add(newFile);
|
||||
//Calling CompleteAdding() will cause background scanner to terminate.
|
||||
directoryChangesEvents?.CompleteAdding();
|
||||
|
||||
//Wait for background scanner to terminate before reinitializing.
|
||||
backgroundScanner?.Wait();
|
||||
|
||||
//Dispose of directoryChangesEvents after backgroundScanner exists.
|
||||
directoryChangesEvents?.Dispose();
|
||||
|
||||
lock (fsCacheLocker)
|
||||
fsCache.Clear();
|
||||
}
|
||||
|
||||
private void FileSystemWatcher_Error(object sender, ErrorEventArgs e)
|
||||
{
|
||||
Init(RootDirectory, SearchPattern, SearchOption);
|
||||
Stop();
|
||||
Init();
|
||||
}
|
||||
|
||||
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
|
||||
@@ -97,28 +101,26 @@ namespace FileManager
|
||||
private void BackgroundScanner()
|
||||
{
|
||||
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
|
||||
UpdateLocalCache(change);
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
UpdateLocalCache(change);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLocalCache(FileSystemEventArgs change)
|
||||
private void UpdateLocalCache(FileSystemEventArgs change)
|
||||
{
|
||||
lock (fsCache)
|
||||
if (change.ChangeType == WatcherChangeTypes.Deleted)
|
||||
{
|
||||
if (change.ChangeType == WatcherChangeTypes.Deleted)
|
||||
{
|
||||
RemovePath(change.FullPath);
|
||||
}
|
||||
else if (change.ChangeType == WatcherChangeTypes.Created)
|
||||
{
|
||||
AddPath(change.FullPath);
|
||||
}
|
||||
else if (change.ChangeType == WatcherChangeTypes.Renamed)
|
||||
{
|
||||
var renameChange = change as RenamedEventArgs;
|
||||
|
||||
RemovePath(renameChange.OldFullPath);
|
||||
AddPath(renameChange.FullPath);
|
||||
}
|
||||
RemovePath(change.FullPath);
|
||||
}
|
||||
else if (change.ChangeType == WatcherChangeTypes.Created)
|
||||
{
|
||||
AddPath(change.FullPath);
|
||||
}
|
||||
else if (change.ChangeType == WatcherChangeTypes.Renamed && change is RenamedEventArgs renameChange)
|
||||
{
|
||||
RemovePath(renameChange.OldFullPath);
|
||||
AddPath(renameChange.FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +139,19 @@ namespace FileManager
|
||||
else
|
||||
AddUniqueFile(path);
|
||||
}
|
||||
private void AddUniqueFiles(IEnumerable<string> newFiles)
|
||||
{
|
||||
foreach (var file in newFiles)
|
||||
AddUniqueFile(file);
|
||||
}
|
||||
private void AddUniqueFile(string newFile)
|
||||
{
|
||||
if (!fsCache.Contains(newFile))
|
||||
fsCache.Add(newFile);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
~BackgroundFileSystem() => Stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
|
||||
<PackageReference Include="Octokit" Version="0.50.0" />
|
||||
<PackageReference Include="Dinah.Core" Version="2.0.1.1" />
|
||||
<PackageReference Include="Polly" Version="7.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Collections.Immutable;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class FilePathCache
|
||||
{
|
||||
internal class CacheEntry
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public FileType FileType { get; set; }
|
||||
public string Path { get; set; }
|
||||
}
|
||||
|
||||
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
|
||||
|
||||
private static string jsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json");
|
||||
|
||||
static FilePathCache()
|
||||
{
|
||||
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
|
||||
if (File.Exists(jsonFile))
|
||||
{
|
||||
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(jsonFile));
|
||||
cache = new Cache<CacheEntry>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Exists(string id, FileType type) => GetPath(id, type) != null;
|
||||
|
||||
public static string GetPath(string id, FileType type)
|
||||
{
|
||||
var entry = cache.SingleOrDefault(i => i.Id == id && i.FileType == type);
|
||||
|
||||
if (entry == null)
|
||||
return null;
|
||||
|
||||
if (!File.Exists(entry.Path))
|
||||
{
|
||||
remove(entry);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Path;
|
||||
}
|
||||
|
||||
private static void remove(CacheEntry entry)
|
||||
{
|
||||
cache.Remove(entry);
|
||||
save();
|
||||
}
|
||||
|
||||
public static void Upsert(string id, FileType type, string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException("Cannot add path to cache. File not found");
|
||||
|
||||
var entry = cache.SingleOrDefault(i => i.Id == id && i.FileType == type);
|
||||
|
||||
if (entry is null)
|
||||
cache.Add(new CacheEntry { Id = id, FileType = type, Path = path });
|
||||
else
|
||||
entry.Path = path;
|
||||
|
||||
save();
|
||||
}
|
||||
|
||||
// cache is thread-safe and lock free. but file saving is not
|
||||
private static object locker { get; } = new object();
|
||||
private static void save()
|
||||
{
|
||||
// create json if not exists
|
||||
static void resave() => File.WriteAllText(jsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
|
||||
|
||||
lock (locker)
|
||||
{
|
||||
try { resave(); }
|
||||
catch (IOException)
|
||||
{
|
||||
try { resave(); }
|
||||
catch (IOException ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error saving FilePaths.json");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
FileManager/FileTemplate.cs
Normal file
51
FileManager/FileTemplate.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||
public class FileTemplate
|
||||
{
|
||||
/// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: <name></summary>
|
||||
public string Template { get; }
|
||||
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public FileTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
|
||||
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/</summary>
|
||||
public Dictionary<string, string> ParameterReplacements { get; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>Convenience method</summary>
|
||||
public void AddParameterReplacement(string key ,string value) => ParameterReplacements.Add(key, value);
|
||||
|
||||
/// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary>
|
||||
public int? ParameterMaxSize { get; set; } = 50;
|
||||
|
||||
/// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
|
||||
public string IllegalCharacterReplacements { get; set; }
|
||||
|
||||
/// <summary>Generate a valid path for this file or directory</summary>
|
||||
public string GetFilePath()
|
||||
{
|
||||
var filename = Template;
|
||||
|
||||
foreach (var r in ParameterReplacements)
|
||||
filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value));
|
||||
|
||||
return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements);
|
||||
}
|
||||
|
||||
private string formatKey(string key)
|
||||
=> key
|
||||
.Replace("<", "")
|
||||
.Replace(">", "");
|
||||
|
||||
private string formatValue(string value)
|
||||
=> ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0
|
||||
? value?.Truncate(ParameterMaxSize.Value)
|
||||
: value;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class FileUtility
|
||||
{
|
||||
public static string GetValidFilename(string dirFullPath, string filename, string extension, params string[] metadataSuffixes)
|
||||
/// <summary>
|
||||
/// "txt" => ".txt"
|
||||
/// <br />".txt" => ".txt"
|
||||
/// <br />null or whitespace => ""
|
||||
/// </summary>
|
||||
public static string GetStandardizedExtension(string extension)
|
||||
=> string.IsNullOrWhiteSpace(extension)
|
||||
? (extension ?? "")?.Trim()
|
||||
: '.' + extension.Trim('.');
|
||||
|
||||
/// <summary>
|
||||
/// Return position with correct number of leading zeros.
|
||||
/// <br />- 2 of 9 => "2"
|
||||
/// <br />- 2 of 90 => "02"
|
||||
/// <br />- 2 of 900 => "002"
|
||||
/// </summary>
|
||||
/// <param name="position">position in sequence. The 'x' in 'x of y'</param>
|
||||
/// <param name="total">total qty in sequence. The 'y' in 'x of y'</param>
|
||||
public static string GetSequenceFormatted(int position, int total)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dirFullPath))
|
||||
throw new ArgumentException($"{nameof(dirFullPath)} may not be null or whitespace", nameof(dirFullPath));
|
||||
ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0);
|
||||
ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0);
|
||||
if (position > total)
|
||||
throw new ArgumentException($"{position} may not be greater than {total}");
|
||||
|
||||
// file max length = 255. dir max len = 247
|
||||
return position.ToString().PadLeft(total.ToString().Length, '0');
|
||||
}
|
||||
|
||||
// sanitize
|
||||
filename = GetAsciiTag(filename);
|
||||
// manage length
|
||||
if (filename.Length > 50)
|
||||
filename = filename.Substring(0, 50) + "[...]";
|
||||
private const int MAX_FILENAME_LENGTH = 255;
|
||||
private const int MAX_DIRECTORY_LENGTH = 247;
|
||||
|
||||
// append id. it is 10 or 14 char in the common cases
|
||||
if (metadataSuffixes != null && metadataSuffixes.Length > 0)
|
||||
filename += " [" + string.Join("][", metadataSuffixes) + "]";
|
||||
/// <summary>
|
||||
/// Ensure valid file name path:
|
||||
/// <br/>- remove invalid chars
|
||||
/// <br/>- ensure uniqueness
|
||||
/// <br/>- enforce max file length
|
||||
/// </summary>
|
||||
public static string GetValidFilename(string path, string illegalCharacterReplacements = "")
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
// this method may also be used for directory names, so no guarantee of extension
|
||||
if (!string.IsNullOrWhiteSpace(extension))
|
||||
extension = '.' + extension.Trim('.');
|
||||
// remove invalid chars
|
||||
path = GetSafePath(path, illegalCharacterReplacements);
|
||||
|
||||
// ensure uniqueness and check lengths
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
dir = dir.Truncate(MAX_DIRECTORY_LENGTH);
|
||||
|
||||
var filename = Path.GetFileNameWithoutExtension(path);
|
||||
var fileStem = Path.Combine(dir, filename);
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
|
||||
var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension;
|
||||
|
||||
// ensure uniqueness
|
||||
var fullfilename = Path.Combine(dirFullPath, filename + extension);
|
||||
var i = 0;
|
||||
while (File.Exists(fullfilename))
|
||||
fullfilename = Path.Combine(dirFullPath, filename + $" ({++i})" + extension);
|
||||
{
|
||||
var increm = $" ({++i})";
|
||||
fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - increm.Length - extension.Length) + increm + extension;
|
||||
}
|
||||
|
||||
return fullfilename;
|
||||
}
|
||||
|
||||
public static string GetAsciiTag(string property)
|
||||
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
|
||||
|
||||
/// <summary>Use with file name, not full path. Valid path charaters which are invalid file name characters will be replaced: ':', '\\', '/'</summary>
|
||||
public static string GetSafeFileName(string str, string illegalCharacterReplacements = "")
|
||||
=> string.Join(illegalCharacterReplacements ?? "", str.Split(Path.GetInvalidFileNameChars()));
|
||||
|
||||
/// <summary>Use with full path, not file name. Valid path charaters which are invalid file name characters will be retained: '\\', '/'</summary>
|
||||
public static string GetSafePath(string path, string illegalCharacterReplacements = "")
|
||||
{
|
||||
if (property == null)
|
||||
return "";
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
// omit characters which are invalid. EXCEPTION: change colon to underscore
|
||||
property = property.Replace(':', '_');
|
||||
var fixedPath = string
|
||||
.Join(illegalCharacterReplacements ?? "", path.Split(Path.GetInvalidPathChars()))
|
||||
.Replace("*", illegalCharacterReplacements)
|
||||
.Replace("?", illegalCharacterReplacements)
|
||||
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
|
||||
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
|
||||
foreach (var ch in Path.GetInvalidFileNameChars())
|
||||
property = property.Replace(ch.ToString(), "");
|
||||
return property;
|
||||
// replace all colons except within the first 2 chars
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < fixedPath.Length; i++)
|
||||
{
|
||||
var c = fixedPath[i];
|
||||
if (i >= 2 && c == ':')
|
||||
builder.Append(illegalCharacterReplacements);
|
||||
else
|
||||
builder.Append(c);
|
||||
}
|
||||
fixedPath = builder.ToString();
|
||||
return fixedPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move file.
|
||||
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
|
||||
/// <br/>- Perform <see cref="SaferMove"/>
|
||||
/// <br/>- Return valid path
|
||||
/// </summary>
|
||||
public static string SaferMoveToValidPath(string source, string destination)
|
||||
{
|
||||
destination = GetValidFilename(destination);
|
||||
SaferMove(source, destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
private static int maxRetryAttempts { get; } = 3;
|
||||
private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100);
|
||||
private static RetryPolicy retryPolicy { get; } =
|
||||
Policy
|
||||
.Handle<Exception>()
|
||||
.WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures);
|
||||
|
||||
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferDelete(string source)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(source))
|
||||
{
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted", new { source });
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file", new { source });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferMove(string source, string destination)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(source))
|
||||
{
|
||||
SaferDelete(destination);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination));
|
||||
File.Move(source, destination);
|
||||
Serilog.Log.Logger.Information("File successfully moved", new { source, destination });
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to move file", new { source, destination });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ namespace FileManager
|
||||
{
|
||||
var obj = GetObject(propertyName);
|
||||
if (obj is null) return default;
|
||||
if (obj is JToken jToken) return jToken.Value<T>();
|
||||
if (obj is JValue jValue) return jValue.Value<T>();
|
||||
if (obj is JObject jObject) return jObject.ToObject<T>();
|
||||
return (T)obj;
|
||||
}
|
||||
|
||||
|
||||
7
Hoopla/Hoopla.csproj
Normal file
7
Hoopla/Hoopla.csproj
Normal file
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
847
Hoopla/HooplaDownloader.newer.ps1
Normal file
847
Hoopla/HooplaDownloader.newer.ps1
Normal file
@@ -0,0 +1,847 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Downloads, decrypts and repackages content from Hoopla
|
||||
|
||||
.DESCRIPTION
|
||||
Uses a HooplaDigital.com account to download DRM-free copies of ebooks, comics,
|
||||
and/or audiobooks available on the platform. Content that is not already borrowed
|
||||
on the account will be borrowed if slots are available. Content that is not borrowed
|
||||
cannot be downloaded.
|
||||
|
||||
* E-Books are downloaded to epub files (most) or cbz (rare, picture books).
|
||||
* Comic books are downloaded to cbz files.
|
||||
* Audiobooks are downloaded to m4a files. (single file, and very little metadata available
|
||||
from Hoopla, such as chapters)
|
||||
|
||||
.PARAMETER Credential
|
||||
Credential to use for logging into Hoopla site.
|
||||
(Cannot be used with Username and Password parameters)
|
||||
|
||||
.PARAMETER Username
|
||||
Username to use for logging into Hoopla site.
|
||||
(Cannot be used with Credential parameter)
|
||||
|
||||
.PARAMETER Password
|
||||
Password to use for logging into Hoopla site.
|
||||
(Cannot be used with Credential parameter)
|
||||
|
||||
.PARAMETER TitleId
|
||||
Specifies one or more title IDs of content to download.
|
||||
|
||||
.PARAMETER OutputFolder
|
||||
Sets the output folder for downloaded content. Defaults to current directory.
|
||||
|
||||
.PARAMETER PatronId
|
||||
Override default patron id for Hoopla. (This is rarely required as most user accounts are only tied
|
||||
to a single patron).
|
||||
|
||||
.PARAMETER EpubZipBin
|
||||
Specifies path to epubzip binary. Else look for one beside script, or in system path.
|
||||
|
||||
.PARAMETER FfmpegBin
|
||||
Specifies path to ffmpeg binary. Else look for one beside script, or in system path.
|
||||
|
||||
.PARAMETER KeepDecryptedData
|
||||
If set, don't delete the intermediary data after decryption, before final output file.
|
||||
For ebooks, this is xml, images, and the manifest. For comics, it is images. For audiobooks,
|
||||
it is mp4 ts files. This is typically only useful for development or troubleshooting.
|
||||
|
||||
.PARAMETER KeepEncryptedData
|
||||
If set, don't delete the encrypted data as downloaded from Hoopla's servers. This is typically
|
||||
only useful for development or troubleshooting.
|
||||
|
||||
.PARAMETER AllBorrowed
|
||||
This parameter is deprecated. If TitleId is not set, it is implied that all borrowed titles will
|
||||
be downloaded.
|
||||
|
||||
.PARAMETER AudioBookForceSingleFile
|
||||
If set, leave audiobook as single file, as if chapter data is not present.
|
||||
|
||||
.EXAMPLE
|
||||
.\Invoke-HooplaDownload.ps1 123456
|
||||
Downloads Hoopla content with title id 123456
|
||||
|
||||
.NOTES
|
||||
Author: kabutops728 - My Anonamouse
|
||||
Version: 2.9
|
||||
#>
|
||||
|
||||
[CmdletBinding(DefaultParameterSetName='CredentialSingleTitle')]
|
||||
param(
|
||||
[int64[]]
|
||||
$TitleId,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='CredentialSingleTitle')]
|
||||
[Management.Automation.PSCredential]
|
||||
$Credential,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
|
||||
[string]
|
||||
$Username,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
|
||||
[string]
|
||||
$Password,
|
||||
|
||||
[ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Container})]
|
||||
[string]$OutputFolder = $PSScriptRoot,
|
||||
|
||||
[int64]$PatronId,
|
||||
|
||||
[string]$EpubZipBin,
|
||||
|
||||
[string]$FfmpegBin,
|
||||
|
||||
[switch]$KeepDecryptedData,
|
||||
|
||||
[switch]$KeepEncryptedData,
|
||||
|
||||
[switch]$AudioBookForceSingleFile,
|
||||
|
||||
# Deprecated
|
||||
[switch]$AllBorrowed
|
||||
)
|
||||
|
||||
$USER_AGENT = 'Hoopla Android/4.27'
|
||||
|
||||
$HEADERS = @{
|
||||
'app' = 'ANDROID'
|
||||
'app-version' = '4.27.1'
|
||||
'device-module' = 'KFKAWI'
|
||||
'device-version' = ''
|
||||
'hoopla-verson' = '4.27.1'
|
||||
'kids-mode' = 'false'
|
||||
'os' = 'ANDROID'
|
||||
'os-version' = '6.0.1'
|
||||
'ws-api' = '2.1'
|
||||
'Host' = 'hoopla-ws.hoopladigital.com'
|
||||
}
|
||||
|
||||
$URL_HOOPLA_WS_BASE = 'https://hoopla-ws.hoopladigital.com'
|
||||
$URL_HOOPLA_LIC_BASE = 'https://hoopla-license2.hoopladigital.com'
|
||||
|
||||
$COMIC_IMAGE_EXTS = @('.jpg','.png','.jpeg','.gif','.bmp','.tif','.tiff')
|
||||
|
||||
enum HooplaKind
|
||||
{
|
||||
EBOOK = 5
|
||||
MUSIC = 6
|
||||
MOVIE = 7
|
||||
AUDIOBOOK = 8
|
||||
TELEVISION = 9
|
||||
COMIC = 10
|
||||
}
|
||||
|
||||
$SUPPORTED_KINDS = @([HooplaKind]::EBOOK, [HooplaKind]::COMIC, [HooplaKind]::AUDIOBOOK)
|
||||
|
||||
Function Connect-Hoopla
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][Management.Automation.PSCredential]$Credential
|
||||
)
|
||||
|
||||
$username = $Credential.UserName
|
||||
$password = $Credential.GetNetworkCredential().Password
|
||||
|
||||
$res = Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/tokens" -Method Post -Headers $HEADERS -UserAgent $USER_AGENT -Body @{username = $username; password = $password}
|
||||
|
||||
if ($res.tokenStatus -ne 'SUCCESS')
|
||||
{
|
||||
throw $res.message
|
||||
}
|
||||
|
||||
$res.token
|
||||
}
|
||||
|
||||
Function Get-HooplaUsers
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaTitleInfo
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$TitleId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/v2/titles/$TitleId" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaBorrowsRemaining
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrows-remaining" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaBorrowedTitles
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/borrowed-titles" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Invoke-HooplaBorrow
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$TitleId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrowed-titles/$TitleId" -Method Post -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Invoke-HooplaZipDownload
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$CircId,
|
||||
[Parameter(Mandatory)][ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Leaf})][string]$OutFile
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
$res = Invoke-WebRequest -Uri "$URL_HOOPLA_WS_BASE/patrons/downloads/$CircId/url" -Method Get -Headers $h -UserAgent $USER_AGENT -UseBasicParsing
|
||||
|
||||
if ($PSVersionTable.PSVersion.Major -ge 6)
|
||||
{
|
||||
Invoke-WebRequest -Uri $res.Headers['Location'][0] -Method Get -UseBasicParsing -OutFile $OutFile
|
||||
}
|
||||
else
|
||||
{
|
||||
Invoke-WebRequest -Uri $res.Headers['Location'] -Method Get -UseBasicParsing -OutFile $OutFile
|
||||
}
|
||||
}
|
||||
|
||||
Function Get-HooplaKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$CircId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_LIC_BASE/downloads/$CircId/key" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-FileKeyKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$CircId,
|
||||
[Parameter(Mandatory)][DateTime]$Due,
|
||||
[Parameter(Mandatory)][int64]$PatronId
|
||||
)
|
||||
|
||||
$combined = '{0:yyyyMMddHHmmss}:{1}:{2}' -f $Due, $PatronId, $CircId
|
||||
|
||||
[Security.Cryptography.HashAlgorithm]::Create('SHA1').ComputeHash([Text.Encoding]::UTF8.GetBytes($combined)) | Select-Object -First 16
|
||||
}
|
||||
|
||||
Function Decrypt-FileKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][byte[]]$FileKeyEnc,
|
||||
[Parameter(Mandatory)][byte[]]$FileKeyKey
|
||||
)
|
||||
|
||||
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
|
||||
$aesManaged.Mode = [Security.Cryptography.CipherMode]::ECB
|
||||
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aesManaged.BlockSize = 128
|
||||
$aesManaged.KeySize = 128
|
||||
$aesManaged.Key = $FileKeyKey
|
||||
|
||||
$decryptor = $aesManaged.CreateDecryptor();
|
||||
|
||||
$unencryptedData = $decryptor.TransformFinalBlock($FileKeyEnc, 0, $FileKeyEnc.Length);
|
||||
$aesManaged.Dispose()
|
||||
|
||||
$unencryptedData
|
||||
}
|
||||
|
||||
Function Decrypt-File
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][byte[]]$FileKey,
|
||||
[Parameter(Mandatory)][string]$MediaKey,
|
||||
[Parameter(Mandatory)][string]$InputFileName,
|
||||
[Parameter(Mandatory)][string]$OutputFileName
|
||||
)
|
||||
|
||||
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
|
||||
$aesManaged.Mode = [Security.Cryptography.CipherMode]::CBC
|
||||
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aesManaged.BlockSize = 128
|
||||
$aesManaged.KeySize = 256
|
||||
$aesManaged.Key = $FileKey
|
||||
$aesManaged.IV = [Text.Encoding]::UTF8.GetBytes($MediaKey) | Select-Object -First 16
|
||||
|
||||
|
||||
$fileStreamReader = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $InputFileName, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
|
||||
$fileStreamWriter = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $OutputFileName, ([IO.FileMode]::Create)
|
||||
|
||||
$FileStreamReader.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null
|
||||
|
||||
$decryptor = $aesManaged.CreateDecryptor()
|
||||
$cryptoStream = New-Object -TypeName 'System.Security.Cryptography.CryptoStream' -ArgumentList $fileStreamWriter, $decryptor, ([Security.Cryptography.CryptoStreamMode]::Write)
|
||||
$fileStreamReader.CopyTo($cryptoStream)
|
||||
|
||||
$cryptoStream.FlushFinalBlock()
|
||||
$cryptoStream.Close()
|
||||
$fileStreamReader.Close()
|
||||
$fileStreamWriter.Close()
|
||||
|
||||
$aesManaged.Dispose()
|
||||
}
|
||||
|
||||
Function Test-Mp4
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('LiteralPath')]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$fileStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $Path, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
|
||||
$fileReader = New-Object -TypeName 'System.IO.BinaryReader' -ArgumentList $fileStream -ErrorAction Stop
|
||||
$head = $fileReader.ReadBytes(8)
|
||||
|
||||
$fileReader.Dispose()
|
||||
$fileStream.Dispose()
|
||||
|
||||
return [Text.Encoding]::ASCII.GetString(($head | Select-Object -Skip 4)) -eq 'ftyp'
|
||||
}
|
||||
|
||||
Function Remove-InvalidFileNameChars
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory,Position=0,
|
||||
ValueFromPipeline=$true,
|
||||
ValueFromPipelineByPropertyName=$true)]
|
||||
[String]$Name
|
||||
)
|
||||
|
||||
$invalidChars = [IO.Path]::GetInvalidFileNameChars() -join ''
|
||||
$re = "[{0}]" -f [RegEx]::Escape($invalidChars)
|
||||
$Name -replace $re, '_'
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToEpub
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder
|
||||
)
|
||||
|
||||
$container = [xml](Get-Content -LiteralPath (Join-Path -Path $InputFolder -ChildPath 'META-INF\container.xml') -Raw)
|
||||
$rootFile = $container.container.rootfiles.rootfile | Select-Object -ExpandProperty Full-Path
|
||||
$contentFile = (Join-Path -Path $InputFolder -ChildPath $rootFile).Trim()
|
||||
$contentRoot = Get-Item -LiteralPath $contentFile | Select-Object -ExpandProperty Directory
|
||||
$content = [xml](Get-Content -LiteralPath $contentFile)
|
||||
|
||||
$fileList = $content.package.manifest.item | Select-Object -ExpandProperty href | ForEach-Object -Process { (Join-Path -Path $contentRoot -ChildPath ([Web.HttpUtility]::UrlDecode($_))).Trim() }
|
||||
$fileList += $contentFile
|
||||
$fileList = $fileList | Sort-Object -Unique
|
||||
|
||||
$title = $content.package.metadata.title | Select-Object -First 1
|
||||
if ($title.GetType() -ne [String])
|
||||
{
|
||||
$title = $content.package.metadata.title | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
|
||||
}
|
||||
|
||||
$author = $content.package.metadata.creator | Select-Object -First 1
|
||||
if ($author.GetType() -ne [String])
|
||||
{
|
||||
$author = $content.package.metadata.creator | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
|
||||
}
|
||||
|
||||
# Usually, content root is a subfolder of the input folder. But sometimes, they are the same. Make sure we declutter the input root if they differ, and always keep the mimetype file.
|
||||
$mimeTypeFile = Join-Path -Path $InputFolder -ChildPath 'mimetype'
|
||||
|
||||
$extra = @(Get-ChildItem -LiteralPath $contentRoot -File -Recurse | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) })
|
||||
$extra += Get-ChildItem -LiteralPath $InputFolder -File | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) }
|
||||
|
||||
$extra = $extra | Sort-Object -Property FullName -Unique
|
||||
$extra | Remove-Item
|
||||
|
||||
$containerXmlFolder = Join-Path -Path $contentRoot.FullName -ChildPath 'META-INF'
|
||||
$containerXmlPath = Join-Path -Path $containerXmlFolder -ChildPath 'container.xml'
|
||||
if (!(Test-Path -LiteralPath $containerXmlPath -PathType Leaf))
|
||||
{
|
||||
New-Item -Path $containerXmlFolder -ItemType Directory -Force | Out-Null
|
||||
$xml = @"
|
||||
<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
|
||||
"@
|
||||
$xml | Out-File -LiteralPath $containerXmlPath -Encoding ascii
|
||||
}
|
||||
|
||||
$finalFile = ('{0} - {1}.epub' -f $title, $author) | Remove-InvalidFileNameChars
|
||||
|
||||
Push-Location
|
||||
Set-Location -LiteralPath $InputFolder
|
||||
|
||||
$finalFileFullPath = (Join-Path -Path $OutFolder -ChildPath $finalFile)
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $EpubZipBin $finalFileFullPath
|
||||
}
|
||||
else
|
||||
{
|
||||
& $EpubZipBin $finalFileFullPath >$null 2>&1
|
||||
}
|
||||
Pop-Location
|
||||
|
||||
Get-Item -LiteralPath $finalFileFullPath
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToCbz
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder,
|
||||
[Parameter(Mandatory)][string]$Name
|
||||
)
|
||||
|
||||
$fileName = $Name | Remove-InvalidFileNameChars
|
||||
$tempOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.zip"
|
||||
$finalOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.cbz"
|
||||
|
||||
Compress-Archive -Path (
|
||||
Get-ChildItem -LiteralPath $InputFolder | Where-Object -FilterScript { $_.Extension -in $COMIC_IMAGE_EXTS } | Select-Object -ExpandProperty FullName
|
||||
) -CompressionLevel Fastest -DestinationPath $tempOutFile
|
||||
|
||||
Rename-Item -LiteralPath $tempOutFile -NewName $finalOutFile
|
||||
|
||||
Get-Item $finalOutFile
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToM4a
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder,
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$Title,
|
||||
[Parameter(Mandatory)][string]$Author,
|
||||
[Parameter(Mandatory)][int]$Year,
|
||||
[string]$Subtitle,
|
||||
[object]$ChapterData
|
||||
|
||||
)
|
||||
|
||||
if ($Author)
|
||||
{
|
||||
$baseFileName = ('{0} - {1}' -f $Name, $Author) | Remove-InvalidFileNameChars
|
||||
}
|
||||
else
|
||||
{
|
||||
$baseFileName = $Name | Remove-InvalidFileNameChars
|
||||
}
|
||||
|
||||
$finalOutFile = Join-Path -Path $OutFolder -ChildPath ('{0}.m4a' -f $baseFileName)
|
||||
$inFile = Get-ChildItem -LiteralPath $InputFolder -Filter '*.m3u8' | Select-Object -First 1 | Select-Object -ExpandProperty FullName
|
||||
|
||||
Push-Location
|
||||
Set-Location $InputFolder
|
||||
$ffArgs = @(
|
||||
'-y',
|
||||
'-i', $infile,
|
||||
'-metadata', ('title="{0}"' -f $Title),
|
||||
'-metadata', ('year="{0}"' -f $Year),
|
||||
'-metadata', ('author="{0}"' -f $Author),
|
||||
'-metadata', 'genre="Audiobook"'
|
||||
)
|
||||
|
||||
if ($Subtitle)
|
||||
{
|
||||
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
|
||||
}
|
||||
|
||||
$ffArgs += @(
|
||||
'-c:a', 'copy',
|
||||
$finalOutFile
|
||||
)
|
||||
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $FfmpegBin @ffArgs
|
||||
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile
|
||||
}
|
||||
else
|
||||
{
|
||||
& $FfmpegBin @ffArgs >$null 2>&1
|
||||
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile >$null 2>&1
|
||||
}
|
||||
|
||||
if ($ChapterData -and (!$AudioBookForceSingleFile))
|
||||
{
|
||||
$outDir = New-Item -Path (Join-Path -Path $OutFolder -ChildPath $baseFileName) -ItemType Directory
|
||||
$chapterCount = $ChapterData | Select-Object -ExpandProperty chapter | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum
|
||||
$ChapterData | ForEach-Object -Process {
|
||||
$ffArgs = @(
|
||||
'-y',
|
||||
'-i', $finalOutFile,
|
||||
'-ss', $_.start,
|
||||
'-t', $_.duration,
|
||||
'-metadata', ('title="{0}"' -f $_.title),
|
||||
'-metadata', ('album="{0}"' -f $Title),
|
||||
'-metadata', ('year="{0}"' -f $Year),
|
||||
'-metadata', ('author="{0}"' -f $Author),
|
||||
'-metadata', 'genre="Audiobook"'
|
||||
'-metadata', ('track={0}/{1}' -f $_.ordinal, $chapterCount)
|
||||
)
|
||||
|
||||
if ($Subtitle)
|
||||
{
|
||||
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
|
||||
}
|
||||
|
||||
$ffArgs += @(
|
||||
'-c', 'copy',
|
||||
(Join-Path -Path $outDir.FullName -ChildPath ('{0} - {1} - {2}.m4a' -f $baseFileName, $_.ordinal, ($_.title | Remove-InvalidFileNameChars)))
|
||||
)
|
||||
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $FfmpegBin @ffArgs
|
||||
}
|
||||
else
|
||||
{
|
||||
& $FfmpegBin @ffArgs >$null 2>&1
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item $finalOutFile
|
||||
$finalOutFile = $outDir
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
|
||||
Get-Item $finalOutFile
|
||||
}
|
||||
|
||||
if (!$Credential)
|
||||
{
|
||||
$ssPassword = ConvertTo-SecureString $Password -AsPlainText -Force
|
||||
$Credential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList $Username, $ssPassword
|
||||
}
|
||||
|
||||
if ((!$AllBorrowed) -and ($null -eq $TitleId))
|
||||
{
|
||||
Write-Warning 'No -TitleId specified. All currently-borrowed titles will be downloaded.'
|
||||
$AllBorrowed = $true
|
||||
}
|
||||
|
||||
$AppExtension = ''
|
||||
if (($PSVersionTable.PSVersion -lt '6.0') -or $IsWindows)
|
||||
{
|
||||
$AppExtension = '.exe'
|
||||
}
|
||||
|
||||
$cmd = ''
|
||||
if ($EpubZipBin)
|
||||
{
|
||||
$cmd = Get-Command -Name $EpubZipBin -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "Epubzip binary specified was not found ($EpubZipBin). Will try to use alternate version if available."
|
||||
}
|
||||
}
|
||||
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "epubzip$AppExtension") -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name "epubzip$AppExtension" -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "Epubzip binary not found ($EpubZipBin). If you are downloading ebooks (rather than comics or audiobooks), you may wish to download the binary from https://github.com/dino-/epub-tools/releases, specify a different path with -EpubZipBin, or specify -KeepDecryptedData so that you can manually pack afterward."
|
||||
}
|
||||
}
|
||||
|
||||
$EpubZipBin = $cmd.Source
|
||||
}
|
||||
|
||||
Write-Verbose ('Using epubzip bin: "{0}"' -f $EpubZipBin)
|
||||
|
||||
$cmd = ''
|
||||
if ($FfmpegBin)
|
||||
{
|
||||
$cmd = Get-Command -Name $FfmpegBin -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "FFMpeg binary specified was not found ($FfmpegBin). Will try to use alternate version if available."
|
||||
}
|
||||
}
|
||||
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "ffmpeg$AppExtension") -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name "ffmpeg$AppExtension" -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "FFmpeg binary not found. If you are downloading audiobooks (rather than ebooks or comics), you may wish to download the binary from https://ffmpeg.zeranoe.com/builds/, specify a different path with -FfmpegBin, or specify -KeepDecryptedData so that you can manually convert afterward."
|
||||
}
|
||||
}
|
||||
|
||||
$FfmpegBin = $cmd.Source
|
||||
}
|
||||
|
||||
Write-Verbose ('Using ffpmeg bin: "{0}"' -f $FfmpegBin)
|
||||
|
||||
if (!(Test-Path -LiteralPath $OutputFolder -PathType Container))
|
||||
{
|
||||
Write-Warning "Output folder doesn't exist. Creating."
|
||||
New-Item -Path $OutputFolder -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$OutputFolder = Get-Item -LiteralPath $OutputFolder | Select-Object -ExpandProperty $_.FullName
|
||||
|
||||
$token = Connect-Hoopla -Credential $Credential
|
||||
Write-Verbose "Logged in. Received token $($token -replace '\-.*', '-****-****-****-************')"
|
||||
|
||||
$users = Get-HooplaUsers $token
|
||||
Write-Verbose "Found $($users.patrons.Count) patrons"
|
||||
|
||||
$userId = $users.id
|
||||
if (!$PatronId)
|
||||
{
|
||||
if ($users.patrons.Count -eq 0)
|
||||
{
|
||||
throw "No patrons found on account. Account may not be correctly set up with library."
|
||||
}
|
||||
elseif ($users.patrons.Count -gt 1)
|
||||
{
|
||||
Write-Warning (
|
||||
"Multiple patrons found on account. Using first one, {0} ({1}). You can specify -PatronId to override" -f $users.patrons[0].id, $users.patrons[0].libraryName
|
||||
)
|
||||
}
|
||||
|
||||
$PatronId = $users.patrons[0].id
|
||||
Write-Verbose "Using PatronId $PatronId"
|
||||
}
|
||||
|
||||
$borrowedRaw = Get-HooplaBorrowedTitles -Token $token -UserId $userId -PatronId $PatronId
|
||||
$borrowed = $borrowedRaw | Where-Object -FilterScript { $_.kind.id -in $SUPPORTED_KINDS }
|
||||
Write-Verbose "Found $($borrowed.Count) ($($borrowedRaw.Count)) titles already borrowed"
|
||||
$toDownload = @()
|
||||
|
||||
if ($AllBorrowed)
|
||||
{
|
||||
$toDownload = $borrowed
|
||||
}
|
||||
else
|
||||
{
|
||||
$toDownload = $borrowed | Where-Object -FilterScript { $_.id -in $TitleId }
|
||||
|
||||
$allBorrowedTitles = $borrowed | Select-Object -ExpandProperty id
|
||||
$toBorrow = $TitleId | Where-Object -FilterScript { $_ -notin $allBorrowedTitles }
|
||||
|
||||
if ($toBorrow)
|
||||
{
|
||||
$borrowsRemainingData = Get-HooplaBorrowsRemaining -UserId $userId -PatronId $PatronId -Token $token
|
||||
Write-Host $borrowsRemainingData.borrowsRemainingMessage
|
||||
|
||||
$borrowsRemaining = $borrowsRemainingData.borrowsRemaining
|
||||
|
||||
$toBorrow | ForEach-Object -Process {
|
||||
Write-Host "Title $_ is not already borrowed or is not a supported kind. Looking up data about it."
|
||||
$titleInfo = Get-HooplaTitleInfo -PatronId $PatronId -Token $token -TitleId $_
|
||||
if ($titleInfo.kind.id -in $SUPPORTED_KINDS)
|
||||
{
|
||||
if ((--$borrowsRemaining) -le 0)
|
||||
{
|
||||
Write-Warning "Title $_ ($($titleInfo.Title)) not borrowed already, but we're out of remaining borrows allowed. Skipping..."
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host "Borrowing title $_ ($($titleInfo.Title))..."
|
||||
$res = Invoke-HooplaBorrow -UserId $userId -PatronId $PatronId -Token $token -TitleId $titleInfo.id
|
||||
Write-Host "Response: $($res.message)"
|
||||
$newToDownload = $res.titles | Where-Object -FilterScript { $_.id -eq $titleInfo.id }
|
||||
if ($newToDownload)
|
||||
{
|
||||
$toDownload += $newToDownload
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Failed to borrow title $_ ($($titleInfo.Title))..."
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Title $_ is not a supported kind ($($titleInfo.kind.name)). Skipping..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tempFolder = [IO.Path]::GetTempPath()
|
||||
|
||||
$now = Get-Date
|
||||
|
||||
$toDownload | ForEach-Object -Process {
|
||||
$info = $_
|
||||
$contentKind = [HooplaKind]$_.kind.id
|
||||
if ($_.contents.mediaType)
|
||||
{
|
||||
$contentKind = [HooplaKind]$_.contents.mediaType
|
||||
}
|
||||
$contents = $info.contents
|
||||
$circId = $contents.circId
|
||||
$mediaKey = $contents.mediaKey
|
||||
$dueUnix = [Math]::Truncate($info.contents.due / 1000)
|
||||
$due = (New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc)).AddSeconds($dueUnix)
|
||||
|
||||
$circFileName = (Join-Path -Path $tempFolder -ChildPath "$($circId).zip")
|
||||
|
||||
Invoke-HooplaZipDownload -PatronId $patronId -Token $token -CircId $circId -OutFile $circFileName
|
||||
$keyData = Get-HooplaKey -PatronId $patronId -Token $token -CircId $circId
|
||||
|
||||
$fileKeyKey = Get-FileKeyKey -CircId $circId -Due $due -PatronId $patronId
|
||||
$fileKey = Decrypt-FileKey -FileKeyEnc ([Convert]::FromBase64String($keyData."$mediaKey")) -FileKeyKey $fileKeyKey
|
||||
|
||||
$encDir = Join-Path -Path $tempFolder -ChildPath ('enc-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
|
||||
New-Item -Path $encDir -ItemType Directory | Out-Null
|
||||
Expand-Archive -LiteralPath $circFileName -DestinationPath $encDir
|
||||
|
||||
Remove-Item -LiteralPath $circFileName
|
||||
|
||||
$decDir = Join-Path -Path $tempFolder -ChildPath ('dec-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
|
||||
New-Item -Path $decDir -ItemType Directory | Out-Null
|
||||
|
||||
$activity = 'Decrypting Content ({0})' -f $_.title
|
||||
Write-Progress -Activity $activity -PercentComplete 0
|
||||
$zipFiles = Get-ChildItem $encDir -Recurse -File
|
||||
$decDone = 0
|
||||
$decTotal = $zipFiles.Count
|
||||
$zipFiles | ForEach-Object -Process {
|
||||
$outFile = $_.FullName.Replace($encDir, $decDir)
|
||||
$outDir = $_.DirectoryName.Replace($encDir, $decDir)
|
||||
|
||||
if (!(Test-Path -LiteralPath $outDir))
|
||||
{
|
||||
New-Item -Path $outDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
if (($contentKind -eq [HooplaKind]::AUDIOBOOK) -and ($_.Extension -eq '.m3u8'))
|
||||
{
|
||||
$lines = Get-Content -LiteralPath $_.FullName | Where-Object -FilterScript {$_ -notmatch '^#EXT-X-KEY'}
|
||||
# Out-File doesn't support utf8 w/o BOM
|
||||
[IO.File]::WriteAllLines($outFile, $lines)
|
||||
return
|
||||
}
|
||||
|
||||
if ($_.Length)
|
||||
{
|
||||
# Hack. Some ebooks contain audio files that download as unencrypted
|
||||
if (($_.Extension -eq '.m4a') -and (Test-Mp4 -LiteralPath $_.FullName))
|
||||
{
|
||||
Write-Verbose -Message ('Coping unencrypted {0}' -f $_.FullName)
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $outFile
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Verbose -Message ('Decrypting {0}' -f $_.FullName)
|
||||
Decrypt-File -FileKey $fileKey -MediaKey $mediaKey -InputFileName $_.FullName -OutputFileName $outFile
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Verbose -Message ('Writing empty file {0}' -f $_.FullName)
|
||||
'' | Out-File -LiteralPath $outFile
|
||||
}
|
||||
|
||||
Write-Progress -Activity $activity -PercentComplete ((++$decDone) / $decTotal * 100)
|
||||
}
|
||||
Write-Progress -Activity $activity -Completed
|
||||
|
||||
switch ($contentKind)
|
||||
{
|
||||
([HooplaKind]::EBOOK) {
|
||||
Convert-HooplaDecryptedToEpub -InputFolder $decDir -OutFolder $OutputFolder
|
||||
}
|
||||
|
||||
([HooplaKind]::COMIC) {
|
||||
$title = $contents.title
|
||||
$subtitle = $contents.subtitle
|
||||
$name = $title
|
||||
if ($subtitle) {
|
||||
$name += ", $subtitle"
|
||||
}
|
||||
Convert-HooplaDecryptedToCbz -InputFolder $decDir -OutFolder $OutputFolder -Name $name
|
||||
}
|
||||
|
||||
([HooplaKind]::AUDIOBOOK) {
|
||||
Convert-HooplaDecryptedToM4a -InputFolder $decDir -OutFolder $OutputFolder -Name $info.title -Title $info.title `
|
||||
-Year $info.year -Author $info.artist.name -Subtitle $contents.subtitle -ChapterData $contents.chapters
|
||||
}
|
||||
}
|
||||
|
||||
if (!$KeepDecryptedData)
|
||||
{
|
||||
Remove-Item -LiteralPath $decDir -Recurse
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host ('Decrypted data for {0} ({1}) stored in {2}' -f $_.id, $_.title, $decDir)
|
||||
}
|
||||
|
||||
if (!$KeepEncryptedData)
|
||||
{
|
||||
Remove-Item -LiteralPath $encDir -Recurse
|
||||
}
|
||||
}
|
||||
|
||||
13
Hoopla/_README.txt
Normal file
13
Hoopla/_README.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
From a Libation user about possibility of integrating Hoopla:
|
||||
|
||||
I have a powershell script. I didn't write it, and neither did the person that gave it to me. It works most of the time (98%). Some titles, it doesn't play well with, but does allow to keep the downloaded data, whether the decrypt was successful, or not, and then you can mess with the data, from there.
|
||||
|
||||
If you run the script with no parameters, then all the books in your library will download, and decrypt into the same directory as the script, into a folder named Completed.
|
||||
|
||||
If you run the script with the command:
|
||||
'.\HooplaDownloader.newer.ps1 -KeepDecryptedData'
|
||||
then it will, and will notify you, when complete, where it was stored.
|
||||
|
||||
There is a parameter to download a specific titleID#, whether it's in your library, or not, but I've not played with it that far, as the method to accomplish it still reserves it to your library, and then proceeds as normal. I can tell you, if it's a "trial and error concern", the title will not be removed from your library, after you run the script, whether it succeeds or not. So, if it fails, you can retry, or try the -KeepDecryptedData option. I received no documentation for it, which is why I'm telling you as much as I know about using it.
|
||||
|
||||
[ see HooplaDownloader.newer.ps1 ]
|
||||
9
Hoopla/temp.cs
Normal file
9
Hoopla/temp.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace Hoopla
|
||||
{
|
||||
public class temp
|
||||
{
|
||||
// placeholder
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApiDTOs;
|
||||
using Dinah.Core;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace InternalUtilities
|
||||
{
|
||||
public static class AudibleApiActions
|
||||
{
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public static Task<Api> GetApiAsync(string username, string localeName, ILoginCallback loginCallback = null)
|
||||
{
|
||||
Serilog.Log.Logger.Information("GetApiAsync. {@DebugInfo}", new
|
||||
{
|
||||
Username = username.ToMask(),
|
||||
LocaleName = localeName,
|
||||
});
|
||||
return EzApiCreator.GetApiAsync(
|
||||
Localization.Get(localeName),
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName),
|
||||
loginCallback);
|
||||
}
|
||||
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public static Task<Api> GetApiAsync(ILoginCallback loginCallback, Account account)
|
||||
{
|
||||
Serilog.Log.Logger.Information("GetApiAsync. {@DebugInfo}", new
|
||||
{
|
||||
Account = account?.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = account?.Locale?.Name
|
||||
});
|
||||
return EzApiCreator.GetApiAsync(
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
loginCallback);
|
||||
}
|
||||
|
||||
private static AsyncRetryPolicy policy { get; }
|
||||
= Policy.Handle<Exception>()
|
||||
// 2 retries == 3 total
|
||||
.RetryAsync(2);
|
||||
|
||||
public static Task<List<Item>> GetLibraryValidatedAsync(Api api)
|
||||
{
|
||||
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or tokens are refreshed
|
||||
// worse, this 1st dummy call doesn't seem to help:
|
||||
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
|
||||
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
|
||||
return policy.ExecuteAsync(() => getItemsAsync(api));
|
||||
}
|
||||
|
||||
private static async Task<List<Item>> getItemsAsync(Api api)
|
||||
{
|
||||
var items = await api.GetAllLibraryItemsAsync();
|
||||
|
||||
// remove episode parents
|
||||
items.RemoveAll(i => i.IsEpisodes);
|
||||
|
||||
#region // episode handling. doesn't quite work
|
||||
// // add individual/children episodes
|
||||
// var childIds = items
|
||||
// .Where(i => i.Episodes)
|
||||
// .SelectMany(ep => ep.Relationships)
|
||||
// .Where(r => r.RelationshipToProduct == AudibleApiDTOs.RelationshipToProduct.Child && r.RelationshipType == AudibleApiDTOs.RelationshipType.Episode)
|
||||
// .Select(c => c.Asin)
|
||||
// .ToList();
|
||||
// foreach (var childId in childIds)
|
||||
// {
|
||||
// var bookResult = await api.GetLibraryBookAsync(childId, AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
// var bookItem = AudibleApiDTOs.LibraryDtoV10.FromJson(bookResult.ToString()).Item;
|
||||
// items.Add(bookItem);
|
||||
// }
|
||||
#endregion
|
||||
|
||||
var validators = new List<IValidator>();
|
||||
validators.AddRange(getValidators());
|
||||
foreach (var v in validators)
|
||||
{
|
||||
var exceptions = v.Validate(items);
|
||||
if (exceptions != null && exceptions.Any())
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<IValidator> getValidators()
|
||||
{
|
||||
var type = typeof(IValidator);
|
||||
var types = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(s => s.GetTypes())
|
||||
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
|
||||
|
||||
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
201
Libation.sln
201
Libation.sln
@@ -5,6 +5,7 @@ VisualStudioVersion = 16.0.28803.156
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
|
||||
__TODO.txt = __TODO.txt
|
||||
_DB_NOTES.txt = _DB_NOTES.txt
|
||||
REFERENCE.txt = REFERENCE.txt
|
||||
@@ -22,71 +23,44 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 Utilities (domain ignoran
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AaxDecrypter", "AaxDecrypter\AaxDecrypter.csproj", "{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager", "FileManager\FileManager.csproj", "{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager", "LibationFileManager\LibationFileManager.csproj", "{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataLayer", "DataLayer\DataLayer.csproj", "{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator", "FileLiberator\FileLiberator.csproj", "{393B5B27-D15C-4F77-9457-FA14BA8F3C73}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities", "InternalUtilities\InternalUtilities.csproj", "{06882742-27A6-4347-97D9-56162CEC9C11}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleUtilities", "AudibleUtilities\AudibleUtilities.csproj", "{06882742-27A6-4347-97D9-56162CEC9C11}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 Domain Internal Utilities (db ignorant)", "3 Domain Internal Utilities (db ignorant)", "{F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "LibationSearchEngine\LibationSearchEngine.csproj", "{2E1F5DB4-40CC-4804-A893-5DCE0193E598}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Tests", "0 Tests", "{38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Demos and Examples", "0 Demos and Examples", "{F61184E7-2426-4A13-ACEF-5689928E2CE2}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Demos", "..\Dinah.Core\_Demos\Dinah.Core.Demos\Dinah.Core.Demos.csproj", "{9F1AA3DE-962F-469B-82B2-46F93491389B}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Tests", "..\Dinah.Core\_Tests\Dinah.Core.Tests\Dinah.Core.Tests.csproj", "{E874D000-AD3A-4629-AC65-7219C2C7C1F0}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestCommon", "..\Dinah.Core\_Tests\TestCommon\TestCommon.csproj", "{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitAllRepos", "..\GitAllRepos\GitAllRepos\GitAllRepos.csproj", "{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi", "..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj", "{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi.Tests", "..\audible api\AudibleApi\_Tests\AudibleApi.Tests\AudibleApi.Tests.csproj", "{111420E2-D4F0-4068-B46A-C4B6DCC823DC}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForms", "LibationWinForms\LibationWinForms.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormsDesigner", "WinFormsDesigner\WinFormsDesigner.csproj", "{0807616A-A77A-4B08-A65A-1582B09E114B}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core", "..\Dinah.Core\Dinah.Core\Dinah.Core.csproj", "{9E951521-2587-4FC6-AD26-FAA9179FB6C4}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.EntityFrameworkCore", "..\Dinah.Core\Dinah.EntityFrameworkCore\Dinah.EntityFrameworkCore.csproj", "{1255D9BA-CE6E-42E4-A253-6376540B9661}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LuceneNet303r2", "..\LuceneNet303r2\LuceneNet303r2\LuceneNet303r2.csproj", "{35803735-B669-4090-9681-CC7F7FABDC71}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LuceneNet303r2.Tests", "..\LuceneNet303r2\LuceneNet303r2.Tests\LuceneNet303r2.Tests.csproj", "{5A7681A5-60D9-480B-9AC7-63E0812A2548}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{428163C3-D558-4914-B570-A92069521877} = {428163C3-D558-4914-B570-A92069521877}
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoImporterService\DtoImporterService.csproj", "{401865F5-1942-4713-B230-04544C0A97B0}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiDTOs", "..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj", "{C03C5D65-3B7F-453B-972F-23950B7E0604}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiDTOs.Tests", "..\audible api\AudibleApi\_Tests\AudibleApiDTOs.Tests\AudibleApiDTOs.Tests.csproj", "{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiClientExample", "..\audible api\AudibleApi\_Demos\AudibleApiClientExample\AudibleApiClientExample.csproj", "{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationServices", "ApplicationServices\ApplicationServices.csproj", "{B95650EA-25F0-449E-BA5D-99126BC5D730}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.WindowsDesktop", "..\Dinah.Core\Dinah.Core.WindowsDesktop\Dinah.Core.WindowsDesktop.csproj", "{059CE32C-9AD6-45E9-A166-790DFFB0B730}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsDesktopUtilities", "WindowsDesktopUtilities\WindowsDesktopUtilities.csproj", "{E7EFD64D-6630-4426-B09C-B6862A92E3FD}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationLauncher", "LibationLauncher\LibationLauncher.csproj", "{F3B04A3A-20C8-4582-A54A-715AF6A5D859}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Libation Tests", "0 Libation Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities.Tests", "_Tests\InternalUtilities.Tests\InternalUtilities.Tests.csproj", "{8447C956-B03E-4F59-9DD4-877793B849D9}"
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.EntityFrameworkCore.Tests", "..\Dinah.Core\_Tests\Dinah.EntityFrameworkCore.Tests\Dinah.EntityFrameworkCore.Tests.csproj", "{6F5131A0-09AE-4707-B82B-5E53CB74688E}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AAXClean", "..\AAXClean\AAXClean.csproj", "{94BEB7CC-511D-45AB-9F09-09BE858EE486}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager", "FileManager\FileManager.csproj", "{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleUtilities.Tests", "_Tests\AudibleUtilities.Tests\AudibleUtilities.Tests.csproj", "{788294BE-0D8E-40D4-9CEE-67896FBB52CE}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator.Tests", "_Tests\FileLiberator.Tests\FileLiberator.Tests.csproj", "{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests\FileManager.Tests\FileManager.Tests.csproj", "{F2E04270-4551-41C4-99FF-E7125BED708C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -118,137 +92,72 @@ Global
|
||||
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0807616A-A77A-4B08-A65A-1582B09E114B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0807616A-A77A-4B08-A65A-1582B09E114B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0807616A-A77A-4B08-A65A-1582B09E114B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0807616A-A77A-4B08-A65A-1582B09E114B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{35803735-B669-4090-9681-CC7F7FABDC71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{35803735-B669-4090-9681-CC7F7FABDC71}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{35803735-B669-4090-9681-CC7F7FABDC71}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{35803735-B669-4090-9681-CC7F7FABDC71}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E7EFD64D-6630-4426-B09C-B6862A92E3FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E7EFD64D-6630-4426-B09C-B6862A92E3FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E7EFD64D-6630-4426-B09C-B6862A92E3FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E7EFD64D-6630-4426-B09C-B6862A92E3FD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F2E04270-4551-41C4-99FF-E7125BED708C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F2E04270-4551-41C4-99FF-E7125BED708C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F2E04270-4551-41C4-99FF-E7125BED708C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F2E04270-4551-41C4-99FF-E7125BED708C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
||||
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
||||
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
|
||||
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
|
||||
{393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
||||
{06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
|
||||
{2E1F5DB4-40CC-4804-A893-5DCE0193E598} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
||||
{9F1AA3DE-962F-469B-82B2-46F93491389B} = {F61184E7-2426-4A13-ACEF-5689928E2CE2}
|
||||
{E874D000-AD3A-4629-AC65-7219C2C7C1F0} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
|
||||
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
|
||||
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
|
||||
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
||||
{111420E2-D4F0-4068-B46A-C4B6DCC823DC} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
|
||||
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{0807616A-A77A-4B08-A65A-1582B09E114B} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{9E951521-2587-4FC6-AD26-FAA9179FB6C4} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
|
||||
{1255D9BA-CE6E-42E4-A253-6376540B9661} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
|
||||
{35803735-B669-4090-9681-CC7F7FABDC71} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
||||
{5A7681A5-60D9-480B-9AC7-63E0812A2548} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
|
||||
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
||||
{C03C5D65-3B7F-453B-972F-23950B7E0604} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
||||
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
|
||||
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6} = {F61184E7-2426-4A13-ACEF-5689928E2CE2}
|
||||
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
||||
{059CE32C-9AD6-45E9-A166-790DFFB0B730} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
|
||||
{E7EFD64D-6630-4426-B09C-B6862A92E3FD} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
|
||||
{F3B04A3A-20C8-4582-A54A-715AF6A5D859} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{8447C956-B03E-4F59-9DD4-877793B849D9} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
{6F5131A0-09AE-4707-B82B-5E53CB74688E} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
|
||||
{94BEB7CC-511D-45AB-9F09-09BE858EE486} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
||||
{428163C3-D558-4914-B570-A92069521877} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
|
||||
{788294BE-0D8E-40D4-9CEE-67896FBB52CE} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
{F2E04270-4551-41C4-99FF-E7125BED708C} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
|
||||
|
||||
41
LibationCli/LibationCli.csproj
Normal file
41
LibationCli/LibationCli.csproj
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
When LibationWinForms and LibationCli output to the same dir, LibationCli must build before LibationWinForms
|
||||
|
||||
VS > rt-clik solution > Project Build Order...
|
||||
Dependencies [tab]
|
||||
Projects: LibationWinForms
|
||||
manually check LibationCli
|
||||
-->
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\LibationWinForms\bin\Debug</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\LibationWinForms\bin\Release</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
14
LibationCli/Options/ConvertOptions.cs
Normal file
14
LibationCli/Options/ConvertOptions.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommandLine;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
[Verb("convert", HelpText = "Convert mp4 to mp3.")]
|
||||
public class ConvertOptions : ProcessableOptionsBase
|
||||
{
|
||||
protected override Task ProcessAsync() => RunAsync(CreateProcessable<FileLiberator.ConvertToMp3>());
|
||||
}
|
||||
}
|
||||
55
LibationCli/Options/ExportOptions.cs
Normal file
55
LibationCli/Options/ExportOptions.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using CommandLine;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
[Verb("export", HelpText = "Must include path and flag for export file type: --xlsx , --csv , --json]")]
|
||||
public class ExportOptions : OptionsBase
|
||||
{
|
||||
[Option(shortName: 'p', longName: "path", Required = true, HelpText = "Path to save file to.")]
|
||||
public string FilePath { get; set; }
|
||||
|
||||
#region explanation of mutually exclusive options
|
||||
/*
|
||||
giving these SetName values makes them mutually exclusive. they are in different sets. eg:
|
||||
class Options
|
||||
{
|
||||
[Option("username", SetName = "auth")]
|
||||
public string Username { get; set; }
|
||||
[Option("password", SetName = "auth")]
|
||||
public string Password { get; set; }
|
||||
|
||||
[Option("guestaccess", SetName = "guest")]
|
||||
public bool GuestAccess { get; set; }
|
||||
}
|
||||
*/
|
||||
#endregion
|
||||
[Option(shortName: 'x', longName: "xlsx", SetName = "xlsx", Required = true)]
|
||||
public bool xlsx { get; set; }
|
||||
|
||||
[Option(shortName: 'c', longName: "csv", SetName = "csv", Required = true)]
|
||||
public bool csv { get; set; }
|
||||
|
||||
[Option(shortName: 'j', longName: "json", SetName = "json", Required = true)]
|
||||
public bool json { get; set; }
|
||||
|
||||
protected override Task ProcessAsync()
|
||||
{
|
||||
if (xlsx)
|
||||
LibraryExporter.ToXlsx(FilePath);
|
||||
if (csv)
|
||||
LibraryExporter.ToCsv(FilePath);
|
||||
if (json)
|
||||
LibraryExporter.ToJson(FilePath);
|
||||
|
||||
Console.WriteLine($"Library exported to: {FilePath}");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
LibationCli/Options/LiberateOptions.cs
Normal file
37
LibationCli/Options/LiberateOptions.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommandLine;
|
||||
using DataLayer;
|
||||
using FileLiberator;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
[Verb("liberate", HelpText = "Liberate: book and pdf backups. Default: download and decrypt all un-liberated titles and download pdfs. "
|
||||
+ "Optional: use 'pdf' flag to only download pdfs.")]
|
||||
public class LiberateOptions : ProcessableOptionsBase
|
||||
{
|
||||
[Option(shortName: 'p', longName: "pdf", Required = false, Default = false, HelpText = "Flag to only download pdfs")]
|
||||
public bool PdfOnly { get; set; }
|
||||
|
||||
protected override Task ProcessAsync()
|
||||
=> PdfOnly
|
||||
? RunAsync(CreateProcessable<DownloadPdf>())
|
||||
: RunAsync(CreateBackupBook());
|
||||
|
||||
private static Processable CreateBackupBook()
|
||||
{
|
||||
var downloadPdf = CreateProcessable<DownloadPdf>();
|
||||
|
||||
//Chain pdf download on DownloadDecryptBook.Completed
|
||||
async void onDownloadDecryptBookCompleted(object sender, LibraryBook e)
|
||||
{
|
||||
await downloadPdf.TryProcessAsync(e);
|
||||
}
|
||||
|
||||
var downloadDecryptBook = CreateProcessable<DownloadDecryptBook>(onDownloadDecryptBookCompleted);
|
||||
return downloadDecryptBook;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
LibationCli/Options/ScanOptions.cs
Normal file
77
LibationCli/Options/ScanOptions.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using CommandLine;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
[Verb("scan", HelpText = "Scan library. Default: scan all accounts. Optional: use 'account' flag to specify a single account.")]
|
||||
public class ScanOptions : OptionsBase
|
||||
{
|
||||
[Value(0, MetaName = "Accounts", HelpText = "Optional: nicknames of accounts to scan.", Required = false)]
|
||||
public IEnumerable<string> AccountNicknames { get; set; }
|
||||
|
||||
protected override async Task ProcessAsync()
|
||||
{
|
||||
var accounts = getAccounts();
|
||||
if (!accounts.Any())
|
||||
{
|
||||
Console.WriteLine("No accounts. Exiting.");
|
||||
Environment.ExitCode = (int)ExitCode.RunTimeError;
|
||||
return;
|
||||
}
|
||||
|
||||
var _accounts = accounts.ToArray();
|
||||
|
||||
var intro
|
||||
= (_accounts.Length == 1)
|
||||
? "Scanning Audible library. This may take a few minutes."
|
||||
: $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account.";
|
||||
Console.WriteLine(intro);
|
||||
|
||||
var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync((a) => ApiExtended.CreateAsync(a), _accounts);
|
||||
|
||||
Console.WriteLine("Scan complete.");
|
||||
Console.WriteLine($"Total processed: {TotalBooksProcessed}\r\nNew: {NewBooksAdded}");
|
||||
}
|
||||
|
||||
private Account[] getAccounts()
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var accounts = persister.AccountsSettings.GetAll().ToArray();
|
||||
|
||||
if (!AccountNicknames.Any())
|
||||
return accounts;
|
||||
|
||||
var found = accounts.Where(acct => AccountNicknames.Contains(acct.AccountName)).ToArray();
|
||||
var notFound = AccountNicknames.Except(found.Select(f => f.AccountName)).ToArray();
|
||||
|
||||
// no accounts found. do not continue
|
||||
if (!found.Any())
|
||||
{
|
||||
Console.WriteLine("Accounts not found:");
|
||||
foreach (var nf in notFound)
|
||||
Console.WriteLine($"- {nf}");
|
||||
return found;
|
||||
}
|
||||
|
||||
// some accounts not found. continue after message
|
||||
if (notFound.Any())
|
||||
{
|
||||
Console.WriteLine("Accounts found:");
|
||||
foreach (var f in found)
|
||||
Console.WriteLine($"- {f}");
|
||||
Console.WriteLine("Accounts not found:");
|
||||
foreach (var nf in notFound)
|
||||
Console.WriteLine($"- {nf}");
|
||||
}
|
||||
|
||||
// else: all accounts area found. silently continue
|
||||
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
LibationCli/Options/_OptionsBase.cs
Normal file
31
LibationCli/Options/_OptionsBase.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommandLine;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
public abstract class OptionsBase
|
||||
{
|
||||
public async Task Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Environment.ExitCode = (int)ExitCode.RunTimeError;
|
||||
|
||||
Console.WriteLine("ERROR");
|
||||
Console.WriteLine("=====");
|
||||
Console.WriteLine(ex.Message);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(ex.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task ProcessAsync();
|
||||
}
|
||||
}
|
||||
58
LibationCli/Options/_ProcessableOptionsBase.cs
Normal file
58
LibationCli/Options/_ProcessableOptionsBase.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using CommandLine;
|
||||
using DataLayer;
|
||||
using FileLiberator;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
// streamlined, non-Forms copy of ProcessorAutomationController
|
||||
public abstract class ProcessableOptionsBase : OptionsBase
|
||||
{
|
||||
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook> completedAction = null)
|
||||
where TProcessable : Processable, new()
|
||||
{
|
||||
var strProc = new TProcessable();
|
||||
|
||||
strProc.Begin += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}");
|
||||
strProc.Completed += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Completed: {e}");
|
||||
|
||||
strProc.Completed += completedAction;
|
||||
|
||||
return strProc;
|
||||
}
|
||||
|
||||
protected static async Task RunAsync(Processable Processable)
|
||||
{
|
||||
foreach (var libraryBook in Processable.GetValidLibraryBooks(DbContexts.GetLibrary_Flat_NoTracking()))
|
||||
await ProcessOneAsync(Processable, libraryBook, false);
|
||||
|
||||
var done = "Done. All books have been processed";
|
||||
Console.WriteLine(done);
|
||||
Serilog.Log.Logger.Information(done);
|
||||
}
|
||||
|
||||
private static async Task ProcessOneAsync(Processable Processable, LibraryBook libraryBook, bool validate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var statusHandler = await Processable.ProcessSingleAsync(libraryBook, validate);
|
||||
|
||||
if (statusHandler.IsSuccess)
|
||||
return;
|
||||
|
||||
foreach (var errorMessage in statusHandler.Errors)
|
||||
Serilog.Log.Logger.Error(errorMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var msg = "Error processing book. Skipping. This book will be tried again on next attempt. For options of skipping or marking as error, retry with main Libation app.";
|
||||
Console.WriteLine(msg + ". See log for more details.");
|
||||
Serilog.Log.Logger.Error(ex, $"{msg} {{@DebugInfo}}", new { Book = libraryBook.LogFriendly() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
LibationCli/Program.cs
Normal file
84
LibationCli/Program.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CommandLine;
|
||||
using CommandLine.Text;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
public enum ExitCode
|
||||
{
|
||||
ProcessCompletedSuccessfully = 0,
|
||||
NonRunNonError = 1,
|
||||
ParseError = 2,
|
||||
RunTimeError = 3
|
||||
}
|
||||
class Program
|
||||
{
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
// //
|
||||
//***********************************************//
|
||||
Setup.Initialize();
|
||||
Setup.SubscribeToDatabaseEvents();
|
||||
|
||||
var types = Setup.LoadVerbs();
|
||||
|
||||
#if DEBUG
|
||||
string input = null;
|
||||
|
||||
//input = " export --help";
|
||||
//input = " scan rmcrackan";
|
||||
//input = " liberate ";
|
||||
|
||||
|
||||
// note: this hack will fail for quoted file paths with spaces because it will break on those spaces
|
||||
if (!string.IsNullOrWhiteSpace(input))
|
||||
args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var setBreakPointHere = args;
|
||||
#endif
|
||||
|
||||
var result = Parser.Default.ParseArguments(args, types);
|
||||
|
||||
// if successfully parsed
|
||||
// async: run parsed options
|
||||
await result.WithParsedAsync<OptionsBase>(opt => opt.Run());
|
||||
|
||||
// if not successfully parsed
|
||||
// sync: handle parse errors
|
||||
result.WithNotParsed(errors => HandleErrors(result, errors));
|
||||
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
|
||||
private static void HandleErrors(ParserResult<object> result, IEnumerable<Error> errors)
|
||||
{
|
||||
var errorsList = errors.ToList();
|
||||
if (errorsList.Any(e => e.Tag.In(ErrorType.HelpRequestedError, ErrorType.VersionRequestedError, ErrorType.HelpVerbRequestedError)))
|
||||
{
|
||||
Environment.ExitCode = (int)ExitCode.NonRunNonError;
|
||||
return;
|
||||
}
|
||||
|
||||
Environment.ExitCode = (int)ExitCode.ParseError;
|
||||
|
||||
if (errorsList.Any(e => e.Tag.In(ErrorType.NoVerbSelectedError)))
|
||||
{
|
||||
Console.WriteLine("No verb selected");
|
||||
return;
|
||||
}
|
||||
|
||||
var helpText = HelpText.AutoBuild(result,
|
||||
h => HelpText.DefaultParsingErrorsHandler(result, h),
|
||||
e => e);
|
||||
Console.WriteLine(helpText);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
LibationCli/Setup.cs
Normal file
63
LibationCli/Setup.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using AppScaffolding;
|
||||
using CommandLine;
|
||||
using CommandLine.Text;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
public static class Setup
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
// //
|
||||
//***********************************************//
|
||||
var config = LibationScaffolding.RunPreConfigMigrations();
|
||||
|
||||
|
||||
LibationScaffolding.RunPostConfigMigrations(config);
|
||||
LibationScaffolding.RunPostMigrationScaffolding(config);
|
||||
|
||||
#if !DEBUG
|
||||
checkForUpdate();
|
||||
#endif
|
||||
}
|
||||
|
||||
private static void checkForUpdate()
|
||||
{
|
||||
var (hasUpgrade, zipUrl, htmlUrl, zipName) = LibationScaffolding.GetLatestRelease();
|
||||
if (!hasUpgrade)
|
||||
return;
|
||||
|
||||
var origColor = Console.ForegroundColor;
|
||||
try
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"UPDATE AVAILABLE @ {zipUrl}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.ForegroundColor = origColor;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SubscribeToDatabaseEvents()
|
||||
{
|
||||
DataLayer.UserDefinedItem.ItemChanged += (sender, e) => ApplicationServices.LibraryCommands.UpdateUserDefinedItem(((DataLayer.UserDefinedItem)sender).Book);
|
||||
}
|
||||
|
||||
public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly()
|
||||
.GetTypes()
|
||||
.Where(t => t.GetCustomAttribute<VerbAttribute>() is not null)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
106
LibationFileManager/AudibleFileStorage.cs
Normal file
106
LibationFileManager/AudibleFileStorage.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using FileManager;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public abstract class AudibleFileStorage
|
||||
{
|
||||
protected abstract string GetFilePathCustom(string productId);
|
||||
|
||||
#region static
|
||||
public static string DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
|
||||
public static string DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
|
||||
|
||||
private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage();
|
||||
public static bool AaxcExists(string productId) => AAXC.Exists(productId);
|
||||
|
||||
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
|
||||
|
||||
public static string BooksDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
|
||||
Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books");
|
||||
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region instance
|
||||
private FileType FileType { get; }
|
||||
private string regexTemplate { get; }
|
||||
|
||||
protected AudibleFileStorage(FileType fileType)
|
||||
{
|
||||
FileType = fileType;
|
||||
|
||||
var extAggr = FileTypes.GetExtensions(FileType).Aggregate((a, b) => $"{a}|{b}");
|
||||
regexTemplate = $@"{{0}}.*?\.({extAggr})$";
|
||||
}
|
||||
|
||||
protected string GetFilePath(string productId)
|
||||
{
|
||||
// primary lookup
|
||||
var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
|
||||
if (cachedFile != null)
|
||||
return cachedFile;
|
||||
|
||||
// secondary lookup attempt
|
||||
var firstOrNull = GetFilePathCustom(productId);
|
||||
if (firstOrNull is not null)
|
||||
FilePathCache.Insert(productId, firstOrNull);
|
||||
|
||||
return firstOrNull;
|
||||
}
|
||||
|
||||
protected Regex GetBookSearchRegex(string productId)
|
||||
{
|
||||
var pattern = string.Format(regexTemplate, productId);
|
||||
return new Regex(pattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
internal class AaxcFileStorage : AudibleFileStorage
|
||||
{
|
||||
internal AaxcFileStorage() : base(FileType.AAXC) { }
|
||||
|
||||
protected override string GetFilePathCustom(string productId)
|
||||
{
|
||||
var regex = GetBookSearchRegex(productId);
|
||||
return Directory
|
||||
.EnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(s => regex.IsMatch(s));
|
||||
}
|
||||
|
||||
public bool Exists(string productId) => GetFilePath(productId) != null;
|
||||
}
|
||||
|
||||
public class AudioFileStorage : AudibleFileStorage
|
||||
{
|
||||
internal AudioFileStorage() : base(FileType.Audio)
|
||||
=> BookDirectoryFiles ??= new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
|
||||
|
||||
private static BackgroundFileSystem BookDirectoryFiles { get; set; }
|
||||
private static object bookDirectoryFilesLocker { get; } = new();
|
||||
protected override string GetFilePathCustom(string productId)
|
||||
{
|
||||
// If user changed the BooksDirectory: reinitialize
|
||||
lock (bookDirectoryFilesLocker)
|
||||
if (BooksDirectory != BookDirectoryFiles.RootDirectory)
|
||||
BookDirectoryFiles = new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
|
||||
|
||||
var regex = GetBookSearchRegex(productId);
|
||||
return BookDirectoryFiles.FindFile(regex);
|
||||
}
|
||||
|
||||
public void Refresh() => BookDirectoryFiles.RefreshFiles();
|
||||
|
||||
public string GetPath(string productId) => GetFilePath(productId);
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Logging;
|
||||
using FileManager;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace FileManager
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public class Configuration
|
||||
{
|
||||
@@ -46,13 +47,17 @@ namespace FileManager
|
||||
|
||||
private PersistentDictionary persistentDictionary;
|
||||
|
||||
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
|
||||
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
|
||||
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
|
||||
|
||||
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
|
||||
/// <returns>Value was changed</returns>
|
||||
public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
|
||||
=> persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
|
||||
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
|
||||
{
|
||||
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
|
||||
if (settingWasChanged)
|
||||
configuration?.Reload();
|
||||
}
|
||||
|
||||
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
|
||||
|
||||
@@ -67,39 +72,7 @@ namespace FileManager
|
||||
return attribute?.Description;
|
||||
}
|
||||
|
||||
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
|
||||
|
||||
#region MainForm: X, Y, Width, Height, MainFormIsMaximized
|
||||
public int MainFormX
|
||||
{
|
||||
get => persistentDictionary.GetNonString<int>(nameof(MainFormX));
|
||||
set => persistentDictionary.SetNonString(nameof(MainFormX), value);
|
||||
}
|
||||
|
||||
public int MainFormY
|
||||
{
|
||||
get => persistentDictionary.GetNonString<int>(nameof(MainFormY));
|
||||
set => persistentDictionary.SetNonString(nameof(MainFormY), value);
|
||||
}
|
||||
|
||||
public int MainFormWidth
|
||||
{
|
||||
get => persistentDictionary.GetNonString<int>(nameof(MainFormWidth));
|
||||
set => persistentDictionary.SetNonString(nameof(MainFormWidth), value);
|
||||
}
|
||||
|
||||
public int MainFormHeight
|
||||
{
|
||||
get => persistentDictionary.GetNonString<int>(nameof(MainFormHeight));
|
||||
set => persistentDictionary.SetNonString(nameof(MainFormHeight), value);
|
||||
}
|
||||
|
||||
public bool MainFormIsMaximized
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(MainFormIsMaximized));
|
||||
set => persistentDictionary.SetNonString(nameof(MainFormIsMaximized), value);
|
||||
}
|
||||
#endregion
|
||||
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
|
||||
|
||||
[Description("Location for book storage. Includes destination of newly liberated books")]
|
||||
public string Books
|
||||
@@ -116,7 +89,7 @@ namespace FileManager
|
||||
set => persistentDictionary.SetString(nameof(InProgress), value);
|
||||
}
|
||||
|
||||
[Description("Allow Libation for fix up audiobook metadata?")]
|
||||
[Description("Allow Libation to fix up audiobook metadata")]
|
||||
public bool AllowLibationFixup
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
|
||||
@@ -129,6 +102,50 @@ namespace FileManager
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
|
||||
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value);
|
||||
}
|
||||
|
||||
[Description("Split my books into multiple files by chapter")]
|
||||
public bool SplitFilesByChapter
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(SplitFilesByChapter));
|
||||
set => persistentDictionary.SetNonString(nameof(SplitFilesByChapter), value);
|
||||
}
|
||||
|
||||
public enum BadBookAction
|
||||
{
|
||||
[Description("Ask each time what action to take.")]
|
||||
Ask = 0,
|
||||
[Description("Stop processing books.")]
|
||||
Abort = 1,
|
||||
[Description("Retry book later. Skip for now. Continue processing books.")]
|
||||
Retry = 2,
|
||||
[Description("Permanently ignore book. Continue processing books. Do not try book again.")]
|
||||
Ignore = 3
|
||||
}
|
||||
|
||||
[Description("When liberating books and there is an error, Libation should:")]
|
||||
public BadBookAction BadBook
|
||||
{
|
||||
get
|
||||
{
|
||||
var badBookStr = persistentDictionary.GetString(nameof(BadBook));
|
||||
return Enum.TryParse<BadBookAction>(badBookStr, out var badBookEnum) ? badBookEnum : BadBookAction.Ask;
|
||||
}
|
||||
set => persistentDictionary.SetString(nameof(BadBook), value.ToString());
|
||||
}
|
||||
|
||||
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
|
||||
public bool ImportEpisodes
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(ImportEpisodes));
|
||||
set => persistentDictionary.SetNonString(nameof(ImportEpisodes), value);
|
||||
}
|
||||
|
||||
[Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")]
|
||||
public bool DownloadEpisodes
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(DownloadEpisodes));
|
||||
set => persistentDictionary.SetNonString(nameof(DownloadEpisodes), value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -190,13 +207,9 @@ namespace FileManager
|
||||
|
||||
#region logging
|
||||
private IConfigurationRoot configuration;
|
||||
|
||||
public void ConfigureLogging()
|
||||
{
|
||||
//// with code. also persists to Settings.json
|
||||
//SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
|
||||
//// hack which achieves the same, in memory only
|
||||
//configuration["Serilog:WriteTo:1:Args:path"] = logPath;
|
||||
|
||||
configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
|
||||
.Build();
|
||||
@@ -210,16 +223,8 @@ namespace FileManager
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
|
||||
var logLevelEnum = Enum<LogEventLevel>.Parse(logLevelStr);
|
||||
return logLevelEnum;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return LogEventLevel.Information;
|
||||
}
|
||||
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
|
||||
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
|
||||
}
|
||||
set
|
||||
{
|
||||
@@ -248,11 +253,10 @@ namespace FileManager
|
||||
#region singleton stuff
|
||||
public static Configuration Instance { get; } = new Configuration();
|
||||
private Configuration() { }
|
||||
#endregion
|
||||
#endregion
|
||||
|
||||
#region LibationFiles
|
||||
|
||||
private const string APPSETTINGS_JSON = "appsettings.json";
|
||||
#region LibationFiles
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
|
||||
private const string LIBATION_FILES_KEY = "LibationFiles";
|
||||
|
||||
[Description("Location for storage of program-created files")]
|
||||
@@ -269,12 +273,16 @@ namespace FileManager
|
||||
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
|
||||
persistentDictionary = new PersistentDictionary(SettingsFilePath);
|
||||
|
||||
// Config init in Program.ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
|
||||
// Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
|
||||
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
|
||||
var logPath = Path.Combine(LibationFiles, "Log.log");
|
||||
bool settingWasChanged = SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
|
||||
if (settingWasChanged)
|
||||
configuration?.Reload();
|
||||
|
||||
// BAD: Serilog.WriteTo[1].Args
|
||||
// "[1]" assumes ordinal position
|
||||
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
|
||||
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
|
||||
|
||||
SetWithJsonPath(jsonpath, "path", logPath, true);
|
||||
|
||||
return libationFilesPathCache;
|
||||
}
|
||||
103
LibationFileManager/FilePathCache.cs
Normal file
103
LibationFileManager/FilePathCache.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Collections.Immutable;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public static class FilePathCache
|
||||
{
|
||||
public record CacheEntry(string Id, FileType FileType, string Path);
|
||||
|
||||
private const string FILENAME = "FileLocations.json";
|
||||
|
||||
public static event EventHandler<CacheEntry> Inserted;
|
||||
public static event EventHandler<CacheEntry> Removed;
|
||||
|
||||
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
|
||||
|
||||
private static string jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME);
|
||||
|
||||
static FilePathCache()
|
||||
{
|
||||
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
|
||||
if (File.Exists(jsonFile))
|
||||
{
|
||||
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(jsonFile));
|
||||
cache = new Cache<CacheEntry>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Exists(string id, FileType type) => GetFirstPath(id, type) != null;
|
||||
|
||||
public static List<(FileType fileType, string path)> GetFiles(string id)
|
||||
=> getEntries(entry => entry.Id == id)
|
||||
.Select(entry => (entry.FileType, entry.Path))
|
||||
.ToList();
|
||||
|
||||
public static string GetFirstPath(string id, FileType type)
|
||||
=> getEntries(entry => entry.Id == id && entry.FileType == type)
|
||||
?.FirstOrDefault()
|
||||
?.Path;
|
||||
|
||||
private static List<CacheEntry> getEntries(Func<CacheEntry, bool> predicate)
|
||||
{
|
||||
var entries = cache.Where(predicate).ToList();
|
||||
if (entries is null || !entries.Any())
|
||||
return null;
|
||||
|
||||
remove(entries.Where(e => !File.Exists(e.Path)).ToList());
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void remove(List<CacheEntry> entries)
|
||||
{
|
||||
if (entries is null)
|
||||
return;
|
||||
|
||||
lock (locker)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
cache.Remove(entry);
|
||||
Removed?.Invoke(null, entry);
|
||||
}
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Insert(string id, string path)
|
||||
{
|
||||
var type = FileTypes.GetFileTypeFromPath(path);
|
||||
var entry = new CacheEntry(id, type, path);
|
||||
cache.Add(entry);
|
||||
Inserted?.Invoke(null, entry);
|
||||
save();
|
||||
}
|
||||
|
||||
// cache is thread-safe and lock free. but file saving is not
|
||||
private static object locker { get; } = new object();
|
||||
private static void save()
|
||||
{
|
||||
// create json if not exists
|
||||
static void resave() => File.WriteAllText(jsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
|
||||
|
||||
lock (locker)
|
||||
{
|
||||
try { resave(); }
|
||||
catch (IOException)
|
||||
{
|
||||
try { resave(); }
|
||||
catch (IOException ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user