mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0f60192b | ||
|
|
424d939c15 | ||
|
|
87f13ff8ed | ||
|
|
1e24df626a | ||
|
|
0312786721 | ||
|
|
1f8a5b256e | ||
|
|
426391f01c | ||
|
|
c296bff47f | ||
|
|
6b649cf4ca | ||
|
|
5103240a76 | ||
|
|
c2418b10f6 | ||
|
|
d705c23472 | ||
|
|
de45d008c7 | ||
|
|
c267332027 | ||
|
|
4829e85faf | ||
|
|
2acb9ca7e5 | ||
|
|
b260554a2a | ||
|
|
41a4055cd9 | ||
|
|
c6e9ba9bf9 | ||
|
|
5059333b38 | ||
|
|
b4015030cf | ||
|
|
7f5cf8f018 | ||
|
|
2c9ccd9c78 | ||
|
|
cebf218db4 | ||
|
|
530b44a0e6 | ||
|
|
b3dc5a7054 | ||
|
|
2567ccb44c | ||
|
|
e67eac92fd | ||
|
|
6e84fd97f1 | ||
|
|
9a458bf3dc | ||
|
|
283a46e1e2 | ||
|
|
6ff2859c39 | ||
|
|
e8df4952fc | ||
|
|
b19e1e8a30 | ||
|
|
a3cf6ac40d | ||
|
|
ab450c37c4 | ||
|
|
c837fefbdd | ||
|
|
46b120ee41 | ||
|
|
cae8ca7ef3 | ||
|
|
904665da7f | ||
|
|
2478c61df6 | ||
|
|
288ed75b5d | ||
|
|
ad5efbd9a9 | ||
|
|
7eb7b2a0f9 | ||
|
|
d0051c0f02 | ||
|
|
d20517063e | ||
|
|
bcca69a102 | ||
|
|
35f8c05106 | ||
|
|
a3d38e082d | ||
|
|
b2e956e70b | ||
|
|
e5119357b2 | ||
|
|
b42ff827d5 | ||
|
|
68da9779da | ||
|
|
8e358d8f04 | ||
|
|
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 | ||
|
|
0cc55fd1e8 | ||
|
|
e36ea70cd1 | ||
|
|
a86185e644 | ||
|
|
64a8f007a5 | ||
|
|
215a626c92 | ||
|
|
de93047192 |
No files matched your search
@@ -1,12 +1,15 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\AAXClean\AAXClean.csproj" />
|
<PackageReference Include="AAXClean" Version="0.1.10" />
|
||||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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,229 +0,0 @@
|
|||||||
using AAXClean;
|
|
||||||
using Dinah.Core;
|
|
||||||
using Dinah.Core.IO;
|
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
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<DownloadProgress> 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 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Setting cover art by this method will insert the art into the audiobook metadata
|
|
||||||
/// </summary>
|
|
||||||
public void SetCoverArt(byte[] coverArt)
|
|
||||||
{
|
|
||||||
if (coverArt is null) return;
|
|
||||||
|
|
||||||
aaxFile?.AppleTags.SetCoverArt(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 than ~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();
|
|
||||||
}
|
|
||||||
|
|
||||||
aaxFile = new AaxFile(nfsPersister.NetworkFileStream);
|
|
||||||
|
|
||||||
RetrievedTags?.Invoke(this, aaxFile.AppleTags);
|
|
||||||
RetrievedCoverArt?.Invoke(this, aaxFile.AppleTags.Cover);
|
|
||||||
|
|
||||||
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()
|
|
||||||
{
|
|
||||||
var zeroProgress = new DownloadProgress
|
|
||||||
{
|
|
||||||
BytesReceived = 0,
|
|
||||||
ProgressPercentage = 0,
|
|
||||||
TotalBytesToReceive = nfsPersister.NetworkFileStream.Length
|
|
||||||
};
|
|
||||||
|
|
||||||
DecryptProgressUpdate?.Invoke(this, zeroProgress);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
nfsPersister.Dispose();
|
|
||||||
|
|
||||||
DecryptProgressUpdate?.Invoke(this, zeroProgress);
|
|
||||||
|
|
||||||
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,
|
|
||||||
new DownloadProgress
|
|
||||||
{
|
|
||||||
ProgressPercentage = progressPercent,
|
|
||||||
BytesReceived = (long)(nfsPersister.NetworkFileStream.Length * progressPercent),
|
|
||||||
TotalBytesToReceive = nfsPersister.NetworkFileStream.Length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
aaxFile?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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<MultiConvertFileProperties, string> multipartFileNameCallback { get; }
|
||||||
|
|
||||||
|
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<MultiConvertFileProperties, 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 ?? MultiConvertFileProperties.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(new()
|
||||||
|
{
|
||||||
|
OutputFileName = OutputFileName,
|
||||||
|
PartsPosition = currentChapter,
|
||||||
|
PartsTotal = splitChapters.Count,
|
||||||
|
Title = newSplitCallback?.Chapter?.Title
|
||||||
|
});
|
||||||
|
fileName = FileUtility.GetValidFilename(fileName);
|
||||||
|
|
||||||
|
multiPartFilePaths.Add(fileName);
|
||||||
|
|
||||||
|
FileUtility.SaferDelete(fileName);
|
||||||
|
|
||||||
|
newSplitCallback.OutputFile = File.Open(fileName, FileMode.OpenOrCreate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
public DownloadLicense(string downloadUrl, string audibleKey, string audibleIV, string userAgent)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||||
ArgumentValidator.EnsureNotNullOrEmpty(audibleKey, nameof(audibleKey));
|
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||||
ArgumentValidator.EnsureNotNullOrEmpty(audibleIV, nameof(audibleIV));
|
|
||||||
ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
|
||||||
|
|
||||||
DownloadUrl = downloadUrl;
|
// no null/empty check. unencrypted files do not have these
|
||||||
AudibleKey = audibleKey;
|
AudibleKey = audibleKey;
|
||||||
AudibleIV = audibleIV;
|
AudibleIV = audibleIV;
|
||||||
UserAgent = userAgent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using FileManager;
|
||||||
|
|
||||||
|
namespace AaxDecrypter
|
||||||
|
{
|
||||||
|
public class MultiConvertFileProperties
|
||||||
|
{
|
||||||
|
public string OutputFileName { get; set; }
|
||||||
|
public int PartsPosition { get; set; }
|
||||||
|
public int PartsTotal { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
public static string DefaultMultipartFilename(MultiConvertFileProperties multiConvertFileProperties)
|
||||||
|
{
|
||||||
|
var template = Path.ChangeExtension(multiConvertFileProperties.OutputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(multiConvertFileProperties.OutputFileName);
|
||||||
|
|
||||||
|
var fileNamingTemplate = new FileNamingTemplate(template) { IllegalCharacterReplacements = " " };
|
||||||
|
fileNamingTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(multiConvertFileProperties.PartsPosition, multiConvertFileProperties.PartsTotal));
|
||||||
|
fileNamingTemplate.AddParameterReplacement("title", multiConvertFileProperties.Title ?? "");
|
||||||
|
|
||||||
|
return fileNamingTemplate.GetFilePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using AAXClean;
|
|
||||||
using Dinah.Core;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
|
||||||
{
|
|
||||||
public static class NFO
|
|
||||||
{
|
|
||||||
public static string CreateContents(string ripper, 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 AAXC\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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,7 +83,7 @@ namespace AaxDecrypter
|
|||||||
private FileStream _readFile { get; }
|
private FileStream _readFile { get; }
|
||||||
private Stream _networkStream { get; set; }
|
private Stream _networkStream { get; set; }
|
||||||
private bool hasBegunDownloading { get; set; }
|
private bool hasBegunDownloading { get; set; }
|
||||||
private bool isCancelled { get; set; }
|
public bool IsCancelled { get; private set; }
|
||||||
private EventWaitHandle downloadEnded { get; set; }
|
private EventWaitHandle downloadEnded { get; set; }
|
||||||
private EventWaitHandle downloadedPiece { get; set; }
|
private EventWaitHandle downloadedPiece { get; set; }
|
||||||
|
|
||||||
@@ -193,9 +193,6 @@ namespace AaxDecrypter
|
|||||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||||
throw new WebException($"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 WebException($"Server at {Uri.Host} does not support Http ranges");
|
|
||||||
|
|
||||||
//Content length is the length of the range request, and it is only equal
|
//Content length is the length of the range request, and it is only equal
|
||||||
//to the complete file length if requesting Range: bytes=0-
|
//to the complete file length if requesting Range: bytes=0-
|
||||||
if (WritePosition == 0)
|
if (WritePosition == 0)
|
||||||
@@ -238,20 +235,22 @@ namespace AaxDecrypter
|
|||||||
downloadedPiece.Set();
|
downloadedPiece.Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
} while (downloadPosition < ContentLength && !isCancelled);
|
} while (downloadPosition < ContentLength && !IsCancelled);
|
||||||
|
|
||||||
_writeFile.Close();
|
_writeFile.Close();
|
||||||
|
_networkStream.Close();
|
||||||
WritePosition = downloadPosition;
|
WritePosition = downloadPosition;
|
||||||
Update();
|
Update();
|
||||||
_networkStream.Close();
|
|
||||||
|
|
||||||
if (!isCancelled && WritePosition < ContentLength)
|
downloadedPiece.Set();
|
||||||
|
downloadEnded.Set();
|
||||||
|
|
||||||
|
if (!IsCancelled && WritePosition < ContentLength)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
|
|
||||||
if (WritePosition > ContentLength)
|
if (WritePosition > ContentLength)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
|
|
||||||
downloadEnded.Set();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -419,12 +418,12 @@ namespace AaxDecrypter
|
|||||||
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
||||||
private void WaitToPosition(long requiredPosition)
|
private void WaitToPosition(long requiredPosition)
|
||||||
{
|
{
|
||||||
while (requiredPosition > WritePosition && !isCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
|
while (requiredPosition > WritePosition && !IsCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Close()
|
public override void Close()
|
||||||
{
|
{
|
||||||
isCancelled = true;
|
IsCancelled = true;
|
||||||
|
|
||||||
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
|
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
<Version>6.6.9.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>
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
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();
|
||||||
|
PopulateMissingConfigValues(config);
|
||||||
|
|
||||||
|
//
|
||||||
|
// migrations go below here
|
||||||
|
//
|
||||||
|
|
||||||
|
Migrations.migrate_to_v6_6_9(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void PopulateMissingConfigValues(Configuration config)
|
||||||
|
{
|
||||||
|
config.InProgress ??= Configuration.WinTemp;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||||
|
config.AllowLibationFixup = true;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.DecryptToLossy)))
|
||||||
|
config.DecryptToLossy = false;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.BadBook)))
|
||||||
|
config.BadBook = Configuration.BadBookAction.Ask;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
||||||
|
config.DownloadEpisodes = true;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.ImportEpisodes)))
|
||||||
|
config.ImportEpisodes = true;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.SplitFilesByChapter)))
|
||||||
|
config.SplitFilesByChapter = false;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.FolderTemplate)))
|
||||||
|
config.FolderTemplate = Templates.Folder.DefaultTemplate;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.FileTemplate)))
|
||||||
|
config.FileTemplate = Templates.File.DefaultTemplate;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.ChapterFileTemplate)))
|
||||||
|
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
|
||||||
|
|
||||||
|
if (!config.Exists(nameof(config.ShowImportedStats)))
|
||||||
|
config.ShowImportedStats = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|
||||||
|
var serilogObj = new JObject
|
||||||
|
{
|
||||||
|
{ "MinimumLevel", "Information" },
|
||||||
|
{ "WriteTo", new JArray
|
||||||
|
{
|
||||||
|
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||||
|
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
|
||||||
|
// {Properties:j} needed for expanded exception logging
|
||||||
|
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
|
||||||
|
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||||
|
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||||
|
};
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// If Serilog also writes to Console, this might be asking for trouble. ie: infinite loops.
|
||||||
|
// To use that way, SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
|
||||||
|
// However, 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
|
||||||
|
};
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public static void migrate_to_v6_6_9(Configuration config)
|
||||||
|
{
|
||||||
|
var writeToPath = $"Serilog.WriteTo";
|
||||||
|
|
||||||
|
// remove WriteTo[].Name == Console
|
||||||
|
{
|
||||||
|
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
|
||||||
|
{
|
||||||
|
for (var i = length1 - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
|
||||||
|
|
||||||
|
if (exists && value == "Console")
|
||||||
|
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add Serilog.Exceptions -- WithExceptionDetails
|
||||||
|
{
|
||||||
|
// outputTemplate should contain "{Properties:j}"
|
||||||
|
{
|
||||||
|
// re-calculate. previous loop may have changed the length
|
||||||
|
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
|
||||||
|
{
|
||||||
|
var propertyName = "outputTemplate";
|
||||||
|
for (var i = 0; i < length2; i++)
|
||||||
|
{
|
||||||
|
var jsonPath = $"{writeToPath}[{i}].Args";
|
||||||
|
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
|
||||||
|
|
||||||
|
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
|
||||||
|
|
||||||
|
if (exists && value != newValue)
|
||||||
|
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serilog.Using must include "Serilog.Exceptions"
|
||||||
|
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
|
||||||
|
|
||||||
|
// Serilog.Enrich must include "WithExceptionDetails"
|
||||||
|
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-5
@@ -6,13 +6,21 @@ using Dinah.Core;
|
|||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
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
|
internal static class UNSAFE_MigrationHelper
|
||||||
{
|
{
|
||||||
#region appsettings.json
|
#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);
|
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
|
||||||
|
|
||||||
@@ -53,7 +61,16 @@ namespace LibationLauncher
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
||||||
var jObj = JObject.Parse(startingContents);
|
|
||||||
|
JObject jObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
jObj = JObject.Parse(startingContents);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
action(jObj);
|
action(jObj);
|
||||||
|
|
||||||
@@ -96,6 +113,108 @@ namespace LibationLauncher
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
|
||||||
|
{
|
||||||
|
JToken val = null;
|
||||||
|
|
||||||
|
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||||
|
|
||||||
|
return val?.Type == jTokenType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
|
||||||
|
{
|
||||||
|
JToken val = null;
|
||||||
|
|
||||||
|
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||||
|
|
||||||
|
if (val?.Type == JTokenType.String)
|
||||||
|
{
|
||||||
|
value = val.Value<string>();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Settings_SetWithJsonPath(string jsonPath, string propertyName, string newValue)
|
||||||
|
{
|
||||||
|
if (!Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out _))
|
||||||
|
return;
|
||||||
|
|
||||||
|
process_SettingsJson(jObj =>
|
||||||
|
{
|
||||||
|
var token = jObj.SelectToken(jsonPath);
|
||||||
|
if (token is null
|
||||||
|
|| token is not JObject o
|
||||||
|
|| o[propertyName] is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var oldValue = token.Value<string>(propertyName);
|
||||||
|
if (oldValue != newValue)
|
||||||
|
token[propertyName] = newValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Settings_TryGetArrayLength(string jsonPath, out int length)
|
||||||
|
{
|
||||||
|
length = 0;
|
||||||
|
|
||||||
|
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
JArray array = null;
|
||||||
|
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
|
||||||
|
|
||||||
|
length = array.Count;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Settings_AddToArray(string jsonPath, string newValue)
|
||||||
|
{
|
||||||
|
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||||
|
return;
|
||||||
|
|
||||||
|
process_SettingsJson(jObj =>
|
||||||
|
{
|
||||||
|
var array = (JArray)jObj.SelectToken(jsonPath);
|
||||||
|
array.Add(newValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Do not add if already exists</summary>
|
||||||
|
public static void Settings_AddUniqueToArray(string arrayPath, string newValue)
|
||||||
|
{
|
||||||
|
if (!Settings_TryGetArrayLength(arrayPath, out var qty))
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (var i = 0; i < qty; i++)
|
||||||
|
{
|
||||||
|
var exists = Settings_TryGetFromJsonPath($"{arrayPath}[{i}]", out var value);
|
||||||
|
if (exists && value == newValue)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings_AddToArray(arrayPath, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>only remove if not exists</summary>
|
||||||
|
public static void Settings_RemoveFromArray(string jsonPath, int position)
|
||||||
|
{
|
||||||
|
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||||
|
return;
|
||||||
|
|
||||||
|
process_SettingsJson(jObj =>
|
||||||
|
{
|
||||||
|
var array = (JArray)jObj.SelectToken(jsonPath);
|
||||||
|
if (position < array.Count)
|
||||||
|
array.RemoveAt(position);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>only insert if not exists</summary>
|
/// <summary>only insert if not exists</summary>
|
||||||
public static void Settings_Insert(string key, string value)
|
public static void Settings_Insert(string key, string value)
|
||||||
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));
|
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));
|
||||||
@@ -122,7 +241,16 @@ namespace LibationLauncher
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var startingContents = File.ReadAllText(SettingsJsonPath);
|
var startingContents = File.ReadAllText(SettingsJsonPath);
|
||||||
var jObj = JObject.Parse(startingContents);
|
|
||||||
|
JObject jObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
jObj = JObject.Parse(startingContents);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
action(jObj);
|
action(jObj);
|
||||||
|
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="27.1.1" />
|
<PackageReference Include="CsvHelper" Version="27.2.1" />
|
||||||
<PackageReference Include="NPOI" Version="2.5.3" />
|
<PackageReference Include="NPOI" Version="2.5.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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="..\DtoImporterService\DtoImporterService.csproj" />
|
||||||
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
|
|
||||||
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using FileManager;
|
using LibationFileManager;
|
||||||
|
|
||||||
namespace ApplicationServices
|
namespace ApplicationServices
|
||||||
{
|
{
|
||||||
public static class DbContexts
|
public static class DbContexts
|
||||||
{
|
{
|
||||||
//// idea for future command/query separation
|
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
|
||||||
// public static LibationContext GetCommandContext() { }
|
|
||||||
// public static LibationContext GetQueryContext() { }
|
|
||||||
|
|
||||||
public static LibationContext GetContext()
|
public static LibationContext GetContext()
|
||||||
=> LibationContext.Create(SqliteStorage.ConnectionString);
|
=> 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,32 +3,27 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AudibleApi;
|
using AudibleApi;
|
||||||
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using DtoImporterService;
|
using DtoImporterService;
|
||||||
using InternalUtilities;
|
using LibationFileManager;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using static DtoImporterService.PerfLogger;
|
||||||
|
|
||||||
namespace ApplicationServices
|
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
|
public static class LibraryCommands
|
||||||
{
|
{
|
||||||
private static LibraryOptions.ResponseGroupOptions LibraryResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS;
|
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
|
||||||
|
{
|
||||||
|
logRestart();
|
||||||
|
|
||||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, ILoginCallback> loginCallbackFactoryFunc, List<LibraryBook> existingLibrary, params Account[] accounts)
|
|
||||||
{
|
|
||||||
//These are the minimum response groups required for the
|
//These are the minimum response groups required for the
|
||||||
//library scanner to pass all validation and filtering.
|
//library scanner to pass all validation and filtering.
|
||||||
LibraryResponseGroups =
|
var libraryResponseGroups =
|
||||||
LibraryOptions.ResponseGroupOptions.ProductAttrs |
|
LibraryOptions.ResponseGroupOptions.ProductAttrs |
|
||||||
LibraryOptions.ResponseGroupOptions.ProductDesc |
|
LibraryOptions.ResponseGroupOptions.ProductDesc |
|
||||||
LibraryOptions.ResponseGroupOptions.Relationships;
|
LibraryOptions.ResponseGroupOptions.Relationships;
|
||||||
|
|
||||||
if (accounts is null || accounts.Length == 0)
|
if (accounts is null || accounts.Length == 0)
|
||||||
@@ -36,8 +31,12 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var libraryItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
|
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||||
Log.Logger.Information($"GetAllLibraryItems: Total count {libraryItems.Count}");
|
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();
|
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ namespace ApplicationServices
|
|||||||
}
|
}
|
||||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||||
{
|
{
|
||||||
lfEx.SaveFiles(FileManager.Configuration.Instance.LibationFiles);
|
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||||
|
|
||||||
// nuget Serilog.Exceptions would automatically log custom properties
|
// 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:
|
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||||
@@ -63,38 +62,44 @@ namespace ApplicationServices
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Logger.Error(ex, "Error importing library");
|
Log.Logger.Error(ex, "Error scanning library");
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
LibraryResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS;
|
stop();
|
||||||
}
|
var putBreakPointHere = logOutput;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region FULL LIBRARY scan and import
|
#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<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
|
||||||
{
|
{
|
||||||
|
logRestart();
|
||||||
|
|
||||||
if (accounts is null || accounts.Length == 0)
|
if (accounts is null || accounts.Length == 0)
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
|
|
||||||
try
|
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;
|
var totalCount = importItems.Count;
|
||||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||||
|
|
||||||
|
Log.Logger.Information("Begin long-running import");
|
||||||
|
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||||
var newCount = await importIntoDbAsync(importItems);
|
var newCount = await importIntoDbAsync(importItems);
|
||||||
Log.Logger.Information($"Import: New count {newCount}");
|
logTime($"post {nameof(importIntoDbAsync)}");
|
||||||
|
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||||
await Task.Run(() => SearchEngineCommands.FullReIndex());
|
|
||||||
Log.Logger.Information("FullReIndex: success");
|
|
||||||
|
|
||||||
return (totalCount, newCount);
|
return (totalCount, newCount);
|
||||||
}
|
}
|
||||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||||
{
|
{
|
||||||
lfEx.SaveFiles(FileManager.Configuration.Instance.LibationFiles);
|
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||||
|
|
||||||
// nuget Serilog.Exceptions would automatically log custom properties
|
// 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:
|
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||||
@@ -115,20 +120,23 @@ namespace ApplicationServices
|
|||||||
Log.Logger.Error(ex, "Error importing library");
|
Log.Logger.Error(ex, "Error importing library");
|
||||||
throw;
|
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>>>();
|
var tasks = new List<Task<List<ImportItem>>>();
|
||||||
foreach (var account in accounts)
|
foreach (var account in accounts)
|
||||||
{
|
{
|
||||||
var callback = loginCallbackFactoryFunc(account);
|
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||||
|
var apiExtended = await apiExtendedfunc(account);
|
||||||
// get APIs in serial, esp b/c of logins
|
|
||||||
var api = await AudibleApiActions.GetApiAsync(callback, account);
|
|
||||||
|
|
||||||
// add scanAccountAsync as a TASK: do not await
|
// add scanAccountAsync as a TASK: do not await
|
||||||
tasks.Add(scanAccountAsync(api, account));
|
tasks.Add(scanAccountAsync(apiExtended, account, libraryResponseGroups));
|
||||||
}
|
}
|
||||||
|
|
||||||
// import library in parallel
|
// import library in parallel
|
||||||
@@ -137,7 +145,7 @@ namespace ApplicationServices
|
|||||||
return importItems;
|
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));
|
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||||
|
|
||||||
@@ -146,142 +154,133 @@ namespace ApplicationServices
|
|||||||
Account = account?.MaskedLogEntry ?? "[null]"
|
Account = account?.MaskedLogEntry ?? "[null]"
|
||||||
});
|
});
|
||||||
|
|
||||||
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api, LibraryResponseGroups);
|
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();
|
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)
|
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
||||||
{
|
{
|
||||||
|
logTime("importIntoDbAsync -- pre db");
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
var libraryImporter = new LibraryImporter(context);
|
var libraryBookImporter = new LibraryBookImporter(context);
|
||||||
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
|
var newCount = await Task.Run(() => libraryBookImporter.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;
|
return newCount;
|
||||||
}
|
}
|
||||||
#endregion
|
#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
|
#region Update book details
|
||||||
public static int UpdateTags(Book book, string newTags)
|
public static int UpdateUserDefinedItem(Book book)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
var udi = book.UserDefinedItem;
|
|
||||||
|
|
||||||
if (udi.Tags == newTags)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
udi.Tags = newTags;
|
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
|
||||||
var qtyChanges = context.SaveChanges();
|
var qtyChanges = context.SaveChanges();
|
||||||
|
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
|
{
|
||||||
|
SearchEngineCommands.UpdateLiberatedStatus(book);
|
||||||
SearchEngineCommands.UpdateBookTags(book);
|
SearchEngineCommands.UpdateBookTags(book);
|
||||||
|
BookUserDefinedItemCommitted?.Invoke(null, book.AudibleProductId);
|
||||||
|
}
|
||||||
|
|
||||||
return qtyChanges;
|
return qtyChanges;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Logger.Error(ex, "Error updating tags");
|
Log.Logger.Error(ex, $"Error updating {nameof(book.UserDefinedItem)}");
|
||||||
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");
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#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...
|
// 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)
|
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded) { }
|
||||||
=> 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 static LibraryStats GetCounts()
|
public static LibraryStats GetCounts()
|
||||||
{
|
{
|
||||||
var libraryBooks = DbContexts.GetContext().GetLibrary_Flat_NoTracking();
|
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
||||||
|
|
||||||
var results = libraryBooks
|
var results = libraryBooks
|
||||||
.AsParallel()
|
.AsParallel()
|
||||||
.Select(lb => Liberated_Status(lb.Book))
|
.Select(lb => Liberated_Status(lb.Book))
|
||||||
.ToList();
|
.ToList();
|
||||||
var booksFullyBackedUp = results.Count(r => r == LiberatedState.Liberated);
|
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
|
||||||
var booksDownloadedOnly = results.Count(r => r == LiberatedState.PartialDownload);
|
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
|
||||||
var booksNoProgress = results.Count(r => r == LiberatedState.NotDownloaded);
|
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
|
var boolResults = libraryBooks
|
||||||
.AsParallel()
|
.AsParallel()
|
||||||
.Where(lb => lb.Book.Supplements.Any())
|
.Where(lb => lb.Book.HasPdf)
|
||||||
.Select(lb => Pdf_Status(lb.Book))
|
.Select(lb => Pdf_Status(lb.Book))
|
||||||
.ToList();
|
.ToList();
|
||||||
var pdfsDownloaded = boolResults.Count(r => r == PdfState.Downloaded);
|
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
|
||||||
var pdfsNotDownloaded = boolResults.Count(r => r == PdfState.NotDownloaded);
|
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
|
||||||
|
|
||||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,11 +44,14 @@ namespace ApplicationServices
|
|||||||
[Name("Length In Minutes")]
|
[Name("Length In Minutes")]
|
||||||
public int LengthInMinutes { get; set; }
|
public int LengthInMinutes { get; set; }
|
||||||
|
|
||||||
|
[Name("Description")]
|
||||||
|
public string Description { get; set; }
|
||||||
|
|
||||||
[Name("Publisher")]
|
[Name("Publisher")]
|
||||||
public string Publisher { get; set; }
|
public string Publisher { get; set; }
|
||||||
|
|
||||||
[Name("Pdf url")]
|
[Name("Has PDF")]
|
||||||
public string PdfUrl { get; set; }
|
public bool HasPdf { get; set; }
|
||||||
|
|
||||||
[Name("Series Names")]
|
[Name("Series Names")]
|
||||||
public string SeriesNames { get; set; }
|
public string SeriesNames { get; set; }
|
||||||
@@ -89,14 +92,14 @@ namespace ApplicationServices
|
|||||||
[Name("My Libation Tags")]
|
[Name("My Libation Tags")]
|
||||||
public string MyLibationTags { get; set; }
|
public string MyLibationTags { get; set; }
|
||||||
|
|
||||||
[Name("Book Liberation Status")]
|
[Name("Book Liberated Status")]
|
||||||
public string BookStatus { get; set; }
|
public string BookStatus { get; set; }
|
||||||
|
|
||||||
[Name("Book File Location")]
|
[Name("PDF Liberated Status")]
|
||||||
public string BookLocation { get; set; }
|
|
||||||
|
|
||||||
[Name("PDF Liberation Status")]
|
|
||||||
public string PdfStatus { get; set; }
|
public string PdfStatus { get; set; }
|
||||||
|
|
||||||
|
[Name("Content Type")]
|
||||||
|
public string ContentType { get; set; }
|
||||||
}
|
}
|
||||||
public static class LibToDtos
|
public static class LibToDtos
|
||||||
{
|
{
|
||||||
@@ -111,10 +114,11 @@ namespace ApplicationServices
|
|||||||
AuthorNames = a.Book.AuthorNames,
|
AuthorNames = a.Book.AuthorNames,
|
||||||
NarratorNames = a.Book.NarratorNames,
|
NarratorNames = a.Book.NarratorNames,
|
||||||
LengthInMinutes = a.Book.LengthInMinutes,
|
LengthInMinutes = a.Book.LengthInMinutes,
|
||||||
|
Description = a.Book.Description,
|
||||||
Publisher = a.Book.Publisher,
|
Publisher = a.Book.Publisher,
|
||||||
PdfUrl = a.Book.Supplements?.FirstOrDefault()?.Url,
|
HasPdf = a.Book.HasPdf,
|
||||||
SeriesNames = a.Book.SeriesNames,
|
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,
|
CommunityRatingOverall = a.Book.Rating?.OverallRating,
|
||||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
||||||
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
||||||
@@ -127,17 +131,15 @@ namespace ApplicationServices
|
|||||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
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();
|
}).ToList();
|
||||||
}
|
}
|
||||||
public static class LibraryExporter
|
public static class LibraryExporter
|
||||||
{
|
{
|
||||||
public static void ToCsv(string saveFilePath)
|
public static void ToCsv(string saveFilePath)
|
||||||
{
|
{
|
||||||
using var context = DbContexts.GetContext();
|
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
|
||||||
|
|
||||||
if (!dtos.Any())
|
if (!dtos.Any())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -151,17 +153,14 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
public static void ToJson(string saveFilePath)
|
public static void ToJson(string saveFilePath)
|
||||||
{
|
{
|
||||||
using var context = DbContexts.GetContext();
|
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
|
||||||
|
|
||||||
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
|
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
|
||||||
System.IO.File.WriteAllText(saveFilePath, json);
|
System.IO.File.WriteAllText(saveFilePath, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ToXlsx(string saveFilePath)
|
public static void ToXlsx(string saveFilePath)
|
||||||
{
|
{
|
||||||
using var context = DbContexts.GetContext();
|
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
|
||||||
|
|
||||||
var workbook = new XSSFWorkbook();
|
var workbook = new XSSFWorkbook();
|
||||||
var sheet = workbook.CreateSheet("Library");
|
var sheet = workbook.CreateSheet("Library");
|
||||||
@@ -185,8 +184,9 @@ namespace ApplicationServices
|
|||||||
nameof (ExportDto.AuthorNames),
|
nameof (ExportDto.AuthorNames),
|
||||||
nameof (ExportDto.NarratorNames),
|
nameof (ExportDto.NarratorNames),
|
||||||
nameof (ExportDto.LengthInMinutes),
|
nameof (ExportDto.LengthInMinutes),
|
||||||
|
nameof (ExportDto.Description),
|
||||||
nameof (ExportDto.Publisher),
|
nameof (ExportDto.Publisher),
|
||||||
nameof (ExportDto.PdfUrl),
|
nameof (ExportDto.HasPdf),
|
||||||
nameof (ExportDto.SeriesNames),
|
nameof (ExportDto.SeriesNames),
|
||||||
nameof (ExportDto.SeriesOrder),
|
nameof (ExportDto.SeriesOrder),
|
||||||
nameof (ExportDto.CommunityRatingOverall),
|
nameof (ExportDto.CommunityRatingOverall),
|
||||||
@@ -201,8 +201,8 @@ namespace ApplicationServices
|
|||||||
nameof (ExportDto.MyRatingStory),
|
nameof (ExportDto.MyRatingStory),
|
||||||
nameof (ExportDto.MyLibationTags),
|
nameof (ExportDto.MyLibationTags),
|
||||||
nameof (ExportDto.BookStatus),
|
nameof (ExportDto.BookStatus),
|
||||||
nameof (ExportDto.BookLocation),
|
nameof (ExportDto.PdfStatus),
|
||||||
nameof (ExportDto.PdfStatus)
|
nameof (ExportDto.ContentType)
|
||||||
};
|
};
|
||||||
var col = 0;
|
var col = 0;
|
||||||
foreach (var c in columns)
|
foreach (var c in columns)
|
||||||
@@ -238,8 +238,9 @@ namespace ApplicationServices
|
|||||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.Description);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Publisher);
|
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.SeriesNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||||
|
|
||||||
@@ -265,8 +266,8 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.BookLocation);
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||||
|
|
||||||
rowIndex++;
|
rowIndex++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ namespace ApplicationServices
|
|||||||
public static void FullReIndex(SearchEngine engine = null)
|
public static void FullReIndex(SearchEngine engine = null)
|
||||||
{
|
{
|
||||||
engine ??= new SearchEngine();
|
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 =>
|
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();
|
var engine = new SearchEngine();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return action(engine);
|
return func(engine);
|
||||||
}
|
}
|
||||||
catch (FileNotFoundException)
|
catch (FileNotFoundException)
|
||||||
{
|
{
|
||||||
FullReIndex(engine);
|
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 Dinah.Core;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace InternalUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
public class Account : IUpdatable
|
public class Account : IUpdatable
|
||||||
{
|
{
|
||||||
@@ -6,7 +6,7 @@ using AudibleApi.Authorization;
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace InternalUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
|
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
|
||||||
// from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm):
|
// from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm):
|
||||||
+1
-1
@@ -3,7 +3,7 @@ using AudibleApi.Authorization;
|
|||||||
using Dinah.Core.IO;
|
using Dinah.Core.IO;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace InternalUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
public class AccountsSettingsPersister : JsonFilePersister<AccountsSettings>
|
public class AccountsSettingsPersister : JsonFilePersister<AccountsSettings>
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
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
|
||||||
|
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);
|
||||||
|
|
||||||
|
// actual individual episode, not the parent of a series.
|
||||||
|
// for now I'm keeping it inside this method since it fits the work flow, incl. importEpisodes logic
|
||||||
|
if (!children.Any())
|
||||||
|
{
|
||||||
|
results.Add(parent);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||||
|
Sequence = parent.Relationships.FirstOrDefault(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;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using FileManager;
|
using LibationFileManager;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace InternalUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
public static class AudibleApiStorage
|
public static class AudibleApiStorage
|
||||||
{
|
{
|
||||||
+8
-6
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using AudibleApiDTOs;
|
using AudibleApi.Common;
|
||||||
|
|
||||||
namespace InternalUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
public interface IValidator
|
public interface IValidator
|
||||||
{
|
{
|
||||||
@@ -33,8 +33,10 @@ namespace InternalUtilities
|
|||||||
|
|
||||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
|
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
|
||||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items)));
|
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;
|
return exceptions;
|
||||||
}
|
}
|
||||||
@@ -76,9 +78,9 @@ namespace InternalUtilities
|
|||||||
|
|
||||||
var distinct = items.GetSeriesDistinct();
|
var distinct = items.GetSeriesDistinct();
|
||||||
if (distinct.Any(s => s.SeriesId is null))
|
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))
|
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;
|
return exceptions;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AudibleApi" Version="2.6.2.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(AudibleUtilities) + ".Tests")]
|
||||||
@@ -7,6 +7,20 @@ namespace DataLayer.Configurations
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<LibraryBook> entity)
|
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.HasKey(b => b.BookId);
|
||||||
|
|
||||||
entity
|
entity
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -12,20 +12,20 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.5">
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.0.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.5">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.2">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.EntityFrameworkCore\Dinah.EntityFrameworkCore.csproj" />
|
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
||||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+23
-12
@@ -15,6 +15,10 @@ namespace DataLayer
|
|||||||
Id = id;
|
Id = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enum will be easier than bool to extend later
|
||||||
|
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 }
|
||||||
|
|
||||||
public class Book
|
public class Book
|
||||||
{
|
{
|
||||||
// implementation detail. set by db only. only used by data layer
|
// 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 Title { get; private set; }
|
||||||
public string Description { get; private set; }
|
public string Description { get; private set; }
|
||||||
public int LengthInMinutes { get; private set; }
|
public int LengthInMinutes { get; private set; }
|
||||||
|
public ContentType ContentType { get; private set; }
|
||||||
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
|
|
||||||
public string Locale { get; private set; }
|
public string Locale { get; private set; }
|
||||||
|
|
||||||
// mutable
|
// mutable
|
||||||
@@ -51,6 +54,12 @@ namespace DataLayer
|
|||||||
// is owned, not optional 1:1
|
// is owned, not optional 1:1
|
||||||
public UserDefinedItem UserDefinedItem { get; private set; }
|
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
|
// is owned, not optional 1:1
|
||||||
/// <summary>The product's aggregate community rating</summary>
|
/// <summary>The product's aggregate community rating</summary>
|
||||||
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
||||||
@@ -64,9 +73,11 @@ namespace DataLayer
|
|||||||
string title,
|
string title,
|
||||||
string description,
|
string description,
|
||||||
int lengthInMinutes,
|
int lengthInMinutes,
|
||||||
|
ContentType contentType,
|
||||||
IEnumerable<Contributor> authors,
|
IEnumerable<Contributor> authors,
|
||||||
IEnumerable<Contributor> narrators,
|
IEnumerable<Contributor> narrators,
|
||||||
Category category, string localeName)
|
Category category,
|
||||||
|
string localeName)
|
||||||
{
|
{
|
||||||
// validate
|
// validate
|
||||||
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
|
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
|
||||||
@@ -88,9 +99,10 @@ namespace DataLayer
|
|||||||
Category = category;
|
Category = category;
|
||||||
|
|
||||||
// simple assigns
|
// simple assigns
|
||||||
Title = title.Trim();
|
Title = title.Trim() ?? "";
|
||||||
Description = description.Trim();
|
Description = description?.Trim() ?? "";
|
||||||
LengthInMinutes = lengthInMinutes;
|
LengthInMinutes = lengthInMinutes;
|
||||||
|
ContentType = contentType;
|
||||||
|
|
||||||
// assigns with biz logic
|
// assigns with biz logic
|
||||||
ReplaceAuthors(authors);
|
ReplaceAuthors(authors);
|
||||||
@@ -173,6 +185,9 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
if (_seriesLink is null)
|
||||||
|
return "";
|
||||||
|
|
||||||
// first: alphabetical by name
|
// first: alphabetical by name
|
||||||
var withNames = _seriesLink
|
var withNames = _seriesLink
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
|
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
|
||||||
@@ -191,7 +206,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));
|
ArgumentValidator.EnsureNotNull(series, nameof(series));
|
||||||
|
|
||||||
@@ -202,9 +217,9 @@ namespace DataLayer
|
|||||||
|
|
||||||
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
|
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
|
||||||
if (singleSeriesBook == null)
|
if (singleSeriesBook == null)
|
||||||
_seriesLink.Add(new SeriesBook(series, this, index));
|
_seriesLink.Add(new SeriesBook(series, this, order));
|
||||||
else
|
else
|
||||||
singleSeriesBook.UpdateIndex(index);
|
singleSeriesBook.UpdateOrder(order);
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -247,10 +262,6 @@ namespace DataLayer
|
|||||||
Category = category;
|
Category = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
// needed for v3 => v4 upgrade
|
|
||||||
public void UpdateLocale(string localeName)
|
|
||||||
=> Locale ??= localeName;
|
|
||||||
|
|
||||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,6 @@ namespace DataLayer
|
|||||||
public Book Book { get; private set; }
|
public Book Book { get; private set; }
|
||||||
|
|
||||||
public DateTime DateAdded { 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; }
|
public string Account { get; private set; }
|
||||||
|
|
||||||
private LibraryBook() { }
|
private LibraryBook() { }
|
||||||
@@ -24,10 +22,6 @@ namespace DataLayer
|
|||||||
Account = account;
|
Account = account;
|
||||||
}
|
}
|
||||||
|
|
||||||
// needed for v3 => v4 upgrade
|
|
||||||
public void UpdateAccount(string account)
|
|
||||||
=> Account ??= account;
|
|
||||||
|
|
||||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,25 +48,6 @@ namespace DataLayer
|
|||||||
Name = name;
|
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;
|
public override string ToString() => Name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,32 +7,27 @@ namespace DataLayer
|
|||||||
internal int SeriesId { get; private set; }
|
internal int SeriesId { get; private set; }
|
||||||
internal int BookId { get; private set; }
|
internal int BookId { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
public string Order { get; private set; }
|
||||||
/// <para>"index" not "order". This is both for sequence and display</para>
|
public float Index => StringLib.ExtractFirstNumber(Order);
|
||||||
/// <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 Series Series { get; private set; }
|
public Series Series { get; private set; }
|
||||||
public Book Book { get; private set; }
|
public Book Book { get; private set; }
|
||||||
|
|
||||||
private SeriesBook() { }
|
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(series, nameof(series));
|
||||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||||
|
|
||||||
Series = series;
|
Series = series;
|
||||||
Book = book;
|
Book = book;
|
||||||
Index = index;
|
Order = order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateIndex(float? index)
|
public void UpdateOrder(string order)
|
||||||
{
|
{
|
||||||
if (index.HasValue)
|
if (!string.IsNullOrWhiteSpace(order))
|
||||||
Index = index.Value;
|
Order = order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"Series={Series} Book={Book}";
|
public override string ToString() => $"Series={Series} Book={Book}";
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ namespace DataLayer
|
|||||||
NotLiberated = 0,
|
NotLiberated = 0,
|
||||||
Liberated = 1,
|
Liberated = 1,
|
||||||
/// <summary>Error occurred during liberation. Don't retry</summary>
|
/// <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
|
public class UserDefinedItem
|
||||||
@@ -30,7 +33,7 @@ namespace DataLayer
|
|||||||
|
|
||||||
// import previously saved tags
|
// import previously saved tags
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
|
||||||
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
Tags = LibationFileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Tags
|
#region Tags
|
||||||
@@ -38,7 +41,15 @@ namespace DataLayer
|
|||||||
public string Tags
|
public string Tags
|
||||||
{
|
{
|
||||||
get => _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);
|
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);
|
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region LiberatedStatuses and book file location
|
#region LiberatedStatuses
|
||||||
public LiberatedStatus BookStatus { get; set; }
|
/// <summary>
|
||||||
public string BookLocation { get; set; }
|
/// Occurs when <see cref="Tags"/>, <see cref="BookStatus"/>, or <see cref="PdfStatus"/> values change.
|
||||||
public LiberatedStatus? PdfStatus { get; set; }
|
/// 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
|
#endregion
|
||||||
|
|
||||||
public override string ToString() => $"{Book} {Rating} {Tags}";
|
public override string ToString() => $"{Book} {Rating} {Tags}";
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ namespace DataLayer
|
|||||||
// // overwrite collection
|
// // overwrite collection
|
||||||
// Entry(product).Collection(x => x.Narrators).Load();
|
// Entry(product).Collection(x => x.Narrators).Load();
|
||||||
// product.Narrators = narrators;
|
// 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<Book> Books { get; private set; }
|
||||||
public DbSet<Contributor> Contributors { get; private set; }
|
public DbSet<Contributor> Contributors { get; private set; }
|
||||||
public DbSet<Series> Series { get; private set; }
|
public DbSet<Series> Series { get; private set; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "5.0.5");
|
.HasAnnotation("ProductVersion", "5.0.10");
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
@@ -28,6 +28,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<int>("CategoryId")
|
b.Property<int>("CategoryId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<DateTime?>("DatePublished")
|
b.Property<DateTime?>("DatePublished")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -152,7 +155,7 @@ namespace DataLayer.Migrations
|
|||||||
|
|
||||||
b.HasKey("BookId");
|
b.HasKey("BookId");
|
||||||
|
|
||||||
b.ToTable("Library");
|
b.ToTable("LibraryBooks");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Series", b =>
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
@@ -182,8 +185,8 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<int>("BookId")
|
b.Property<int>("BookId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<float?>("Index")
|
b.Property<string>("Order")
|
||||||
.HasColumnType("REAL");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("SeriesId", "BookId");
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
@@ -253,9 +256,6 @@ namespace DataLayer.Migrations
|
|||||||
b1.Property<int>("BookId")
|
b1.Property<int>("BookId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b1.Property<string>("BookLocation")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b1.Property<int>("BookStatus")
|
b1.Property<int>("BookStatus")
|
||||||
.HasColumnType("INTEGER");
|
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-3
@@ -6,7 +6,7 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
// only library importing should use tracking. All else should be NoTracking.
|
// only library importing should use tracking. All else should be NoTracking.
|
||||||
// only library importing should directly query Book. All else should use LibraryBook
|
// 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
|
//// 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)
|
//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)
|
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
|
||||||
=> context
|
=> context
|
||||||
.Library
|
.LibraryBooks
|
||||||
.AsNoTrackingWithIdentityResolution()
|
.AsNoTrackingWithIdentityResolution()
|
||||||
.GetLibrary()
|
.GetLibrary()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||||
=> context
|
=> context
|
||||||
.Library
|
.LibraryBooks
|
||||||
.AsNoTrackingWithIdentityResolution()
|
.AsNoTrackingWithIdentityResolution()
|
||||||
.GetLibraryBook(productId);
|
.GetLibraryBook(productId);
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ namespace DataLayer
|
|||||||
.Select(t => (t.Book.AudibleProductId, t.Tags))
|
.Select(t => (t.Book.AudibleProductId, t.Tags))
|
||||||
.ToList();
|
.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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using AudibleApiDTOs;
|
using AudibleApi.Common;
|
||||||
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using InternalUtilities;
|
|
||||||
|
|
||||||
namespace DtoImporterService
|
namespace DtoImporterService
|
||||||
{
|
{
|
||||||
@@ -19,13 +19,13 @@ namespace DtoImporterService
|
|||||||
new ContributorImporter(DbContext).Import(importItems);
|
new ContributorImporter(DbContext).Import(importItems);
|
||||||
new SeriesImporter(DbContext).Import(importItems);
|
new SeriesImporter(DbContext).Import(importItems);
|
||||||
new CategoryImporter(DbContext).Import(importItems);
|
new CategoryImporter(DbContext).Import(importItems);
|
||||||
|
|
||||||
// get distinct
|
// 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
|
// load db existing => .Local
|
||||||
loadLocal_books(productIds);
|
loadLocal_books(productIds);
|
||||||
|
|
||||||
// upsert
|
// upsert
|
||||||
var qtyNew = upsertBooks(importItems);
|
var qtyNew = upsertBooks(importItems);
|
||||||
return qtyNew;
|
return qtyNew;
|
||||||
@@ -33,12 +33,30 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
private void loadLocal_books(List<string> productIds)
|
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
|
var remainingProductIds = productIds
|
||||||
.Distinct()
|
|
||||||
.Except(localProductIds)
|
.Except(localProductIds)
|
||||||
.ToList();
|
.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
|
// GetBooks() eager loads Series, category, et al
|
||||||
if (remainingProductIds.Any())
|
if (remainingProductIds.Any())
|
||||||
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||||
@@ -50,7 +68,7 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
foreach (var item in importItems)
|
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)
|
if (book is null)
|
||||||
{
|
{
|
||||||
book = createNewBook(item);
|
book = createNewBook(item);
|
||||||
@@ -67,6 +85,8 @@ namespace DtoImporterService
|
|||||||
{
|
{
|
||||||
var item = importItem.DtoItem;
|
var item = importItem.DtoItem;
|
||||||
|
|
||||||
|
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
|
||||||
|
|
||||||
// absence of authors is very rare, but possible
|
// absence of authors is very rare, but possible
|
||||||
if (!item.Authors?.Any() ?? true)
|
if (!item.Authors?.Any() ?? true)
|
||||||
item.Authors = new[] { new Person { Name = "", Asin = null } };
|
item.Authors = new[] { new Person { Name = "", Asin = null } };
|
||||||
@@ -74,7 +94,8 @@ namespace DtoImporterService
|
|||||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||||
var authors = item
|
var authors = item
|
||||||
.Authors
|
.Authors
|
||||||
.Select(a => DbContext.Contributors.Local.Single(c => a.Name == c.Name))
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
|
||||||
|
.Select(a => DbContext.Contributors.Local.FirstOrDefault(c => a.Name == c.Name))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var narrators
|
var narrators
|
||||||
@@ -84,7 +105,8 @@ namespace DtoImporterService
|
|||||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||||
: item
|
: item
|
||||||
.Narrators
|
.Narrators
|
||||||
.Select(n => DbContext.Contributors.Local.Single(c => n.Name == c.Name))
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
|
||||||
|
.Select(n => DbContext.Contributors.Local.FirstOrDefault(c => n.Name == c.Name))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||||
@@ -98,30 +120,52 @@ namespace DtoImporterService
|
|||||||
// 2+
|
// 2+
|
||||||
: item.Categories[1].CategoryId;
|
: item.Categories[1].CategoryId;
|
||||||
|
|
||||||
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
|
// This should properly be SingleOrDefault() not FirstOrDefault(), but FirstOrDefault is defensive
|
||||||
|
var category = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == lastCategory);
|
||||||
|
|
||||||
var book = DbContext.Books.Add(new Book(
|
Book book;
|
||||||
new AudibleProductId(item.ProductId),
|
try
|
||||||
item.TitleWithSubtitle,
|
{
|
||||||
item.Description,
|
book = DbContext.Books.Add(new Book(
|
||||||
item.LengthInMinutes,
|
new AudibleProductId(item.ProductId),
|
||||||
authors,
|
item.TitleWithSubtitle,
|
||||||
narrators,
|
item.Description,
|
||||||
category,
|
item.LengthInMinutes,
|
||||||
importItem.LocaleName)
|
contentType,
|
||||||
).Entity;
|
authors,
|
||||||
|
narrators,
|
||||||
|
category,
|
||||||
|
importItem.LocaleName)
|
||||||
|
).Entity;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error adding book. {@DebugInfo}", new {
|
||||||
|
item.ProductId,
|
||||||
|
item.TitleWithSubtitle,
|
||||||
|
item.Description,
|
||||||
|
item.LengthInMinutes,
|
||||||
|
contentType,
|
||||||
|
QtyAuthors = authors?.Count,
|
||||||
|
QtyNarrators = narrators?.Count,
|
||||||
|
Category = category?.Name,
|
||||||
|
importItem.LocaleName
|
||||||
|
});
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
var publisherName = item.Publisher;
|
var publisherName = item.Publisher;
|
||||||
if (!string.IsNullOrWhiteSpace(publisherName))
|
if (!string.IsNullOrWhiteSpace(publisherName))
|
||||||
{
|
{
|
||||||
var publisher = DbContext.Contributors.Local.Single(c => publisherName == c.Name);
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
|
||||||
|
var publisher = DbContext.Contributors.Local.FirstOrDefault(c => publisherName == c.Name);
|
||||||
book.ReplacePublisher(publisher);
|
book.ReplacePublisher(publisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
|
if (item.PdfUrl is not null)
|
||||||
book.AddSupplementDownloadUrl(item.SupplementUrl);
|
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||||
|
|
||||||
return book;
|
return book;
|
||||||
}
|
}
|
||||||
@@ -131,11 +175,10 @@ namespace DtoImporterService
|
|||||||
var item = importItem.DtoItem;
|
var item = importItem.DtoItem;
|
||||||
|
|
||||||
// set/update book-specific info which may have changed
|
// set/update book-specific info which may have changed
|
||||||
book.PictureId = item.PictureId;
|
if (item.PictureId is not null)
|
||||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
book.PictureId = item.PictureId;
|
||||||
|
|
||||||
// needed during v3 => v4 migration
|
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||||
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
|
// 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);
|
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
||||||
@@ -146,19 +189,8 @@ namespace DtoImporterService
|
|||||||
{
|
{
|
||||||
foreach (var seriesEntry in item.Series)
|
foreach (var seriesEntry in item.Series)
|
||||||
{
|
{
|
||||||
var series = DbContext.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
var series = DbContext.Series.Local.FirstOrDefault(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||||
|
book.UpsertSeries(series, seriesEntry.Sequence);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using AudibleApiDTOs;
|
using AudibleApi.Common;
|
||||||
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using InternalUtilities;
|
|
||||||
|
|
||||||
namespace DtoImporterService
|
namespace DtoImporterService
|
||||||
{
|
{
|
||||||
@@ -35,7 +35,7 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
private void loadLocal_categories(List<string> categoryIds)
|
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
|
var remainingCategoryIds = categoryIds
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Except(localIds)
|
.Except(localIds)
|
||||||
@@ -66,12 +66,22 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
Category parentCategory = null;
|
Category parentCategory = null;
|
||||||
if (i == 1)
|
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)
|
if (category is null)
|
||||||
{
|
{
|
||||||
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
|
try
|
||||||
|
{
|
||||||
|
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error adding category. {@DebugInfo}", new { id, name });
|
||||||
|
throw;
|
||||||
|
}
|
||||||
qtyNew++;
|
qtyNew++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using AudibleApiDTOs;
|
using AudibleApi.Common;
|
||||||
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using InternalUtilities;
|
|
||||||
|
|
||||||
namespace DtoImporterService
|
namespace DtoImporterService
|
||||||
{
|
{
|
||||||
@@ -47,57 +47,81 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
private void loadLocal_contributors(List<string> contributorNames)
|
private void loadLocal_contributors(List<string> contributorNames)
|
||||||
{
|
{
|
||||||
|
// must include default/empty/missing
|
||||||
|
contributorNames.Add(Contributor.GetEmpty().Name);
|
||||||
|
|
||||||
//// BAD: very inefficient
|
//// BAD: very inefficient
|
||||||
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
|
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
|
||||||
|
|
||||||
// GOOD: Except() is efficient. Due to hashing, it's close to O(n)
|
// 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
|
var remainingContribNames = contributorNames
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Except(localNames)
|
.Except(localNames)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// load existing => local
|
// load existing => local
|
||||||
// remember to include default/empty/missing
|
|
||||||
var emptyName = Contributor.GetEmpty().Name;
|
|
||||||
if (remainingContribNames.Any())
|
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
|
// only use after loading contributors => local
|
||||||
private int upsertPeople(List<Person> people)
|
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
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
|
||||||
var person = DbContext.Contributors.Local.FirstOrDefault(c => c.Name == p.Name);
|
var p = groupby.FirstOrDefault(g => g.Name == name).People.First();
|
||||||
if (person == null)
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
person = DbContext.Contributors.Add(new Contributor(p.Name, p.Asin)).Entity;
|
DbContext.Contributors.Add(new Contributor(p.Name, p.Asin));
|
||||||
qtyNew++;
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error adding person. {@DebugInfo}", new { p?.Name, p?.Asin });
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return qtyNew;
|
return newPeople.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only use after loading contributors => local
|
// only use after loading contributors => local
|
||||||
private int upsertPublishers(List<string> publishers)
|
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)
|
foreach (var pub in newPublishers)
|
||||||
{
|
{
|
||||||
if (DbContext.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
|
try
|
||||||
{
|
{
|
||||||
DbContext.Contributors.Add(new Contributor(publisherName));
|
DbContext.Contributors.Add(new Contributor(pub));
|
||||||
qtyNew++;
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error adding publisher. {@DebugInfo}", new { pub });
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return qtyNew;
|
return newPublishers.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
|
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||||
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using AudibleApiDTOs;
|
using AudibleApi.Common;
|
||||||
|
|
||||||
namespace InternalUtilities
|
namespace DtoImporterService
|
||||||
{
|
{
|
||||||
public class ImportItem
|
public class ImportItem
|
||||||
{
|
{
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using InternalUtilities;
|
|
||||||
|
|
||||||
namespace DtoImporterService
|
namespace DtoImporterService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using AudibleUtilities;
|
||||||
|
using DataLayer;
|
||||||
|
|
||||||
|
namespace DtoImporterService
|
||||||
|
{
|
||||||
|
public class LibraryBookImporter : ItemsImporterBase
|
||||||
|
{
|
||||||
|
public LibraryBookImporter(LibationContext context) : base(context) { }
|
||||||
|
|
||||||
|
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||||
|
|
||||||
|
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||||
|
{
|
||||||
|
new BookImporter(DbContext).Import(importItems);
|
||||||
|
|
||||||
|
var qtyNew = upsertLibraryBooks(importItems);
|
||||||
|
return qtyNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
|
||||||
|
{
|
||||||
|
// technically, we should be able to have duplicate books from separate accounts.
|
||||||
|
// this would violate the current pk and would be difficult to deal with elsewhere:
|
||||||
|
// - what to show in the grid
|
||||||
|
// - which to consider liberated
|
||||||
|
//
|
||||||
|
// 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.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
|
||||||
|
var newItems = importItems
|
||||||
|
.Where(dto => !currentLibraryProductIds
|
||||||
|
.Contains(dto.DtoItem.ProductId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
|
||||||
|
// just use the first
|
||||||
|
var groupby = newItems.GroupBy(
|
||||||
|
i => i.DtoItem.ProductId,
|
||||||
|
i => i,
|
||||||
|
(key, g) => new { ProductId = key, ImportItems = g.ToList() }
|
||||||
|
)
|
||||||
|
.ToList();
|
||||||
|
foreach (var gb in groupby)
|
||||||
|
{
|
||||||
|
var newItem = gb.ImportItems.First();
|
||||||
|
|
||||||
|
var libraryBook = new LibraryBook(
|
||||||
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
|
||||||
|
DbContext.Books.Local.FirstOrDefault(b => b.AudibleProductId == newItem.DtoItem.ProductId),
|
||||||
|
newItem.DtoItem.DateAdded,
|
||||||
|
newItem.AccountId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DbContext.LibraryBooks.Add(libraryBook);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var qtyNew = groupby.Count;
|
||||||
|
return qtyNew;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using AudibleApiDTOs;
|
|
||||||
using DataLayer;
|
|
||||||
using InternalUtilities;
|
|
||||||
|
|
||||||
namespace DtoImporterService
|
|
||||||
{
|
|
||||||
public class LibraryImporter : ItemsImporterBase
|
|
||||||
{
|
|
||||||
public LibraryImporter(LibationContext context) : base(context) { }
|
|
||||||
|
|
||||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
|
|
||||||
|
|
||||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
|
||||||
{
|
|
||||||
new BookImporter(DbContext).Import(importItems);
|
|
||||||
|
|
||||||
var qtyNew = upsertLibraryBooks(importItems);
|
|
||||||
return qtyNew;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
|
|
||||||
{
|
|
||||||
// technically, we should be able to have duplicate books from separate accounts.
|
|
||||||
// this would violate the current pk and would be difficult to deal with elsewhere:
|
|
||||||
// - 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
|
|
||||||
//
|
|
||||||
// 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 newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
|
|
||||||
|
|
||||||
foreach (var newItem in newItems)
|
|
||||||
{
|
|
||||||
var libraryBook = new LibraryBook(
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
var qtyNew = newItems.Count;
|
|
||||||
return qtyNew;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using AudibleApiDTOs;
|
using AudibleApi.Common;
|
||||||
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using InternalUtilities;
|
|
||||||
|
|
||||||
namespace DtoImporterService
|
namespace DtoImporterService
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ namespace DtoImporterService
|
|||||||
return qtyNew;
|
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 seriesIds = series.Select(s => s.SeriesId).ToList();
|
||||||
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
|
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
|
||||||
@@ -42,16 +42,24 @@ namespace DtoImporterService
|
|||||||
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
|
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;
|
var qtyNew = 0;
|
||||||
|
|
||||||
foreach (var s in requestedSeries)
|
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)
|
if (series is null)
|
||||||
{
|
{
|
||||||
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
try
|
||||||
|
{
|
||||||
|
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error adding series. {@DebugInfo}", new { s?.SeriesId });
|
||||||
|
throw;
|
||||||
|
}
|
||||||
qtyNew++;
|
qtyNew++;
|
||||||
}
|
}
|
||||||
series.UpdateName(s.SeriesName);
|
series.UpdateName(s.SeriesName);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
private class MultipartRenamer
|
||||||
|
{
|
||||||
|
private LibraryBook libraryBook { get; }
|
||||||
|
|
||||||
|
internal MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
|
||||||
|
|
||||||
|
internal string MultipartFilename(AaxDecrypter.MultiConvertFileProperties props)
|
||||||
|
=> Templates.ChapterFile.GetFilename(libraryBook.ToDto(), props);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Func<AaxDecrypter.MultiConvertFileProperties, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
|
||||||
|
=> new MultipartRenamer(libraryBook).MultipartFilename;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DownloadDecryptBook:
|
||||||
|
/// File path for where to move files into.
|
||||||
|
/// Path: directory nested inside of Books directory
|
||||||
|
/// File name: n/a
|
||||||
|
/// </summary>
|
||||||
|
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
|
||||||
|
=> Templates.Folder.GetFilename(libraryBook.ToDto());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DownloadDecryptBook:
|
||||||
|
/// Path: in progress directory.
|
||||||
|
/// File name: final file name.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||||
|
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PDF: audio file does not exist
|
||||||
|
/// </summary>
|
||||||
|
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||||
|
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PDF: audio file already exists
|
||||||
|
/// </summary>
|
||||||
|
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||||
|
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,49 @@
|
|||||||
using AAXClean;
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AAXClean;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
using Dinah.Core.IO;
|
|
||||||
using Dinah.Core.Net.Http;
|
using Dinah.Core.Net.Http;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
using System;
|
using LibationFileManager;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class ConvertToMp3 : IAudioDecodable
|
public class ConvertToMp3 : AudioDecodable
|
||||||
{
|
{
|
||||||
|
|
||||||
private Mp4File m4bBook;
|
private Mp4File m4bBook;
|
||||||
|
|
||||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
private long fileSize;
|
||||||
public event EventHandler<Action<byte[]>> RequestCoverArt;
|
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||||
public event EventHandler<string> TitleDiscovered;
|
|
||||||
public event EventHandler<string> AuthorsDiscovered;
|
|
||||||
public event EventHandler<string> NarratorsDiscovered;
|
|
||||||
public event EventHandler<byte[]> CoverImageDiscovered;
|
|
||||||
public event EventHandler<string> StreamingBegin;
|
|
||||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
|
||||||
public event EventHandler<string> StreamingCompleted;
|
|
||||||
public event EventHandler<LibraryBook> Begin;
|
|
||||||
public event EventHandler<string> StatusUpdate;
|
|
||||||
public event EventHandler<LibraryBook> Completed;
|
|
||||||
|
|
||||||
private long fileSize;
|
public override void Cancel() => m4bBook?.Cancel();
|
||||||
private string Mp3FileName(string m4bPath) => m4bPath is null ? string.Empty : PathLib.ReplaceExtension(m4bPath, ".mp3");
|
|
||||||
|
|
||||||
public void Cancel() => m4bBook?.Cancel();
|
public override bool Validate(LibraryBook libraryBook)
|
||||||
|
|
||||||
public 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));
|
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);
|
||||||
|
|
||||||
StreamingBegin?.Invoke(this, $"Begin converting {libraryBook} to mp3");
|
OnStreamingBegin($"Begin converting {libraryBook} to mp3");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var m4bPath = ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book);
|
var m4bPath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
|
||||||
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
|
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
|
||||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||||
|
|
||||||
fileSize = m4bBook.InputStream.Length;
|
fileSize = m4bBook.InputStream.Length;
|
||||||
|
|
||||||
TitleDiscovered?.Invoke(this, m4bBook.AppleTags.Title);
|
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||||
AuthorsDiscovered?.Invoke(this, m4bBook.AppleTags.FirstAuthor);
|
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||||
NarratorsDiscovered?.Invoke(this, m4bBook.AppleTags.Narrator);
|
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||||
CoverImageDiscovered?.Invoke(this, m4bBook.AppleTags.Cover);
|
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||||
|
|
||||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||||
|
|
||||||
@@ -66,9 +51,9 @@ namespace FileLiberator
|
|||||||
m4bBook.InputStream.Close();
|
m4bBook.InputStream.Close();
|
||||||
mp3File.Close();
|
mp3File.Close();
|
||||||
|
|
||||||
var mp3Path = Mp3FileName(m4bPath);
|
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||||
|
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path);
|
||||||
FileExt.SafeMove(mp3File.Name, mp3Path);
|
OnFileCreated(libraryBook, realMp3Path);
|
||||||
|
|
||||||
var statusHandler = new StatusHandler();
|
var statusHandler = new StatusHandler();
|
||||||
|
|
||||||
@@ -79,8 +64,8 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
StreamingCompleted?.Invoke(this, $"Completed converting to mp3: {libraryBook.Book.Title}");
|
OnStreamingCompleted($"Completed converting to mp3: {libraryBook.Book.Title}");
|
||||||
Completed?.Invoke(this, libraryBook);
|
OnCompleted(libraryBook);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +76,11 @@ namespace FileLiberator
|
|||||||
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||||
|
|
||||||
if (double.IsNormal(estTimeRemaining))
|
if (double.IsNormal(estTimeRemaining))
|
||||||
StreamingTimeRemaining?.Invoke(this, TimeSpan.FromSeconds(estTimeRemaining));
|
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||||
|
|
||||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||||
|
|
||||||
StreamingProgressChanged?.Invoke(this,
|
OnStreamingProgressChanged(
|
||||||
new DownloadProgress
|
new DownloadProgress
|
||||||
{
|
{
|
||||||
ProgressPercentage = progressPercent,
|
ProgressPercentage = progressPercent,
|
||||||
|
|||||||
@@ -8,226 +8,203 @@ using AudibleApi;
|
|||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
using FileManager;
|
using FileManager;
|
||||||
|
using LibationFileManager;
|
||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadDecryptBook : IAudioDecodable
|
public class DownloadDecryptBook : AudioDecodable
|
||||||
{
|
{
|
||||||
|
private AudiobookDownloadBase abDownloader;
|
||||||
|
|
||||||
private AaxcDownloadConverter aaxcDownloader;
|
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists;
|
||||||
|
|
||||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
public override void Cancel() => abDownloader?.Cancel();
|
||||||
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 event EventHandler<string> StreamingBegin;
|
|
||||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
|
||||||
public event EventHandler<string> StreamingCompleted;
|
|
||||||
public event EventHandler<LibraryBook> Begin;
|
|
||||||
public event EventHandler<string> StatusUpdate;
|
|
||||||
public event EventHandler<LibraryBook> Completed;
|
|
||||||
|
|
||||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
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
|
try
|
||||||
{
|
{
|
||||||
if (ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book))
|
if (libraryBook.Book.Audio_Exists)
|
||||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||||
|
|
||||||
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
|
// decrypt failed
|
||||||
if (outputAudioFilename is null)
|
if (!success)
|
||||||
return new StatusHandler { "Decrypt failed" };
|
return new StatusHandler { "Decrypt failed" };
|
||||||
|
|
||||||
// moves files and returns dest dir
|
// moves new files from temp dir to final dest
|
||||||
_ = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
|
var movedAudioFile = moveFilesToBooksDir(libraryBook, entries);
|
||||||
|
|
||||||
var finalAudioExists = ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book);
|
// decrypt failed
|
||||||
if (!finalAudioExists)
|
if (!movedAudioFile)
|
||||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||||
|
|
||||||
// only need to update if success. if failure, it will remain at 0 == NotLiberated
|
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
|
||||||
ApplicationServices.LibraryCommands.UpdateBook(libraryBook, LiberatedStatus.Liberated, outputAudioFilename);
|
|
||||||
|
|
||||||
return new StatusHandler();
|
return new StatusHandler();
|
||||||
}
|
}
|
||||||
finally
|
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)
|
||||||
{
|
{
|
||||||
StreamingBegin?.Invoke(this, $"Begin decrypting {libraryBook}");
|
OnStreamingBegin($"Begin decrypting {libraryBook}");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
validate(libraryBook);
|
downloadValidation(libraryBook);
|
||||||
|
|
||||||
var api = await InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
|
|
||||||
|
|
||||||
|
var api = await libraryBook.GetApiAsync();
|
||||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||||
|
|
||||||
var aaxcDecryptDlLic = new DownloadLicense
|
var audiobookDlLic = new DownloadLicense
|
||||||
(
|
(
|
||||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||||
contentLic?.Voucher?.Key,
|
contentLic?.Voucher?.Key,
|
||||||
contentLic?.Voucher?.Iv,
|
contentLic?.Voucher?.Iv,
|
||||||
Resources.UserAgent
|
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)
|
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
|
abDownloader
|
||||||
{
|
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm ? new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic)
|
||||||
OutputFormat.Mp4a => "m4b",
|
: Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
|
||||||
OutputFormat.Mp3 => "mp3",
|
outFileName, cacheDir, audiobookDlLic, outputFormat,
|
||||||
_ => throw new NotImplementedException(),
|
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook)
|
||||||
};
|
)
|
||||||
|
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic, outputFormat);
|
||||||
var outFileName = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].{extension}");
|
abDownloader.DecryptProgressUpdate += (_, progress) => OnStreamingProgressChanged(progress);
|
||||||
|
abDownloader.DecryptTimeRemaining += (_, remaining) => OnStreamingTimeRemaining(remaining);
|
||||||
|
abDownloader.RetrievedTitle += (_, title) => OnTitleDiscovered(title);
|
||||||
aaxcDownloader = new AaxcDownloadConverter(outFileName, cacheDir, aaxcDecryptDlLic, format) { AppName = "Libation" };
|
abDownloader.RetrievedAuthors += (_, authors) => OnAuthorsDiscovered(authors);
|
||||||
aaxcDownloader.DecryptProgressUpdate += (s, progress) => StreamingProgressChanged?.Invoke(this, progress);
|
abDownloader.RetrievedNarrators += (_, narrators) => OnNarratorsDiscovered(narrators);
|
||||||
aaxcDownloader.DecryptTimeRemaining += (s, remaining) => StreamingTimeRemaining?.Invoke(this, remaining);
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
aaxcDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||||
aaxcDownloader.RetrievedTags += aaxcDownloader_RetrievedTags;
|
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
// REAL WORK DONE HERE
|
||||||
var success = await Task.Run(() => aaxcDownloader.Run());
|
var success = await Task.Run(abDownloader.Run);
|
||||||
|
return success;
|
||||||
// decrypt failed
|
|
||||||
if (!success)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return outFileName;
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
StreamingCompleted?.Invoke(this, $"Completed downloading and decrypting {libraryBook.Book.Title}");
|
OnStreamingCompleted($"Completed downloading and decrypting {libraryBook.Book.Title}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void downloadValidation(LibraryBook libraryBook)
|
||||||
private void AaxcDownloader_RetrievedCoverArt(object sender, byte[] e)
|
|
||||||
{
|
{
|
||||||
if (e is null && Configuration.Instance.AllowLibationFixup)
|
string errorString(string field)
|
||||||
{
|
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||||
RequestCoverArt?.Invoke(this, aaxcDownloader.SetCoverArt);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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 not null)
|
if (e is not null)
|
||||||
{
|
OnCoverImageDiscovered(e);
|
||||||
CoverImageDiscovered?.Invoke(this, 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);
|
// create final directory. move each file into it
|
||||||
AuthorsDiscovered?.Invoke(this, e.FirstAuthor ?? "[unknown]");
|
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||||
NarratorsDiscovered?.Invoke(this, e.Narrator ?? "[unknown]");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string moveFilesToBooksDir(Book product, string outputAudioFilename)
|
|
||||||
{
|
|
||||||
// create final directory. move each file into it. MOVE AUDIO FILE LAST
|
|
||||||
// new dir: safetitle_limit50char + " [" + productId + "]"
|
|
||||||
|
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestDir(product.Title, product.AudibleProductId);
|
|
||||||
Directory.CreateDirectory(destinationDir);
|
Directory.CreateDirectory(destinationDir);
|
||||||
|
|
||||||
var sortedFiles = getProductFilesSorted(product, outputAudioFilename);
|
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||||
|
|
||||||
var musicFileExt = Path.GetExtension(outputAudioFilename).Trim('.');
|
if (getFirstAudio() == default)
|
||||||
|
return false;
|
||||||
|
|
||||||
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext
|
for (var i = 0; i < entries.Count; i++)
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
var title
|
var entry = entries[i];
|
||||||
= (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))
|
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)));
|
||||||
throw new Exception(errorString("Account"));
|
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
// propogate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||||
throw new Exception(errorString("Locale"));
|
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,30 +6,26 @@ using Dinah.Core.Net.Http;
|
|||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
// currently only used to download the .zip flies for upgrade
|
// currently only used to download the .zip flies for upgrade
|
||||||
public class DownloadFile : IStreamable
|
public class DownloadFile : Streamable
|
||||||
{
|
{
|
||||||
public event EventHandler<string> StreamingBegin;
|
|
||||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
|
||||||
public event EventHandler<string> StreamingCompleted;
|
|
||||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
|
||||||
|
|
||||||
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
|
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
|
||||||
{
|
{
|
||||||
var client = new HttpClient();
|
var client = new HttpClient();
|
||||||
|
|
||||||
var progress = new Progress<DownloadProgress>();
|
var progress = new Progress<DownloadProgress>();
|
||||||
progress.ProgressChanged += (_, e) => StreamingProgressChanged?.Invoke(this, e);
|
progress.ProgressChanged += (_, e) => OnStreamingProgressChanged(e);
|
||||||
|
|
||||||
StreamingBegin?.Invoke(this, proposedDownloadFilePath);
|
OnStreamingBegin(proposedDownloadFilePath);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||||
|
OnFileCreated("Upgrade", actualDownloadedFilePath);
|
||||||
return actualDownloadedFilePath;
|
return actualDownloadedFilePath;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
StreamingCompleted?.Invoke(this, proposedDownloadFilePath);
|
OnStreamingCompleted(proposedDownloadFilePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,60 +8,79 @@ using DataLayer;
|
|||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
using Dinah.Core.Net.Http;
|
using Dinah.Core.Net.Http;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
|
using LibationFileManager;
|
||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadPdf : DownloadableBase
|
public class DownloadPdf : Processable
|
||||||
{
|
{
|
||||||
public override bool Validate(LibraryBook libraryBook)
|
public override bool Validate(LibraryBook libraryBook)
|
||||||
=> !string.IsNullOrWhiteSpace(getdownloadUrl(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);
|
OnBegin(libraryBook);
|
||||||
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
|
||||||
var result = verifyDownload(libraryBook);
|
|
||||||
|
|
||||||
var liberatedStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
|
try
|
||||||
ApplicationServices.LibraryCommands.UpdatePdf(libraryBook, liberatedStatus);
|
{
|
||||||
|
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)
|
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
|
var extension = Path.GetExtension(getdownloadUrl(libraryBook));
|
||||||
|
|
||||||
// if audio file exists, get it's dir. else return base Book dir
|
// 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 is not null)
|
||||||
|
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, existingPath, extension);
|
||||||
|
|
||||||
if (existingPath != null)
|
return AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, extension);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string getdownloadUrl(LibraryBook libraryBook)
|
private static string getdownloadUrl(LibraryBook libraryBook)
|
||||||
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
|
=> 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);
|
OnStreamingBegin(proposedDownloadFilePath);
|
||||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
|
||||||
|
|
||||||
var client = new HttpClient();
|
try
|
||||||
var actualDownloadedFilePath = await PerformDownloadAsync(
|
{
|
||||||
proposedDownloadFilePath,
|
var api = await libraryBook.GetApiAsync();
|
||||||
(p) => client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, p));
|
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)
|
private static StatusHandler verifyDownload(string actualDownloadedFilePath)
|
||||||
=> !ApplicationServices.TransitionalFileLocator.PDF_Exists(libraryBook.Book)
|
=> !File.Exists(actualDownloadedFilePath)
|
||||||
? new StatusHandler { "Downloaded PDF cannot be found" }
|
? new StatusHandler { "Downloaded PDF cannot be found" }
|
||||||
: new StatusHandler();
|
: new StatusHandler();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +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 : IProcessable
|
|
||||||
{
|
|
||||||
public event EventHandler<LibraryBook> Begin;
|
|
||||||
public event EventHandler<LibraryBook> Completed;
|
|
||||||
|
|
||||||
public event EventHandler<string> StreamingBegin;
|
|
||||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
|
||||||
public event EventHandler<string> StreamingCompleted;
|
|
||||||
|
|
||||||
public event EventHandler<string> StatusUpdate;
|
|
||||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
|
||||||
|
|
||||||
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) => StreamingProgressChanged?.Invoke(this, e);
|
|
||||||
|
|
||||||
StreamingBegin?.Invoke(this, proposedDownloadFilePath);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await func(progress);
|
|
||||||
StatusUpdate?.Invoke(this, result);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
StreamingCompleted?.Invoke(this, proposedDownloadFilePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
|
||||||
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
|
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
|
||||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
|
||||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||||
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace FileLiberator
|
|
||||||
{
|
|
||||||
public interface IAudioDecodable : IProcessable
|
|
||||||
{
|
|
||||||
event EventHandler<Action<byte[]>> RequestCoverArt;
|
|
||||||
event EventHandler<string> TitleDiscovered;
|
|
||||||
event EventHandler<string> AuthorsDiscovered;
|
|
||||||
event EventHandler<string> NarratorsDiscovered;
|
|
||||||
event EventHandler<byte[]> CoverImageDiscovered;
|
|
||||||
void Cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DataLayer;
|
|
||||||
using Dinah.Core.ErrorHandling;
|
|
||||||
|
|
||||||
namespace FileLiberator
|
|
||||||
{
|
|
||||||
public interface IProcessable : IStreamable
|
|
||||||
{
|
|
||||||
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,51 +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, bool validate)
|
|
||||||
{
|
|
||||||
if (validate && !processable.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 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
|
|
||||||
namespace FileLiberator
|
|
||||||
{
|
|
||||||
public interface IStreamable
|
|
||||||
{
|
|
||||||
event EventHandler<string> StreamingBegin;
|
|
||||||
event EventHandler<DownloadProgress> StreamingProgressChanged;
|
|
||||||
event EventHandler<TimeSpan> StreamingTimeRemaining;
|
|
||||||
event EventHandler<string> StreamingCompleted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DataLayer;
|
||||||
|
using Dinah.Core;
|
||||||
|
using LibationFileManager;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
||||||
|
{
|
||||||
|
Account = libraryBook.Account,
|
||||||
|
|
||||||
|
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||||
|
Title = libraryBook.Book.Title ?? "",
|
||||||
|
Locale = libraryBook.Book.Locale,
|
||||||
|
|
||||||
|
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||||
|
|
||||||
|
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
||||||
|
|
||||||
|
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||||
|
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")]
|
||||||
@@ -1,172 +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; set; }
|
|
||||||
#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}");
|
|
||||||
BookDirectoryFiles ??= new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
lock (BookDirectoryFiles)
|
|
||||||
{
|
|
||||||
if (storageDir != BookDirectoryFiles.RootDirectory)
|
|
||||||
{
|
|
||||||
BookDirectoryFiles = new BackgroundFileSystem(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.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace FileManager
|
namespace FileManager
|
||||||
{
|
{
|
||||||
class BackgroundFileSystem
|
/// <summary>
|
||||||
|
/// Tracks actual locations of files.
|
||||||
|
/// </summary>
|
||||||
|
public class BackgroundFileSystem
|
||||||
{
|
{
|
||||||
public string RootDirectory { get; private set; }
|
public string RootDirectory { get; private set; }
|
||||||
public string SearchPattern { get; private set; }
|
public string SearchPattern { get; private set; }
|
||||||
@@ -18,6 +19,8 @@ namespace FileManager
|
|||||||
private FileSystemWatcher fileSystemWatcher { get; set; }
|
private FileSystemWatcher fileSystemWatcher { get; set; }
|
||||||
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
|
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
|
||||||
private Task backgroundScanner { get; set; }
|
private Task backgroundScanner { get; set; }
|
||||||
|
|
||||||
|
private object fsCacheLocker { get; } = new();
|
||||||
private List<string> fsCache { get; } = new();
|
private List<string> fsCache { get; } = new();
|
||||||
|
|
||||||
public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions)
|
public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions)
|
||||||
@@ -29,17 +32,15 @@ namespace FileManager
|
|||||||
Init();
|
Init();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string FindFile(string regexPattern, RegexOptions options)
|
public string FindFile(System.Text.RegularExpressions.Regex regex)
|
||||||
{
|
{
|
||||||
lock (fsCache)
|
lock (fsCacheLocker)
|
||||||
{
|
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
|
||||||
return fsCache.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, options));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshFiles()
|
public void RefreshFiles()
|
||||||
{
|
{
|
||||||
lock (fsCache)
|
lock (fsCacheLocker)
|
||||||
{
|
{
|
||||||
fsCache.Clear();
|
fsCache.Clear();
|
||||||
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
||||||
@@ -50,17 +51,19 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
Stop();
|
Stop();
|
||||||
|
|
||||||
lock (fsCache)
|
lock (fsCacheLocker)
|
||||||
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
||||||
|
|
||||||
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
||||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory);
|
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
||||||
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
{
|
||||||
|
IncludeSubdirectories = true,
|
||||||
|
EnableRaisingEvents = true
|
||||||
|
};
|
||||||
|
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
||||||
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
|
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
|
||||||
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
|
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
|
||||||
fileSystemWatcher.Error += FileSystemWatcher_Error;
|
fileSystemWatcher.Error += FileSystemWatcher_Error;
|
||||||
fileSystemWatcher.IncludeSubdirectories = true;
|
|
||||||
fileSystemWatcher.EnableRaisingEvents = true;
|
|
||||||
|
|
||||||
backgroundScanner = new Task(BackgroundScanner);
|
backgroundScanner = new Task(BackgroundScanner);
|
||||||
backgroundScanner.Start();
|
backgroundScanner.Start();
|
||||||
@@ -79,7 +82,7 @@ namespace FileManager
|
|||||||
//Dispose of directoryChangesEvents after backgroundScanner exists.
|
//Dispose of directoryChangesEvents after backgroundScanner exists.
|
||||||
directoryChangesEvents?.Dispose();
|
directoryChangesEvents?.Dispose();
|
||||||
|
|
||||||
lock (fsCache)
|
lock (fsCacheLocker)
|
||||||
fsCache.Clear();
|
fsCache.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +102,7 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
|
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
|
||||||
{
|
{
|
||||||
lock (fsCache)
|
lock (fsCacheLocker)
|
||||||
UpdateLocalCache(change);
|
UpdateLocalCache(change);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,9 +142,7 @@ namespace FileManager
|
|||||||
private void AddUniqueFiles(IEnumerable<string> newFiles)
|
private void AddUniqueFiles(IEnumerable<string> newFiles)
|
||||||
{
|
{
|
||||||
foreach (var file in newFiles)
|
foreach (var file in newFiles)
|
||||||
{
|
|
||||||
AddUniqueFile(file);
|
AddUniqueFile(file);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
private void AddUniqueFile(string newFile)
|
private void AddUniqueFile(string newFile)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
|
<PackageReference Include="Dinah.Core" Version="4.0.3.1" />
|
||||||
<PackageReference Include="Octokit" Version="0.50.0" />
|
<PackageReference Include="Polly" Version="7.2.3" />
|
||||||
<PackageReference Include="Polly" Version="7.2.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Dinah.Core;
|
||||||
|
|
||||||
|
namespace FileManager
|
||||||
|
{
|
||||||
|
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||||
|
public class FileNamingTemplate
|
||||||
|
{
|
||||||
|
/// <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 FileNamingTemplate(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, object> ParameterReplacements { get; } = new Dictionary<string, object>();
|
||||||
|
|
||||||
|
/// <summary>Convenience method</summary>
|
||||||
|
public void AddParameterReplacement(string key, object value)
|
||||||
|
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
|
||||||
|
=> 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 static string formatKey(string key)
|
||||||
|
=> key
|
||||||
|
.Replace("<", "")
|
||||||
|
.Replace(">", "");
|
||||||
|
|
||||||
|
private string formatValue(object value)
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
||||||
|
// Esp important for file templates.
|
||||||
|
var val = value
|
||||||
|
.ToString()
|
||||||
|
.Replace($"{System.IO.Path.DirectorySeparatorChar}", IllegalCharacterReplacements)
|
||||||
|
.Replace($"{System.IO.Path.AltDirectorySeparatorChar}", IllegalCharacterReplacements);
|
||||||
|
return
|
||||||
|
ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0
|
||||||
|
? val.Truncate(ParameterMaxSize.Value)
|
||||||
|
: val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+195
-27
@@ -1,52 +1,220 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Dinah.Core;
|
||||||
|
using Polly;
|
||||||
|
using Polly.Retry;
|
||||||
|
|
||||||
namespace FileManager
|
namespace FileManager
|
||||||
{
|
{
|
||||||
public static class FileUtility
|
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().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))
|
ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0);
|
||||||
throw new ArgumentException($"{nameof(dirFullPath)} may not be null or whitespace", nameof(dirFullPath));
|
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
|
private const int MAX_FILENAME_LENGTH = 255;
|
||||||
filename = GetAsciiTag(filename);
|
private const int MAX_DIRECTORY_LENGTH = 247;
|
||||||
// manage length
|
|
||||||
if (filename.Length > 50)
|
|
||||||
filename = filename.Substring(0, 50) + "[...]";
|
|
||||||
|
|
||||||
// append id. it is 10 or 14 char in the common cases
|
/// <summary>
|
||||||
if (metadataSuffixes != null && metadataSuffixes.Length > 0)
|
/// Ensure valid file name path:
|
||||||
filename += " [" + string.Join("][", metadataSuffixes) + "]";
|
/// <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
|
// remove invalid chars
|
||||||
if (!string.IsNullOrWhiteSpace(extension))
|
path = GetSafePath(path, illegalCharacterReplacements);
|
||||||
extension = '.' + extension.Trim('.');
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
fullfilename = removeInvalidWhitespace(fullfilename);
|
||||||
|
|
||||||
// ensure uniqueness
|
|
||||||
var fullfilename = Path.Combine(dirFullPath, filename + extension);
|
|
||||||
var i = 0;
|
var i = 0;
|
||||||
while (File.Exists(fullfilename))
|
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;
|
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)
|
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||||
return "";
|
|
||||||
|
|
||||||
// omit characters which are invalid. EXCEPTION: change colon to underscore
|
path = replaceInvalidChars(path, illegalCharacterReplacements);
|
||||||
property = property.Replace(':', '_');
|
path = standardizeSlashes(path);
|
||||||
|
path = replaceColons(path, illegalCharacterReplacements);
|
||||||
|
path = removeDoubleSlashes(path);
|
||||||
|
|
||||||
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
|
return path;
|
||||||
foreach (var ch in Path.GetInvalidFileNameChars())
|
|
||||||
property = property.Replace(ch.ToString(), "");
|
|
||||||
return property;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static char[] invalidChars { get; } = Path.GetInvalidPathChars().Union(new[] {
|
||||||
|
'*', '?',
|
||||||
|
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
|
||||||
|
// In live code, Path.GetInvalidPathChars() does not include them
|
||||||
|
'"', '<', '>'
|
||||||
|
}).ToArray();
|
||||||
|
private static string replaceInvalidChars(string path, string illegalCharacterReplacements)
|
||||||
|
=> string.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars));
|
||||||
|
|
||||||
|
private static string standardizeSlashes(string path)
|
||||||
|
=> path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
|
private static string replaceColons(string path, string illegalCharacterReplacements)
|
||||||
|
{
|
||||||
|
// replace all colons except within the first 2 chars
|
||||||
|
var builder = new System.Text.StringBuilder();
|
||||||
|
for (var i = 0; i < path.Length; i++)
|
||||||
|
{
|
||||||
|
var c = path[i];
|
||||||
|
if (i >= 2 && c == ':')
|
||||||
|
builder.Append(illegalCharacterReplacements);
|
||||||
|
else
|
||||||
|
builder.Append(c);
|
||||||
|
}
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string removeDoubleSlashes(string path)
|
||||||
|
{
|
||||||
|
if (path.Length < 2)
|
||||||
|
return path;
|
||||||
|
|
||||||
|
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
|
||||||
|
|
||||||
|
var remainder = path[1..];
|
||||||
|
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||||
|
while (remainder.Contains(dblSeparator))
|
||||||
|
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
|
||||||
|
|
||||||
|
return path[0] + remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string removeInvalidWhitespace_pattern { get; } = $@"\s*\{Path.DirectorySeparatorChar}\s*";
|
||||||
|
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
|
||||||
|
|
||||||
|
/// <summary>no part of the path may begin or end in whitespace</summary>
|
||||||
|
private static string removeInvalidWhitespace(string fullfilename)
|
||||||
|
{
|
||||||
|
// no whitespace at beginning or end
|
||||||
|
// replace whitespace around path slashes
|
||||||
|
// regex (with space added for clarity)
|
||||||
|
// \s* \\ \s* => \
|
||||||
|
fullfilename = fullfilename.Trim();
|
||||||
|
|
||||||
|
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, @"\");
|
||||||
|
|
||||||
|
fullfilename = removeDoubleSlashes(fullfilename);
|
||||||
|
return fullfilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,8 +30,7 @@ namespace FileManager
|
|||||||
if (IsReadOnly)
|
if (IsReadOnly)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
File.WriteAllText(Filepath, "{}");
|
createNewFile();
|
||||||
System.Threading.Thread.Sleep(100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetString(string propertyName)
|
public string GetString(string propertyName)
|
||||||
@@ -51,7 +50,8 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
var obj = GetObject(propertyName);
|
var obj = GetObject(propertyName);
|
||||||
if (obj is null) return default;
|
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;
|
return (T)obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +204,39 @@ namespace FileManager
|
|||||||
|
|
||||||
private JObject readFile()
|
private JObject readFile()
|
||||||
{
|
{
|
||||||
|
if (!File.Exists(Filepath))
|
||||||
|
{
|
||||||
|
var msg = "Unrecoverable error. Settings file cannot be found";
|
||||||
|
var ex = new FileNotFoundException(msg, Filepath);
|
||||||
|
Serilog.Log.Logger.Error(ex, msg);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
|
||||||
var settingsJsonContents = File.ReadAllText(Filepath);
|
var settingsJsonContents = File.ReadAllText(Filepath);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(settingsJsonContents))
|
||||||
|
{
|
||||||
|
createNewFile();
|
||||||
|
settingsJsonContents = File.ReadAllText(Filepath);
|
||||||
|
}
|
||||||
|
|
||||||
var jObject = JsonConvert.DeserializeObject<JObject>(settingsJsonContents);
|
var jObject = JsonConvert.DeserializeObject<JObject>(settingsJsonContents);
|
||||||
|
|
||||||
|
if (jObject is null)
|
||||||
|
{
|
||||||
|
var msg = "Unrecoverable error. Unable to read settings from Settings file";
|
||||||
|
var ex = new NullReferenceException(msg);
|
||||||
|
Serilog.Log.Logger.Error(ex, msg);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
|
||||||
return jObject;
|
return jObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void createNewFile()
|
||||||
|
{
|
||||||
|
File.WriteAllText(Filepath, "{}");
|
||||||
|
System.Threading.Thread.Sleep(100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileManager) + ".Tests")]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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, LibraryOptions.ResponseGroupOptions responseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS)
|
|
||||||
{
|
|
||||||
// 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, responseGroups));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<Item>> getItemsAsync(Api api, LibraryOptions.ResponseGroupOptions responseGroups)
|
|
||||||
{
|
|
||||||
var items = await api.GetAllLibraryItemsAsync(responseGroups);
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
|
|
||||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
+58
-142
@@ -1,10 +1,11 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 16
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 16.0.28803.156
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
|
||||||
__TODO.txt = __TODO.txt
|
__TODO.txt = __TODO.txt
|
||||||
_DB_NOTES.txt = _DB_NOTES.txt
|
_DB_NOTES.txt = _DB_NOTES.txt
|
||||||
REFERENCE.txt = REFERENCE.txt
|
REFERENCE.txt = REFERENCE.txt
|
||||||
@@ -22,71 +23,46 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 Utilities (domain ignoran
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AaxDecrypter", "AaxDecrypter\AaxDecrypter.csproj", "{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AaxDecrypter", "AaxDecrypter\AaxDecrypter.csproj", "{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}"
|
||||||
EndProject
|
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
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataLayer", "DataLayer\DataLayer.csproj", "{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataLayer", "DataLayer\DataLayer.csproj", "{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator", "FileLiberator\FileLiberator.csproj", "{393B5B27-D15C-4F77-9457-FA14BA8F3C73}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator", "FileLiberator\FileLiberator.csproj", "{393B5B27-D15C-4F77-9457-FA14BA8F3C73}"
|
||||||
EndProject
|
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
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 Domain Internal Utilities (db ignorant)", "3 Domain Internal Utilities (db ignorant)", "{F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 Domain Internal Utilities (db ignorant)", "3 Domain Internal Utilities (db ignorant)", "{F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "LibationSearchEngine\LibationSearchEngine.csproj", "{2E1F5DB4-40CC-4804-A893-5DCE0193E598}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "LibationSearchEngine\LibationSearchEngine.csproj", "{2E1F5DB4-40CC-4804-A893-5DCE0193E598}"
|
||||||
EndProject
|
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}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForms", "LibationWinForms\LibationWinForms.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}"
|
||||||
EndProject
|
ProjectSection(ProjectDependencies) = postProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormsDesigner", "WinFormsDesigner\WinFormsDesigner.csproj", "{0807616A-A77A-4B08-A65A-1582B09E114B}"
|
{428163C3-D558-4914-B570-A92069521877} = {428163C3-D558-4914-B570-A92069521877}
|
||||||
EndProject
|
EndProjectSection
|
||||||
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}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoImporterService\DtoImporterService.csproj", "{401865F5-1942-4713-B230-04544C0A97B0}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoImporterService\DtoImporterService.csproj", "{401865F5-1942-4713-B230-04544C0A97B0}"
|
||||||
EndProject
|
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}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationServices", "ApplicationServices\ApplicationServices.csproj", "{B95650EA-25F0-449E-BA5D-99126BC5D730}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.WindowsDesktop", "..\Dinah.Core\Dinah.Core.WindowsDesktop\Dinah.Core.WindowsDesktop.csproj", "{059CE32C-9AD6-45E9-A166-790DFFB0B730}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
|
||||||
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}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
|
||||||
EndProject
|
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
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}"
|
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
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -118,137 +94,77 @@ Global
|
|||||||
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.Build.0 = 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
|
|
||||||
{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.ActiveCfg = Debug|Any CPU
|
||||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = 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
|
||||||
|
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
{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}
|
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
|
||||||
{393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
{393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
||||||
{06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
|
{06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
|
||||||
{2E1F5DB4-40CC-4804-A893-5DCE0193E598} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
{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}
|
{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}
|
{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}
|
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
||||||
{059CE32C-9AD6-45E9-A166-790DFFB0B730} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
|
|
||||||
{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}
|
{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}
|
{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}
|
||||||
|
{EB781571-8548-477E-82AD-FB9FAB548D2F} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
|
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net6.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>
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 is not null && File.Exists(cachedFile))
|
||||||
|
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 System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Dinah.Core.Logging;
|
using Dinah.Core.Logging;
|
||||||
|
using FileManager;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
|
|
||||||
namespace FileManager
|
namespace LibationFileManager
|
||||||
{
|
{
|
||||||
public class Configuration
|
public class Configuration
|
||||||
{
|
{
|
||||||
@@ -46,13 +47,17 @@ namespace FileManager
|
|||||||
|
|
||||||
private PersistentDictionary persistentDictionary;
|
private PersistentDictionary persistentDictionary;
|
||||||
|
|
||||||
|
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
|
||||||
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
|
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
|
||||||
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
|
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
|
||||||
|
|
||||||
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
|
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
|
||||||
/// <returns>Value was changed</returns>
|
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
|
||||||
public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
|
{
|
||||||
=> persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
|
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
|
||||||
|
if (settingWasChanged)
|
||||||
|
configuration?.Reload();
|
||||||
|
}
|
||||||
|
|
||||||
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
|
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
|
||||||
|
|
||||||
@@ -67,39 +72,7 @@ namespace FileManager
|
|||||||
return attribute?.Description;
|
return attribute?.Description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
|
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
|
|
||||||
|
|
||||||
[Description("Location for book storage. Includes destination of newly liberated books")]
|
[Description("Location for book storage. Includes destination of newly liberated books")]
|
||||||
public string Books
|
public string Books
|
||||||
@@ -116,7 +89,7 @@ namespace FileManager
|
|||||||
set => persistentDictionary.SetString(nameof(InProgress), value);
|
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
|
public bool AllowLibationFixup
|
||||||
{
|
{
|
||||||
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
|
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
|
||||||
@@ -129,6 +102,89 @@ namespace FileManager
|
|||||||
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
|
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
|
||||||
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value);
|
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("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
|
||||||
|
public bool ShowImportedStats
|
||||||
|
{
|
||||||
|
get => persistentDictionary.GetNonString<bool>(nameof(ShowImportedStats));
|
||||||
|
set => persistentDictionary.SetNonString(nameof(ShowImportedStats), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region templates: custom file naming
|
||||||
|
|
||||||
|
[Description("How to format the folders in which files will be saved")]
|
||||||
|
public string FolderTemplate
|
||||||
|
{
|
||||||
|
get => getTemplate(nameof(FolderTemplate), Templates.Folder);
|
||||||
|
set => setTemplate(nameof(FolderTemplate), Templates.Folder, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("How to format the saved pdf and audio files")]
|
||||||
|
public string FileTemplate
|
||||||
|
{
|
||||||
|
get => getTemplate(nameof(FileTemplate), Templates.File);
|
||||||
|
set => setTemplate(nameof(FileTemplate), Templates.File, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("How to format the saved audio files when split by chapters")]
|
||||||
|
public string ChapterFileTemplate
|
||||||
|
{
|
||||||
|
get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile);
|
||||||
|
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string getTemplate(string settingName, Templates templ) => templ.GetValid(persistentDictionary.GetString(settingName));
|
||||||
|
private void setTemplate(string settingName, Templates templ, string newValue)
|
||||||
|
{
|
||||||
|
var template = newValue?.Trim();
|
||||||
|
if (templ.IsValid(template))
|
||||||
|
persistentDictionary.SetString(settingName, template);
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -190,13 +246,9 @@ namespace FileManager
|
|||||||
|
|
||||||
#region logging
|
#region logging
|
||||||
private IConfigurationRoot configuration;
|
private IConfigurationRoot configuration;
|
||||||
|
|
||||||
public void ConfigureLogging()
|
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()
|
configuration = new ConfigurationBuilder()
|
||||||
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
|
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
|
||||||
.Build();
|
.Build();
|
||||||
@@ -210,23 +262,15 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
try
|
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
|
||||||
{
|
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
|
||||||
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
|
|
||||||
var logLevelEnum = Enum<LogEventLevel>.Parse(logLevelStr);
|
|
||||||
return logLevelEnum;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return LogEventLevel.Information;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
|
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
|
||||||
if (!valueWasChanged)
|
if (!valueWasChanged)
|
||||||
{
|
{
|
||||||
Log.Logger.Information("LogLevel.set attempt. No change");
|
Log.Logger.Debug("LogLevel.set attempt. No change");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,11 +292,10 @@ namespace FileManager
|
|||||||
#region singleton stuff
|
#region singleton stuff
|
||||||
public static Configuration Instance { get; } = new Configuration();
|
public static Configuration Instance { get; } = new Configuration();
|
||||||
private Configuration() { }
|
private Configuration() { }
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region LibationFiles
|
#region LibationFiles
|
||||||
|
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
|
||||||
private const string APPSETTINGS_JSON = "appsettings.json";
|
|
||||||
private const string LIBATION_FILES_KEY = "LibationFiles";
|
private const string LIBATION_FILES_KEY = "LibationFiles";
|
||||||
|
|
||||||
[Description("Location for storage of program-created files")]
|
[Description("Location for storage of program-created files")]
|
||||||
@@ -269,12 +312,16 @@ namespace FileManager
|
|||||||
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
|
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
|
||||||
persistentDictionary = new PersistentDictionary(SettingsFilePath);
|
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
|
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
|
||||||
var logPath = Path.Combine(LibationFiles, "Log.log");
|
var logPath = Path.Combine(LibationFiles, "Log.log");
|
||||||
bool settingWasChanged = SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
|
|
||||||
if (settingWasChanged)
|
// BAD: Serilog.WriteTo[1].Args
|
||||||
configuration?.Reload();
|
// "[1]" assumes ordinal position
|
||||||
|
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
|
||||||
|
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
|
||||||
|
|
||||||
|
SetWithJsonPath(jsonpath, "path", logPath, true);
|
||||||
|
|
||||||
return libationFilesPathCache;
|
return libationFilesPathCache;
|
||||||
}
|
}
|
||||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user