mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-26 23:47:57 -05:00
Compare commits
245 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
270e2531e2 | ||
|
|
959a1aebe9 | ||
|
|
2217fe6948 | ||
|
|
96abf56a87 | ||
|
|
5731a8f693 | ||
|
|
ff722b6a52 | ||
|
|
9271114408 | ||
|
|
ebfdd44142 | ||
|
|
6ed4eb34bd | ||
|
|
9372571370 | ||
|
|
215c539920 | ||
|
|
7c7da2024e | ||
|
|
f55a3ca008 | ||
|
|
726b36de4d | ||
|
|
abd00ff1df | ||
|
|
7b966f6962 | ||
|
|
c0e955d5ef | ||
|
|
bc6f53c8ea | ||
|
|
cefab86ce1 | ||
|
|
249a2f3b59 | ||
|
|
0e9f2c7681 | ||
|
|
d25c32ff45 | ||
|
|
642a500f87 | ||
|
|
0e2469db64 | ||
|
|
9aa4ef70af | ||
|
|
1812fc2c7c | ||
|
|
f9849abb7b | ||
|
|
9cfe8ee6ca | ||
|
|
44e2cef18c | ||
|
|
4dc29affc3 | ||
|
|
2df38706f7 | ||
|
|
f30e9dae6f | ||
|
|
50843e5102 | ||
|
|
a13b00d520 | ||
|
|
b5ebe3db23 | ||
|
|
40f3e4503b | ||
|
|
0d93243b66 | ||
|
|
59c3845d21 | ||
|
|
a3ee3c2881 | ||
|
|
e971d34948 | ||
|
|
2b3f67fb99 | ||
|
|
4509b8c8eb | ||
|
|
2e40bebd7d | ||
|
|
dfc4121ab0 | ||
|
|
3648607d4d | ||
|
|
b22c35f841 | ||
|
|
2795690199 | ||
|
|
b1f92343cf | ||
|
|
9e1d657f60 | ||
|
|
389761355d | ||
|
|
69054afaa0 | ||
|
|
aacdcea1e1 | ||
|
|
0beb3bf437 | ||
|
|
e925b57f7f | ||
|
|
5deaa06d78 | ||
|
|
eda62975ba | ||
|
|
d91e02db29 | ||
|
|
cd604d03b1 | ||
|
|
d5cd569319 | ||
|
|
a58f51a8ce | ||
|
|
d24c10ddf5 | ||
|
|
a12391f0ab | ||
|
|
60f1d8117d | ||
|
|
20b6f28cb5 | ||
|
|
9a1fa89f6f | ||
|
|
2a294f4f85 | ||
|
|
0938c84929 | ||
|
|
99cc6a6425 | ||
|
|
0025825d5c | ||
|
|
81b6833118 | ||
|
|
a51e76d44d | ||
|
|
755a7338e9 | ||
|
|
56732a5365 | ||
|
|
dd3b032b21 | ||
|
|
dd25792864 | ||
|
|
6979ab4450 | ||
|
|
4b31207f91 | ||
|
|
84a847a838 | ||
|
|
6900a68b9d | ||
|
|
743644c4e9 | ||
|
|
e9e380dbe6 | ||
|
|
515dfceb73 | ||
|
|
3941906d72 | ||
|
|
6407d15fe0 | ||
|
|
be84fb317e | ||
|
|
3af010c1f5 | ||
|
|
714bb2ba50 | ||
|
|
2e5360f0ba | ||
|
|
258775ff3f | ||
|
|
82318ffab7 | ||
|
|
901572e7bb | ||
|
|
cfa938360a | ||
|
|
80017ce9fd | ||
|
|
c67972a327 | ||
|
|
57ee150d3c | ||
|
|
57302e1b5c | ||
|
|
09dbc67914 | ||
|
|
b768362eae | ||
|
|
04a32533cb | ||
|
|
1ad2135a3f | ||
|
|
643ae09b2b | ||
|
|
8391e43b03 | ||
|
|
8a54eda4a0 | ||
|
|
e0406378cb | ||
|
|
e1299331cc | ||
|
|
248b336867 | ||
|
|
b7d96ae447 | ||
|
|
8ab2af1c5d | ||
|
|
2d459bb2cf | ||
|
|
aeb0d2a82b | ||
|
|
f50dab94a4 | ||
|
|
efa5cefa23 | ||
|
|
2e4a97fde7 | ||
|
|
2f241806fa | ||
|
|
e417f60a36 | ||
|
|
b00f2bd908 | ||
|
|
220cda42e7 | ||
|
|
f992a7ec64 | ||
|
|
c54c45df33 | ||
|
|
a8b9e187e6 | ||
|
|
53f252e56f | ||
|
|
2827bc8904 | ||
|
|
98a775fc5a | ||
|
|
f28a729d36 | ||
|
|
00a6a4bf50 | ||
|
|
fdefa7c3bf | ||
|
|
244862299f | ||
|
|
4decf9d3b7 | ||
|
|
83f538d304 | ||
|
|
9e0e06e436 | ||
|
|
f27ac279b2 | ||
|
|
ed03fd2451 | ||
|
|
ccb60ae367 | ||
|
|
6ad541c199 | ||
|
|
9606acda26 | ||
|
|
9abb9e376d | ||
|
|
f93498bfe3 | ||
|
|
a13e1f27bb | ||
|
|
c7c1b4505b | ||
|
|
d9e0f1aedf | ||
|
|
d8a0124b68 | ||
|
|
79e0a8fba7 | ||
|
|
8497987967 | ||
|
|
717fefd2c0 | ||
|
|
066cae8e33 | ||
|
|
9083574a77 | ||
|
|
6b1ab9c777 | ||
|
|
3be7c87c8e | ||
|
|
8694d3206b | ||
|
|
f67f3805c6 | ||
|
|
612dd41b4b | ||
|
|
13378a482d | ||
|
|
352b498c23 | ||
|
|
3a652cfb70 | ||
|
|
93e9ce31ba | ||
|
|
69ed7767b2 | ||
|
|
6fcaa8d551 | ||
|
|
15ece43463 | ||
|
|
25f5f0ed14 | ||
|
|
de66e5b405 | ||
|
|
73c671b7c0 | ||
|
|
4994684690 | ||
|
|
6c757773f7 | ||
|
|
2d0af587d5 | ||
|
|
c7891dc448 | ||
|
|
95ae8335a1 | ||
|
|
2fa5170f28 | ||
|
|
123a32ff9b | ||
|
|
41620352e8 | ||
|
|
54eea8ddae | ||
|
|
bcc237c693 | ||
|
|
65dc273e12 | ||
|
|
7bb4853903 | ||
|
|
f9917d4064 | ||
|
|
0f9f0d9eae | ||
|
|
498aeaac3a | ||
|
|
9534969c2d | ||
|
|
b120bb8a66 | ||
|
|
f8a51f0882 | ||
|
|
7529fdf878 | ||
|
|
f1aacd92ad | ||
|
|
b1b426427c | ||
|
|
0683e5f55b | ||
|
|
5c81441f83 | ||
|
|
57bc74cd23 | ||
|
|
1cecd4ba2e | ||
|
|
7a4bd639fb | ||
|
|
87e6a46808 | ||
|
|
a2e30df51f | ||
|
|
c8e759c067 | ||
|
|
6c9074169a | ||
|
|
1375da2065 | ||
|
|
d5d72a13f6 | ||
|
|
a1ba324166 | ||
|
|
b0139c47be | ||
|
|
80b0ef600d | ||
|
|
f3128b562d | ||
|
|
6734dec55c | ||
|
|
b9314ac678 | ||
|
|
e319326c30 | ||
|
|
5474446f62 | ||
|
|
d53a617bc8 | ||
|
|
9076fae6f6 | ||
|
|
5d4a97cdc4 | ||
|
|
bbe745f487 | ||
|
|
47360c036d | ||
|
|
e69df2abbc | ||
|
|
88d49acdad | ||
|
|
01a914c390 | ||
|
|
0b42b8ee49 | ||
|
|
c598576683 | ||
|
|
b126eed028 | ||
|
|
3020a116cf | ||
|
|
88b9ea2f2d | ||
|
|
159c04c4b1 | ||
|
|
fad0f021ed | ||
|
|
52f21dcab1 | ||
|
|
a6b89ca4c5 | ||
|
|
650c00cf66 | ||
|
|
089edf934e | ||
|
|
efe2b19e24 | ||
|
|
c41dc9a6db | ||
|
|
707cb78dbc | ||
|
|
fc0d97d8e7 | ||
|
|
1494a15a6e | ||
|
|
ac0de2a05e | ||
|
|
3cc80b6a24 | ||
|
|
38b04be6ba | ||
|
|
0c52d443b2 | ||
|
|
aa0ebac50e | ||
|
|
debebf6ee0 | ||
|
|
9034288e7c | ||
|
|
19ee02ced4 | ||
|
|
33723d7412 | ||
|
|
a01a67e34a | ||
|
|
ecdb510513 | ||
|
|
0b08bb3c4a | ||
|
|
22e5dbf83d | ||
|
|
3b33648267 | ||
|
|
8709518cd7 | ||
|
|
3da1dff4d8 | ||
|
|
6aa544b322 | ||
|
|
bd993b4e4d | ||
|
|
4f7b66d64e | ||
|
|
df90fc5361 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -328,3 +328,8 @@ ASALocalRun/
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
|
||||
# manually ignored files
|
||||
/__TODO.txt
|
||||
/DataLayer/LibationContext.db
|
||||
|
||||
40
AaxDecrypter/AAXChapters.cs
Normal file
40
AaxDecrypter/AAXChapters.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Diagnostics;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AAXChapters : Chapters
|
||||
{
|
||||
public AAXChapters(string file)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = DecryptSupportLibraries.ffprobePath,
|
||||
Arguments = "-loglevel panic -show_chapters -print_format xml \"" + file + "\""
|
||||
};
|
||||
var xml = info.RunHidden().Output;
|
||||
|
||||
var xmlDocument = new System.Xml.XmlDocument();
|
||||
xmlDocument.LoadXml(xml);
|
||||
var chaptersXml = xmlDocument.SelectNodes("/ffprobe/chapters/chapter")
|
||||
.Cast<System.Xml.XmlNode>()
|
||||
.Where(n => n.Name == "chapter");
|
||||
|
||||
foreach (var cnode in chaptersXml)
|
||||
{
|
||||
double startTime = double.Parse(cnode.Attributes["start_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture);
|
||||
double endTime = double.Parse(cnode.Attributes["end_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture);
|
||||
|
||||
string chapterTitle = cnode.ChildNodes
|
||||
.Cast<System.Xml.XmlNode>()
|
||||
.Where(childnode => childnode.Attributes["key"].Value == "title")
|
||||
.Select(childnode => childnode.Attributes["value"].Value)
|
||||
.FirstOrDefault();
|
||||
|
||||
AddChapter(new Chapter(startTime, endTime, chapterTitle));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -62,9 +62,10 @@ namespace AaxDecrypter
|
||||
public Tags tags { get; private set; }
|
||||
public EncodingInfo encodingInfo { get; private set; }
|
||||
|
||||
public static async Task<AaxToM4bConverter> CreateAsync(string inputFile, string decryptKey)
|
||||
public static async Task<AaxToM4bConverter> CreateAsync(string inputFile, string decryptKey, Chapters chapters = null)
|
||||
{
|
||||
var converter = new AaxToM4bConverter(inputFile, decryptKey);
|
||||
converter.chapters = chapters ?? new AAXChapters(inputFile);
|
||||
await converter.prelimProcessing();
|
||||
converter.printPrelim();
|
||||
|
||||
@@ -72,8 +73,9 @@ namespace AaxDecrypter
|
||||
}
|
||||
private AaxToM4bConverter(string inputFile, string decryptKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentNullException(nameof(inputFile), "Input file may not be null or whitespace");
|
||||
if (!File.Exists(inputFile)) throw new ArgumentNullException(nameof(inputFile), "File does not exist");
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(inputFile, nameof(inputFile));
|
||||
if (!File.Exists(inputFile))
|
||||
throw new ArgumentNullException(nameof(inputFile), "File does not exist");
|
||||
|
||||
steps = new StepSequence
|
||||
{
|
||||
@@ -89,53 +91,43 @@ namespace AaxDecrypter
|
||||
["End: Create Nfo"] = End_CreateNfo
|
||||
};
|
||||
|
||||
this.inputFileName = inputFile;
|
||||
inputFileName = inputFile;
|
||||
this.decryptKey = decryptKey;
|
||||
}
|
||||
|
||||
private async Task prelimProcessing()
|
||||
{
|
||||
this.tags = new Tags(this.inputFileName);
|
||||
this.encodingInfo = new EncodingInfo(this.inputFileName);
|
||||
this.chapters = new Chapters(this.inputFileName, this.tags.duration.TotalSeconds);
|
||||
tags = new Tags(inputFileName);
|
||||
encodingInfo = new EncodingInfo(inputFileName);
|
||||
|
||||
var defaultFilename = Path.Combine(
|
||||
Path.GetDirectoryName(this.inputFileName),
|
||||
getASCIITag(this.tags.author),
|
||||
getASCIITag(this.tags.title) + ".m4b"
|
||||
Path.GetDirectoryName(inputFileName),
|
||||
PathLib.ToPathSafeString(tags.author),
|
||||
PathLib.ToPathSafeString(tags.title) + ".m4b"
|
||||
);
|
||||
SetOutputFilename(defaultFilename);
|
||||
|
||||
// set default name
|
||||
SetOutputFilename(defaultFilename);
|
||||
|
||||
await Task.Run(() => saveCover(inputFileName));
|
||||
}
|
||||
private string getASCIITag(string property)
|
||||
{
|
||||
foreach (char ch in new string(Path.GetInvalidFileNameChars()) + new string(Path.GetInvalidPathChars()))
|
||||
property = property.Replace(ch.ToString(), "");
|
||||
return property;
|
||||
}
|
||||
|
||||
private void saveCover(string aaxFile)
|
||||
{
|
||||
using var file = TagLib.File.Create(aaxFile, "audio/mp4", TagLib.ReadStyle.Average);
|
||||
this.coverBytes = file.Tag.Pictures[0].Data.Data;
|
||||
coverBytes = file.Tag.Pictures[0].Data.Data;
|
||||
}
|
||||
|
||||
private void printPrelim()
|
||||
{
|
||||
Console.WriteLine("Audible Book ID = " + tags.id);
|
||||
Console.WriteLine($"Audible Book ID = {tags.id}");
|
||||
|
||||
Console.WriteLine("Book: " + tags.title);
|
||||
Console.WriteLine("Author: " + tags.author);
|
||||
Console.WriteLine("Narrator: " + tags.narrator);
|
||||
Console.WriteLine("Year: " + tags.year);
|
||||
Console.WriteLine("Total Time: "
|
||||
+ tags.duration.GetTotalTimeFormatted()
|
||||
+ " in " + chapters.Count() + " chapters");
|
||||
Console.WriteLine("WARNING-Source is "
|
||||
+ encodingInfo.originalBitrate + " kbits @ "
|
||||
+ encodingInfo.sampleRate + "Hz, "
|
||||
+ encodingInfo.channels + " channels");
|
||||
Console.WriteLine($"Book: {tags.title}");
|
||||
Console.WriteLine($"Author: {tags.author}");
|
||||
Console.WriteLine($"Narrator: {tags.narrator}");
|
||||
Console.WriteLine($"Year: {tags.year}");
|
||||
Console.WriteLine($"Total Time: {tags.duration.GetTotalTimeFormatted()} in {chapters.Count} chapters");
|
||||
Console.WriteLine($"WARNING-Source is {encodingInfo.originalBitrate} kbits @ {encodingInfo.sampleRate}Hz, {encodingInfo.channels} channels");
|
||||
}
|
||||
|
||||
public bool Run()
|
||||
@@ -156,21 +148,19 @@ namespace AaxDecrypter
|
||||
|
||||
public void SetOutputFilename(string outFileName)
|
||||
{
|
||||
this.outputFileName = outFileName;
|
||||
outputFileName = PathLib.ReplaceExtension(outFileName, ".m4b");
|
||||
outDir = Path.GetDirectoryName(outputFileName);
|
||||
|
||||
if (Path.GetExtension(this.outputFileName) != ".m4b")
|
||||
this.outputFileName = outputFileWithNewExt(".m4b");
|
||||
|
||||
this.outDir = Path.GetDirectoryName(this.outputFileName);
|
||||
if (File.Exists(outputFileName))
|
||||
File.Delete(outputFileName);
|
||||
}
|
||||
|
||||
private string outputFileWithNewExt(string extension)
|
||||
=> Path.Combine(this.outDir, Path.GetFileNameWithoutExtension(this.outputFileName) + '.' + extension.Trim('.'));
|
||||
private string outputFileWithNewExt(string extension) => PathLib.ReplaceExtension(outputFileName, extension);
|
||||
|
||||
public bool Step1_CreateDir()
|
||||
{
|
||||
ProcessRunner.WorkingDir = this.outDir;
|
||||
Directory.CreateDirectory(this.outDir);
|
||||
ProcessRunner.WorkingDir = outDir;
|
||||
Directory.CreateDirectory(outDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -178,7 +168,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
DecryptProgressUpdate?.Invoke(this, 0);
|
||||
|
||||
var tempRipFile = Path.Combine(this.outDir, "funny.aac");
|
||||
var tempRipFile = Path.Combine(outDir, "funny.aac");
|
||||
|
||||
var fail = "WARNING-Decrypt failure. ";
|
||||
|
||||
@@ -193,7 +183,7 @@ namespace AaxDecrypter
|
||||
if (returnCode == -99)
|
||||
{
|
||||
Console.WriteLine($"{fail}Incorrect decrypt key: {decryptKey}");
|
||||
this.decryptKey = null;
|
||||
decryptKey = null;
|
||||
returnCode = getKey_decrypt(tempRipFile);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +222,7 @@ namespace AaxDecrypter
|
||||
|
||||
Console.WriteLine("Cracking activation bytes");
|
||||
var activation_bytes = BytesCracker.GetActivationBytes(checksum);
|
||||
this.decryptKey = activation_bytes;
|
||||
decryptKey = activation_bytes;
|
||||
Console.WriteLine("Activation bytes cracked. Decrypt key: " + activation_bytes);
|
||||
}
|
||||
|
||||
@@ -243,10 +233,10 @@ namespace AaxDecrypter
|
||||
Console.WriteLine("Decrypting with key " + decryptKey);
|
||||
|
||||
var returnCode = 100;
|
||||
var thread = new Thread(() => returnCode = this.ngDecrypt());
|
||||
var thread = new Thread(() => returnCode = ngDecrypt());
|
||||
thread.Start();
|
||||
|
||||
double fileLen = new FileInfo(this.inputFileName).Length;
|
||||
double fileLen = new FileInfo(inputFileName).Length;
|
||||
while (thread.IsAlive && returnCode == 100)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
@@ -266,34 +256,35 @@ namespace AaxDecrypter
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = DecryptSupportLibraries.mp4trackdumpPath,
|
||||
Arguments = "-c " + this.encodingInfo.channels + " -r " + this.encodingInfo.sampleRate + " \"" + this.inputFileName + "\""
|
||||
Arguments = "-c " + encodingInfo.channels + " -r " + encodingInfo.sampleRate + " \"" + inputFileName + "\""
|
||||
};
|
||||
info.EnvironmentVariables["VARIABLE"] = decryptKey;
|
||||
|
||||
var (output, exitCode) = info.RunHidden();
|
||||
var result = info.RunHidden();
|
||||
|
||||
// bad checksum -- bad decrypt key
|
||||
if (output.Contains("checksums mismatch, aborting!"))
|
||||
if (result.Output.Contains("checksums mismatch, aborting!"))
|
||||
return -99;
|
||||
|
||||
return exitCode;
|
||||
return result.ExitCode;
|
||||
}
|
||||
|
||||
// temp file names for steps 3, 4, 5
|
||||
string tempChapsPath => Path.Combine(this.outDir, "tempChaps.mp4");
|
||||
// temp file names for steps 3, 4, 5
|
||||
string tempChapsGuid { get; } = Guid.NewGuid().ToString().ToUpper().Replace("-", "");
|
||||
string tempChapsPath => Path.Combine(outDir, $"tempChaps_{tempChapsGuid}.mp4");
|
||||
string mp4_file => outputFileWithNewExt(".mp4");
|
||||
string ff_txt_file => mp4_file + ".ff.txt";
|
||||
|
||||
public bool Step3_Chapterize()
|
||||
{
|
||||
string str1 = "";
|
||||
if (this.chapters.FirstChapterStart != 0.0)
|
||||
var str1 = "";
|
||||
if (chapters.FirstChapter.StartTime != 0.0)
|
||||
{
|
||||
str1 = " -ss " + this.chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (this.chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " ";
|
||||
str1 = " -ss " + chapters.FirstChapter.StartTime.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + chapters.LastChapter.EndTime.ToString("0.000", CultureInfo.InvariantCulture) + " ";
|
||||
}
|
||||
|
||||
string ffmpegTags = this.tags.GenerateFfmpegTags();
|
||||
string ffmpegChapters = this.chapters.GenerateFfmpegChapters();
|
||||
var ffmpegTags = tags.GenerateFfmpegTags();
|
||||
var ffmpegChapters = chapters.GenerateFfmpegChapters();
|
||||
File.WriteAllText(ff_txt_file, ffmpegTags + ffmpegChapters);
|
||||
|
||||
var tagAndChapterInfo = new ProcessStartInfo
|
||||
@@ -309,8 +300,8 @@ namespace AaxDecrypter
|
||||
public bool Step4_InsertCoverArt()
|
||||
{
|
||||
// save cover image as temp file
|
||||
var coverPath = Path.Combine(this.outDir, "cover-" + Guid.NewGuid() + ".jpg");
|
||||
FileExt.CreateFile(coverPath, this.coverBytes);
|
||||
var coverPath = Path.Combine(outDir, "cover-" + Guid.NewGuid() + ".jpg");
|
||||
FileExt.CreateFile(coverPath, coverBytes);
|
||||
|
||||
var insertCoverArtInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -329,26 +320,26 @@ namespace AaxDecrypter
|
||||
{
|
||||
FileExt.SafeDelete(mp4_file);
|
||||
FileExt.SafeDelete(ff_txt_file);
|
||||
FileExt.SafeMove(tempChapsPath, this.outputFileName);
|
||||
FileExt.SafeMove(tempChapsPath, outputFileName);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Step6_AddTags()
|
||||
{
|
||||
this.tags.AddAppleTags(this.outputFileName);
|
||||
tags.AddAppleTags(outputFileName);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool End_CreateCue()
|
||||
{
|
||||
File.WriteAllText(outputFileWithNewExt(".cue"), this.chapters.GetCuefromChapters(Path.GetFileName(this.outputFileName)));
|
||||
File.WriteAllText(outputFileWithNewExt(".cue"), Cue.CreateContents(Path.GetFileName(outputFileName), chapters));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool End_CreateNfo()
|
||||
{
|
||||
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, this.tags, this.encodingInfo, this.chapters));
|
||||
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateContents(AppName, tags, encodingInfo, chapters));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,7 @@ namespace AaxDecrypter
|
||||
};
|
||||
|
||||
// checksum is in the debug info. ffprobe's debug info is written to stderr, not stdout
|
||||
var readErrorOutput = true;
|
||||
var ffprobeStderr = info.RunHidden(readErrorOutput).Output;
|
||||
var ffprobeStderr = info.RunHidden().Error;
|
||||
|
||||
// example checksum line:
|
||||
// ... [aax] file checksum == 0c527840c4f18517157eb0b4f9d6f9317ce60cd1
|
||||
27
AaxDecrypter/Chapter.cs
Normal file
27
AaxDecrypter/Chapter.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class Chapter
|
||||
{
|
||||
public Chapter(double startTime, double endTime, string title)
|
||||
{
|
||||
StartTime = startTime;
|
||||
EndTime = endTime;
|
||||
Title = title;
|
||||
}
|
||||
/// <summary>
|
||||
/// Chapter start time, in seconds.
|
||||
/// </summary>
|
||||
public double StartTime { get; private set; }
|
||||
/// <summary>
|
||||
/// Chapter end time, in seconds.
|
||||
/// </summary>
|
||||
public double EndTime { get; private set; }
|
||||
public string Title { get; private set; }
|
||||
}
|
||||
}
|
||||
40
AaxDecrypter/Chapters.cs
Normal file
40
AaxDecrypter/Chapters.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public abstract class Chapters
|
||||
{
|
||||
private List<Chapter> _chapterList = new();
|
||||
public int Count => _chapterList.Count;
|
||||
public Chapter FirstChapter => _chapterList[0];
|
||||
public Chapter LastChapter => _chapterList[Count - 1];
|
||||
public IEnumerable<Chapter> ChapterList => _chapterList.AsEnumerable();
|
||||
public IEnumerable<TimeSpan> GetBeginningTimes() => ChapterList.Select(c => TimeSpan.FromSeconds(c.StartTime));
|
||||
protected void AddChapter(Chapter chapter)
|
||||
{
|
||||
_chapterList.Add(chapter);
|
||||
}
|
||||
protected void AddChapters(IEnumerable<Chapter> chapters)
|
||||
{
|
||||
_chapterList.AddRange(chapters);
|
||||
}
|
||||
public string GenerateFfmpegChapters()
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
foreach (Chapter c in ChapterList)
|
||||
{
|
||||
stringBuilder.Append("[CHAPTER]\n");
|
||||
stringBuilder.Append("TIMEBASE=1/1000\n");
|
||||
stringBuilder.Append("START=" + c.StartTime * 1000 + "\n");
|
||||
stringBuilder.Append("END=" + c.EndTime * 1000 + "\n");
|
||||
stringBuilder.Append("title=" + c.Title + "\n");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
60
AaxDecrypter/Cue.cs
Normal file
60
AaxDecrypter/Cue.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class Cue
|
||||
{
|
||||
public static string CreateContents(string filePath, Chapters chapters)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
stringBuilder.AppendLine(GetFileLine(filePath, "MP3"));
|
||||
|
||||
var trackCount = 0;
|
||||
foreach (Chapter c in chapters.ChapterList)
|
||||
{
|
||||
trackCount++;
|
||||
var startTime = TimeSpan.FromSeconds(c.StartTime);
|
||||
|
||||
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
|
||||
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
||||
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss\\:ff}");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath)
|
||||
=> UpdateFileName(cueFileInfo.FullName, audioFilePath);
|
||||
|
||||
public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo)
|
||||
=> UpdateFileName(cueFilePath, audioFileInfo.FullName);
|
||||
|
||||
public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo)
|
||||
=> UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName);
|
||||
|
||||
public static void UpdateFileName(string cueFilePath, string audioFilePath)
|
||||
{
|
||||
var cueContents = File.ReadAllLines(cueFilePath);
|
||||
|
||||
for (var i = 0; i < cueContents.Length; i++)
|
||||
{
|
||||
var line = cueContents[i];
|
||||
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
|
||||
continue;
|
||||
|
||||
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
||||
cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]);
|
||||
break;
|
||||
}
|
||||
|
||||
File.WriteAllLines(cueFilePath, cueContents);
|
||||
}
|
||||
|
||||
private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}";
|
||||
}
|
||||
}
|
||||
54
AaxDecrypter/NFO.cs
Normal file
54
AaxDecrypter/NFO.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class NFO
|
||||
{
|
||||
public static string CreateContents(string ripper, Tags tags, EncodingInfo encodingInfo, Chapters chapters)
|
||||
{
|
||||
var _hours = (int)tags.duration.TotalHours;
|
||||
var myDuration
|
||||
= (_hours > 0 ? _hours + " hours, " : "")
|
||||
+ tags.duration.Minutes + " minutes, "
|
||||
+ tags.duration.Seconds + " seconds";
|
||||
|
||||
var header
|
||||
= "General Information\r\n"
|
||||
+ "===================\r\n"
|
||||
+ $" Title: {tags.title}\r\n"
|
||||
+ $" Author: {tags.author}\r\n"
|
||||
+ $" Read By: {tags.narrator}\r\n"
|
||||
+ $" Copyright: {tags.year}\r\n"
|
||||
+ $" Audiobook Copyright: {tags.year}\r\n";
|
||||
if (tags.genre != "")
|
||||
header += $" Genre: {tags.genre}\r\n";
|
||||
|
||||
var s
|
||||
= header
|
||||
+ $" Publisher: {tags.publisher}\r\n"
|
||||
+ $" Duration: {myDuration}\r\n"
|
||||
+ $" Chapters: {chapters.Count}\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Media Information\r\n"
|
||||
+ "=================\r\n"
|
||||
+ " Source Format: Audible AAX\r\n"
|
||||
+ $" Source Sample Rate: {encodingInfo.sampleRate} Hz\r\n"
|
||||
+ $" Source Channels: {encodingInfo.channels}\r\n"
|
||||
+ $" Source Bitrate: {encodingInfo.originalBitrate} kbits\r\n"
|
||||
+ "\r\n"
|
||||
+ " Lossless Encode: Yes\r\n"
|
||||
+ " Encoded Codec: AAC / M4B\r\n"
|
||||
+ $" Encoded Sample Rate: {encodingInfo.sampleRate} Hz\r\n"
|
||||
+ $" Encoded Channels: {encodingInfo.channels}\r\n"
|
||||
+ $" Encoded Bitrate: {encodingInfo.originalBitrate} kbits\r\n"
|
||||
+ "\r\n"
|
||||
+ $" Ripper: {ripper}\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Book Description\r\n"
|
||||
+ "================\r\n"
|
||||
+ tags.comments;
|
||||
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
AaxDecrypter/Tags.cs
Normal file
67
AaxDecrypter/Tags.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using TagLib;
|
||||
using TagLib.Mpeg4;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class Tags
|
||||
{
|
||||
public string title { get; }
|
||||
public string album { get; }
|
||||
public string author { get; }
|
||||
public string comments { get; }
|
||||
public string narrator { get; }
|
||||
public string year { get; }
|
||||
public string publisher { get; }
|
||||
public string id { get; }
|
||||
public string genre { get; }
|
||||
public TimeSpan duration { get; }
|
||||
|
||||
// input file
|
||||
public Tags(string file)
|
||||
{
|
||||
using var tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
|
||||
title = tagLibFile.Tag.Title.Replace(" (Unabridged)", "");
|
||||
album = tagLibFile.Tag.Album.Replace(" (Unabridged)", "");
|
||||
author = tagLibFile.Tag.FirstPerformer ?? "[unknown]";
|
||||
year = tagLibFile.Tag.Year.ToString();
|
||||
comments = tagLibFile.Tag.Comment ?? "";
|
||||
duration = tagLibFile.Properties.Duration;
|
||||
genre = tagLibFile.Tag.FirstGenre ?? "";
|
||||
|
||||
var tag = tagLibFile.GetTag(TagTypes.Apple, true);
|
||||
publisher = tag.Publisher ?? "";
|
||||
narrator = string.IsNullOrWhiteSpace(tagLibFile.Tag.FirstComposer) ? tag.Narrator : tagLibFile.Tag.FirstComposer;
|
||||
comments = !string.IsNullOrWhiteSpace(tag.LongDescription) ? tag.LongDescription : tag.Description;
|
||||
id = tag.AudibleCDEK;
|
||||
}
|
||||
|
||||
// my best guess of what this step is doing:
|
||||
// re-publish the data we read from the input file => output file
|
||||
public void AddAppleTags(string file)
|
||||
{
|
||||
using var tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
|
||||
var tag = (AppleTag)tagLibFile.GetTag(TagTypes.Apple, true);
|
||||
tag.Publisher = publisher;
|
||||
tag.LongDescription = comments;
|
||||
tag.Description = comments;
|
||||
tagLibFile.Save();
|
||||
}
|
||||
|
||||
public string GenerateFfmpegTags()
|
||||
=> $";FFMETADATA1"
|
||||
+ $"\nmajor_brand=aax"
|
||||
+ $"\nminor_version=1"
|
||||
+ $"\ncompatible_brands=aax M4B mp42isom"
|
||||
+ $"\ndate={year}"
|
||||
+ $"\ngenre={genre}"
|
||||
+ $"\ntitle={title}"
|
||||
+ $"\nartist={author}"
|
||||
+ $"\nalbum={album}"
|
||||
+ $"\ncomposer={narrator}"
|
||||
+ $"\ncomment={comments.Truncate(254)}"
|
||||
+ $"\ndescription={comments}"
|
||||
+ $"\n";
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Dinah.Core.Diagnostics;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class Chapters
|
||||
{
|
||||
private List<double> markers { get; }
|
||||
|
||||
public double FirstChapterStart => markers[0];
|
||||
public double LastChapterStart => markers[markers.Count - 1];
|
||||
|
||||
public Chapters(string file, double totalTime)
|
||||
{
|
||||
this.markers = getAAXChapters(file);
|
||||
|
||||
// add end time
|
||||
this.markers.Add(totalTime);
|
||||
}
|
||||
|
||||
private static List<double> getAAXChapters(string file)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = DecryptSupportLibraries.ffprobePath,
|
||||
Arguments = "-loglevel panic -show_chapters -print_format xml \"" + file + "\""
|
||||
};
|
||||
var xml = info.RunHidden().Output;
|
||||
|
||||
var xmlDocument = new System.Xml.XmlDocument();
|
||||
xmlDocument.LoadXml(xml);
|
||||
var chapters = xmlDocument.SelectNodes("/ffprobe/chapters/chapter")
|
||||
.Cast<System.Xml.XmlNode>()
|
||||
.Select(xmlNode => double.Parse(xmlNode.Attributes["start_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
return chapters;
|
||||
}
|
||||
|
||||
// subtract 1 b/c end time marker is a real entry but isn't a real chapter
|
||||
public int Count() => this.markers.Count - 1;
|
||||
|
||||
public string GetCuefromChapters(string fileName)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
if (fileName != "")
|
||||
{
|
||||
stringBuilder.Append("FILE \"" + fileName + "\" MP4\n");
|
||||
}
|
||||
|
||||
for (var i = 0; i < Count(); i++)
|
||||
{
|
||||
var chapter = i + 1;
|
||||
|
||||
var timeSpan = TimeSpan.FromSeconds(this.markers[i]);
|
||||
var minutes = Math.Floor(timeSpan.TotalMinutes).ToString();
|
||||
var seconds = timeSpan.Seconds.ToString("D2");
|
||||
var milliseconds = (timeSpan.Milliseconds / 10).ToString("D2");
|
||||
string str = minutes + ":" + seconds + ":" + milliseconds;
|
||||
|
||||
stringBuilder.Append("TRACK " + chapter + " AUDIO\n");
|
||||
stringBuilder.Append(" TITLE \"Chapter " + chapter.ToString("D2") + "\"\n");
|
||||
stringBuilder.Append(" INDEX 01 " + str + "\n");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
public string GenerateFfmpegChapters()
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
for (var i = 0; i < Count(); i++)
|
||||
{
|
||||
var chapter = i + 1;
|
||||
|
||||
var start = this.markers[i] * 1000.0;
|
||||
var end = this.markers[i + 1] * 1000.0;
|
||||
var chapterName = chapter.ToString("D3");
|
||||
|
||||
stringBuilder.Append("[CHAPTER]\n");
|
||||
stringBuilder.Append("TIMEBASE=1/1000\n");
|
||||
stringBuilder.Append("START=" + start + "\n");
|
||||
stringBuilder.Append("END=" + end + "\n");
|
||||
stringBuilder.Append("title=" + chapterName + "\n");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class NFO
|
||||
{
|
||||
public static string CreateNfoContents(string ripper, Tags tags, EncodingInfo encodingInfo, Chapters chapters)
|
||||
{
|
||||
int _hours = (int)tags.duration.TotalHours;
|
||||
string myDuration
|
||||
= (_hours > 0 ? _hours + " hours, " : "")
|
||||
+ tags.duration.Minutes + " minutes, "
|
||||
+ tags.duration.Seconds + " seconds";
|
||||
|
||||
string str4
|
||||
= "General Information\r\n"
|
||||
+ "===================\r\n"
|
||||
+ " Title: " + tags.title + "\r\n"
|
||||
+ " Author: " + tags.author + "\r\n"
|
||||
+ " Read By: " + tags.narrator + "\r\n"
|
||||
+ " Copyright: " + tags.year + "\r\n"
|
||||
+ " Audiobook Copyright: " + tags.year + "\r\n";
|
||||
if (tags.genre != "")
|
||||
{
|
||||
str4 = str4 + " Genre: " + tags.genre + "\r\n";
|
||||
}
|
||||
|
||||
string s
|
||||
= str4
|
||||
+ " Publisher: " + tags.publisher + "\r\n"
|
||||
+ " Duration: " + myDuration + "\r\n"
|
||||
+ " Chapters: " + chapters.Count() + "\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Media Information\r\n"
|
||||
+ "=================\r\n"
|
||||
+ " Source Format: Audible AAX\r\n"
|
||||
+ " Source Sample Rate: " + encodingInfo.sampleRate + " Hz\r\n"
|
||||
+ " Source Channels: " + encodingInfo.channels + "\r\n"
|
||||
+ " Source Bitrate: " + encodingInfo.originalBitrate + " kbits\r\n"
|
||||
+ "\r\n"
|
||||
+ " Lossless Encode: Yes\r\n"
|
||||
+ " Encoded Codec: AAC / M4B\r\n"
|
||||
+ " Encoded Sample Rate: " + encodingInfo.sampleRate + " Hz\r\n"
|
||||
+ " Encoded Channels: " + encodingInfo.channels + "\r\n"
|
||||
+ " Encoded Bitrate: " + encodingInfo.originalBitrate + " kbits\r\n"
|
||||
+ "\r\n"
|
||||
+ " Ripper: " + ripper + "\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Book Description\r\n"
|
||||
+ "================\r\n"
|
||||
+ tags.comments;
|
||||
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using TagLib;
|
||||
using TagLib.Mpeg4;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class Tags
|
||||
{
|
||||
public string title { get; }
|
||||
public string album { get; }
|
||||
public string author { get; }
|
||||
public string comments { get; }
|
||||
public string narrator { get; }
|
||||
public string year { get; }
|
||||
public string publisher { get; }
|
||||
public string id { get; }
|
||||
public string genre { get; }
|
||||
public TimeSpan duration { get; }
|
||||
|
||||
public Tags(string file)
|
||||
{
|
||||
using TagLib.File tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
|
||||
this.title = tagLibFile.Tag.Title.Replace(" (Unabridged)", "");
|
||||
this.album = tagLibFile.Tag.Album.Replace(" (Unabridged)", "");
|
||||
this.author = tagLibFile.Tag.FirstPerformer;
|
||||
this.year = tagLibFile.Tag.Year.ToString();
|
||||
this.comments = tagLibFile.Tag.Comment;
|
||||
this.duration = tagLibFile.Properties.Duration;
|
||||
this.genre = tagLibFile.Tag.FirstGenre;
|
||||
|
||||
var tag = tagLibFile.GetTag(TagTypes.Apple, true);
|
||||
this.publisher = tag.Publisher;
|
||||
this.narrator = string.IsNullOrWhiteSpace(tagLibFile.Tag.FirstComposer) ? tag.Narrator : tagLibFile.Tag.FirstComposer;
|
||||
this.comments = !string.IsNullOrWhiteSpace(tag.LongDescription) ? tag.LongDescription : tag.Description;
|
||||
this.id = tag.AudibleCDEK;
|
||||
}
|
||||
|
||||
public void AddAppleTags(string file)
|
||||
{
|
||||
using var file1 = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
|
||||
var tag = (AppleTag)file1.GetTag(TagTypes.Apple, true);
|
||||
tag.Publisher = this.publisher;
|
||||
tag.LongDescription = this.comments;
|
||||
tag.Description = this.comments;
|
||||
file1.Save();
|
||||
}
|
||||
|
||||
public string GenerateFfmpegTags()
|
||||
{
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
stringBuilder.Append(";FFMETADATA1\n");
|
||||
stringBuilder.Append("major_brand=aax\n");
|
||||
stringBuilder.Append("minor_version=1\n");
|
||||
stringBuilder.Append("compatible_brands=aax M4B mp42isom\n");
|
||||
stringBuilder.Append("date=" + this.year + "\n");
|
||||
stringBuilder.Append("genre=" + this.genre + "\n");
|
||||
stringBuilder.Append("title=" + this.title + "\n");
|
||||
stringBuilder.Append("artist=" + this.author + "\n");
|
||||
stringBuilder.Append("album=" + this.album + "\n");
|
||||
stringBuilder.Append("composer=" + this.narrator + "\n");
|
||||
stringBuilder.Append("comment=" + this.comments.Truncate(254) + "\n");
|
||||
stringBuilder.Append("description=" + this.comments + "\n");
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="27.0.2" />
|
||||
<PackageReference Include="NPOI" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
|
||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
|
||||
|
||||
16
ApplicationServices/DbContexts.cs
Normal file
16
ApplicationServices/DbContexts.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using DataLayer;
|
||||
using FileManager;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class DbContexts
|
||||
{
|
||||
//// idea for future command/query separation
|
||||
// public static LibationContext GetCommandContext() { }
|
||||
// public static LibationContext GetQueryContext() { }
|
||||
|
||||
public static LibationContext GetContext()
|
||||
=> LibationContext.Create(SqliteStorage.ConnectionString);
|
||||
}
|
||||
}
|
||||
123
ApplicationServices/LibraryCommands.cs
Normal file
123
ApplicationServices/LibraryCommands.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using DtoImporterService;
|
||||
using InternalUtilities;
|
||||
using Serilog;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class LibraryCommands
|
||||
{
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts)
|
||||
{
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return (0, 0);
|
||||
|
||||
try
|
||||
{
|
||||
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
var newCount = await importIntoDbAsync(importItems);
|
||||
Log.Logger.Information($"Import: New count {newCount}");
|
||||
|
||||
await Task.Run(() => SearchEngineCommands.FullReIndex());
|
||||
Log.Logger.Information("FullReIndex: success");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.MoveResponseBodyFile(FileManager.Configuration.Instance.LibationFiles);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new {
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePath
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, Account[] accounts)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
var callback = loginCallbackFactoryFunc(account);
|
||||
|
||||
// get APIs in serial, esp b/c of logins
|
||||
var api = await AudibleApiActions.GetApiAsync(callback, account);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(api, account));
|
||||
}
|
||||
|
||||
// import library in parallel
|
||||
var arrayOfLists = await Task.WhenAll(tasks);
|
||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||
return importItems;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(Api api, Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||
{
|
||||
Account = account?.MaskedLogEntry ?? "[null]"
|
||||
});
|
||||
|
||||
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api);
|
||||
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)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryImporter = new LibraryImporter(context);
|
||||
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
|
||||
context.SaveChanges();
|
||||
|
||||
return newCount;
|
||||
}
|
||||
|
||||
public static int UpdateTags(this LibationContext context, Book book, string newTags)
|
||||
{
|
||||
try
|
||||
{
|
||||
book.UserDefinedItem.Tags = newTags;
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
|
||||
if (qtyChanges > 0)
|
||||
SearchEngineCommands.UpdateBookTags(book);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error updating tags");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
268
ApplicationServices/LibraryExporter.cs
Normal file
268
ApplicationServices/LibraryExporter.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using NPOI.XSSF.UserModel;
|
||||
using Serilog;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public class ExportDto
|
||||
{
|
||||
public static string GetName(string fieldName)
|
||||
{
|
||||
var property = typeof(ExportDto).GetProperty(fieldName);
|
||||
var attribute = property.GetCustomAttributes(typeof(NameAttribute), true)[0];
|
||||
var description = (NameAttribute)attribute;
|
||||
var text = description.Names;
|
||||
return text[0];
|
||||
}
|
||||
|
||||
[Name("Account")]
|
||||
public string Account { get; set; }
|
||||
|
||||
[Name("Date Added to library")]
|
||||
public DateTime DateAdded { get; set; }
|
||||
|
||||
[Name("Audible Product Id")]
|
||||
public string AudibleProductId { get; set; }
|
||||
|
||||
[Name("Locale")]
|
||||
public string Locale { get; set; }
|
||||
|
||||
[Name("Title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[Name("Authors")]
|
||||
public string AuthorNames { get; set; }
|
||||
|
||||
[Name("Narrators")]
|
||||
public string NarratorNames { get; set; }
|
||||
|
||||
[Name("Length In Minutes")]
|
||||
public int LengthInMinutes { get; set; }
|
||||
|
||||
[Name("Publisher")]
|
||||
public string Publisher { get; set; }
|
||||
|
||||
[Name("Pdf url")]
|
||||
public string PdfUrl { get; set; }
|
||||
|
||||
[Name("Series Names")]
|
||||
public string SeriesNames { get; set; }
|
||||
|
||||
[Name("Series Order")]
|
||||
public string SeriesOrder { get; set; }
|
||||
|
||||
[Name("Community Rating: Overall")]
|
||||
public float? CommunityRatingOverall { get; set; }
|
||||
|
||||
[Name("Community Rating: Performance")]
|
||||
public float? CommunityRatingPerformance { get; set; }
|
||||
|
||||
[Name("Community Rating: Story")]
|
||||
public float? CommunityRatingStory { get; set; }
|
||||
|
||||
[Name("Cover Id")]
|
||||
public string PictureId { get; set; }
|
||||
|
||||
[Name("Is Abridged?")]
|
||||
public bool IsAbridged { get; set; }
|
||||
|
||||
[Name("Date Published")]
|
||||
public DateTime? DatePublished { get; set; }
|
||||
|
||||
[Name("Categories")]
|
||||
public string CategoriesNames { get; set; }
|
||||
|
||||
[Name("My Rating: Overall")]
|
||||
public float? MyRatingOverall { get; set; }
|
||||
|
||||
[Name("My Rating: Performance")]
|
||||
public float? MyRatingPerformance { get; set; }
|
||||
|
||||
[Name("My Rating: Story")]
|
||||
public float? MyRatingStory { get; set; }
|
||||
|
||||
[Name("My Libation Tags")]
|
||||
public string MyLibationTags { get; set; }
|
||||
}
|
||||
public static class LibToDtos
|
||||
{
|
||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||
=> library.Select(a => new ExportDto
|
||||
{
|
||||
Account = a.Account,
|
||||
DateAdded = a.DateAdded,
|
||||
AudibleProductId = a.Book.AudibleProductId,
|
||||
Locale = a.Book.Locale,
|
||||
Title = a.Book.Title,
|
||||
AuthorNames = a.Book.AuthorNames,
|
||||
NarratorNames = a.Book.NarratorNames,
|
||||
LengthInMinutes = a.Book.LengthInMinutes,
|
||||
Publisher = a.Book.Publisher,
|
||||
PdfUrl = a.Book.Supplements?.FirstOrDefault()?.Url,
|
||||
SeriesNames = a.Book.SeriesNames,
|
||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Index} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
CommunityRatingOverall = a.Book.Rating?.OverallRating,
|
||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
||||
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
||||
PictureId = a.Book.PictureId,
|
||||
IsAbridged = a.Book.IsAbridged,
|
||||
DatePublished = a.Book.DatePublished,
|
||||
CategoriesNames = a.Book.CategoriesNames.Any() ? a.Book.CategoriesNames.Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||
MyLibationTags = a.Book.UserDefinedItem.Tags
|
||||
}).ToList();
|
||||
}
|
||||
public static class LibraryExporter
|
||||
{
|
||||
public static void ToCsv(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
if (!dtos.Any())
|
||||
return;
|
||||
|
||||
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||
|
||||
csv.WriteHeader(typeof(ExportDto));
|
||||
csv.NextRecord();
|
||||
csv.WriteRecords(dtos);
|
||||
}
|
||||
|
||||
public static void ToJson(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
|
||||
System.IO.File.WriteAllText(saveFilePath, json);
|
||||
}
|
||||
|
||||
public static void ToXlsx(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var workbook = new XSSFWorkbook();
|
||||
var sheet = workbook.CreateSheet("Library");
|
||||
|
||||
var detailSubtotalFont = workbook.CreateFont();
|
||||
detailSubtotalFont.IsBold = true;
|
||||
|
||||
var detailSubtotalCellStyle = workbook.CreateCellStyle();
|
||||
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
|
||||
|
||||
// headers
|
||||
var rowIndex = 0;
|
||||
var row = sheet.CreateRow(rowIndex);
|
||||
|
||||
var columns = new[] {
|
||||
nameof (ExportDto.Account),
|
||||
nameof (ExportDto.DateAdded),
|
||||
nameof (ExportDto.AudibleProductId),
|
||||
nameof (ExportDto.Locale),
|
||||
nameof (ExportDto.Title),
|
||||
nameof (ExportDto.AuthorNames),
|
||||
nameof (ExportDto.NarratorNames),
|
||||
nameof (ExportDto.LengthInMinutes),
|
||||
nameof (ExportDto.Publisher),
|
||||
nameof (ExportDto.PdfUrl),
|
||||
nameof (ExportDto.SeriesNames),
|
||||
nameof (ExportDto.SeriesOrder),
|
||||
nameof (ExportDto.CommunityRatingOverall),
|
||||
nameof (ExportDto.CommunityRatingPerformance),
|
||||
nameof (ExportDto.CommunityRatingStory),
|
||||
nameof (ExportDto.PictureId),
|
||||
nameof (ExportDto.IsAbridged),
|
||||
nameof (ExportDto.DatePublished),
|
||||
nameof (ExportDto.CategoriesNames),
|
||||
nameof (ExportDto.MyRatingOverall),
|
||||
nameof (ExportDto.MyRatingPerformance),
|
||||
nameof (ExportDto.MyRatingStory),
|
||||
nameof (ExportDto.MyLibationTags)
|
||||
};
|
||||
var col = 0;
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var cell = row.CreateCell(col++);
|
||||
var name = ExportDto.GetName(c);
|
||||
cell.SetCellValue(name);
|
||||
cell.CellStyle = detailSubtotalCellStyle;
|
||||
}
|
||||
|
||||
var dateFormat = workbook.CreateDataFormat();
|
||||
var dateStyle = workbook.CreateCellStyle();
|
||||
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
|
||||
|
||||
rowIndex++;
|
||||
|
||||
// Add data rows
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
col = 0;
|
||||
|
||||
row = sheet.CreateRow(rowIndex);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||
|
||||
var dateAddedCell = row.CreateCell(col++);
|
||||
dateAddedCell.CellStyle = dateStyle;
|
||||
dateAddedCell.SetCellValue(dto.DateAdded);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||
row.CreateCell(col++).SetCellValue(dto.Publisher);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfUrl);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||
|
||||
col = createCell(row, col, dto.CommunityRatingOverall);
|
||||
col = createCell(row, col, dto.CommunityRatingPerformance);
|
||||
col = createCell(row, col, dto.CommunityRatingStory);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
||||
|
||||
var datePubCell = row.CreateCell(col++);
|
||||
datePubCell.CellStyle = dateStyle;
|
||||
if (dto.DatePublished.HasValue)
|
||||
datePubCell.SetCellValue(dto.DatePublished.Value);
|
||||
else
|
||||
datePubCell.SetCellValue("");
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
|
||||
|
||||
col = createCell(row, col, dto.MyRatingOverall);
|
||||
col = createCell(row, col, dto.MyRatingPerformance);
|
||||
col = createCell(row, col, dto.MyRatingStory);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||
workbook.Write(fileData);
|
||||
}
|
||||
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
|
||||
{
|
||||
if (nullableFloat.HasValue)
|
||||
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
|
||||
else
|
||||
row.CreateCell(col++).SetCellValue("");
|
||||
return col;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
ApplicationServices/SearchEngineCommands.cs
Normal file
56
ApplicationServices/SearchEngineCommands.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class SearchEngineCommands
|
||||
{
|
||||
public static void FullReIndex()
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
engine.CreateNewIndex();
|
||||
}
|
||||
|
||||
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
|
||||
e.Search(searchString)
|
||||
);
|
||||
|
||||
public static void UpdateBookTags(Book book) => performSearchEngineAction_safe(e =>
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
||||
);
|
||||
|
||||
public static void UpdateIsLiberated(Book book) => performSearchEngineAction_safe(e =>
|
||||
e.UpdateIsLiberated(book.AudibleProductId)
|
||||
);
|
||||
|
||||
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
try
|
||||
{
|
||||
action(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex();
|
||||
action(engine);
|
||||
}
|
||||
}
|
||||
|
||||
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> action)
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
try
|
||||
{
|
||||
return action(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex();
|
||||
return action(engine);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using DtoImporterService;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace ApplicationService
|
||||
{
|
||||
public class LibraryIndexer
|
||||
{
|
||||
public async Task<(int totalCount, int newCount)> IndexAsync(ILoginCallback callback)
|
||||
{
|
||||
var audibleApiActions = new AudibleApiActions();
|
||||
var items = await audibleApiActions.GetAllLibraryItemsAsync(callback);
|
||||
var totalCount = items.Count;
|
||||
|
||||
var libImporter = new LibraryImporter();
|
||||
var newCount = await Task.Run(() => libImporter.Import(items));
|
||||
|
||||
await SearchEngineActions.FullReIndexAsync();
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
|
||||
namespace ApplicationService
|
||||
{
|
||||
public static class SearchEngineActions
|
||||
{
|
||||
public static async Task FullReIndexAsync()
|
||||
{
|
||||
var engine = new LibationSearchEngine.SearchEngine();
|
||||
await engine.CreateNewIndexAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static void UpdateBookTags(Book book)
|
||||
{
|
||||
var engine = new LibationSearchEngine.SearchEngine();
|
||||
engine.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
|
||||
}
|
||||
|
||||
public static async Task ProductReIndexAsync(string productId)
|
||||
{
|
||||
var engine = new LibationSearchEngine.SearchEngine();
|
||||
await engine.UpdateBookAsync(productId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AudibleDotCom
|
||||
{
|
||||
public enum AudiblePageType
|
||||
{
|
||||
ProductDetails = 1,
|
||||
|
||||
Library = 2
|
||||
}
|
||||
public static class AudiblePageExt
|
||||
{
|
||||
public static AudiblePage GetAudiblePageRobust(this AudiblePageType audiblePage) => AudiblePage.FromPageType(audiblePage);
|
||||
}
|
||||
|
||||
public abstract partial class AudiblePage : Enumeration<AudiblePage>
|
||||
{
|
||||
// useful for generic classes:
|
||||
// public abstract class PageScraper<T> where T : AudiblePageRobust {
|
||||
// public AudiblePage AudiblePage => AudiblePageRobust.GetAudiblePageFromType(typeof(T));
|
||||
public static AudiblePageType GetAudiblePageFromType(Type audiblePageRobustType)
|
||||
=> (AudiblePageType)GetAll().Single(t => t.GetType() == audiblePageRobustType).Id;
|
||||
|
||||
public AudiblePageType AudiblePageType { get; }
|
||||
|
||||
protected AudiblePage(AudiblePageType audiblePage, string abbreviation) : base((int)audiblePage, abbreviation) => AudiblePageType = audiblePage;
|
||||
|
||||
public static AudiblePage FromPageType(AudiblePageType audiblePage) => FromValue((int)audiblePage);
|
||||
|
||||
/// <summary>For pages which need a param, the param is marked with {0}</summary>
|
||||
protected abstract string Url { get; }
|
||||
public string GetUrl(string id) => string.Format(Url, id);
|
||||
|
||||
public string Abbreviation => DisplayName;
|
||||
}
|
||||
public abstract partial class AudiblePage : Enumeration<AudiblePage>
|
||||
{
|
||||
public static AudiblePage Library { get; } = LibraryPage.Instance;
|
||||
public class LibraryPage : AudiblePage
|
||||
{
|
||||
#region singleton stuff
|
||||
public static LibraryPage Instance { get; } = new LibraryPage();
|
||||
static LibraryPage() { }
|
||||
private LibraryPage() : base(AudiblePageType.Library, "LIB") { }
|
||||
#endregion
|
||||
|
||||
protected override string Url => "http://www.audible.com/lib";
|
||||
}
|
||||
}
|
||||
public abstract partial class AudiblePage : Enumeration<AudiblePage>
|
||||
{
|
||||
public static AudiblePage Product { get; } = ProductDetailPage.Instance;
|
||||
public class ProductDetailPage : AudiblePage
|
||||
{
|
||||
#region singleton stuff
|
||||
public static ProductDetailPage Instance { get; } = new ProductDetailPage();
|
||||
static ProductDetailPage() { }
|
||||
private ProductDetailPage() : base(AudiblePageType.ProductDetails, "PD") { }
|
||||
#endregion
|
||||
|
||||
protected override string Url => "http://www.audible.com/pd/{0}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using FileManager;
|
||||
|
||||
namespace AudibleDotCom
|
||||
{
|
||||
public class AudiblePageSource
|
||||
{
|
||||
public AudiblePageType AudiblePage { get; }
|
||||
public string Source { get; }
|
||||
public string PageId { get; }
|
||||
|
||||
public AudiblePageSource(AudiblePageType audiblePage, string source, string pageId)
|
||||
{
|
||||
AudiblePage = audiblePage;
|
||||
Source = source;
|
||||
PageId = pageId;
|
||||
}
|
||||
|
||||
/// <summary>declawed allows local file to safely be reloaded in chrome
|
||||
/// NOTE ABOUT DECLAWED FILES
|
||||
/// making them safer also breaks functionality
|
||||
/// eg: previously hidden parts become visible. this changes how selenium can parse pages.
|
||||
/// hidden elements don't expose .Text property</summary>
|
||||
public AudiblePageSource Declawed() => new AudiblePageSource(AudiblePage, FileUtility.Declaw(Source), PageId);
|
||||
|
||||
public string Serialized() => $"<!-- |{AudiblePage.GetAudiblePageRobust().Abbreviation}|{(PageId ?? "").Trim()}| -->\r\n" + Source;
|
||||
|
||||
public static AudiblePageSource Deserialize(string serializedSource)
|
||||
{
|
||||
var endOfLine1 = serializedSource.IndexOf('\n');
|
||||
|
||||
var parameters = serializedSource
|
||||
.Substring(0, endOfLine1)
|
||||
.Split('|');
|
||||
var abbrev = parameters[1];
|
||||
var pageId = parameters[2];
|
||||
|
||||
var source = serializedSource.Substring(endOfLine1 + 1);
|
||||
var audiblePage = AudibleDotCom.AudiblePage.FromDisplayName(abbrev).AudiblePageType;
|
||||
|
||||
return new AudiblePageSource(audiblePage, source, pageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Selenium.Support" Version="3.141.0" />
|
||||
<PackageReference Include="Selenium.WebDriver" Version="3.141.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AudibleDotCom\AudibleDotCom.csproj" />
|
||||
<ProjectReference Include="..\CookieMonster\CookieMonster.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="chromedriver.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,184 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleDotCom;
|
||||
using Dinah.Core.Humanizer;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Chrome;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
/// <summary>browser manipulation. web driver access
|
||||
/// browser operators. create and store web driver, browser navigation which can vary depending on whether anon or auth'd
|
||||
///
|
||||
/// this base class: is online. no auth. used for most pages. retain no chrome cookies</summary>
|
||||
public abstract class SeleniumRetriever : IPageRetriever
|
||||
{
|
||||
#region // chrome driver details
|
||||
/*
|
||||
HIDING CHROME CONSOLE WINDOW
|
||||
hiding chrome console window has proven to cause more headaches than it solves. here's how to do it though:
|
||||
// can also use CreateDefaultService() overloads to specify driver path and/or file name
|
||||
var chromeDriverService = ChromeDriverService.CreateDefaultService();
|
||||
chromeDriverService.HideCommandPromptWindow = true;
|
||||
return new ChromeDriver(chromeDriverService, options);
|
||||
|
||||
HEADLESS CHROME
|
||||
this WOULD be how to do headless. but amazon/audible are far too tricksy about their changes and anti-scraping measures
|
||||
which renders 'headless' mode useless
|
||||
var options = new ChromeOptions();
|
||||
options.AddArgument("--headless");
|
||||
|
||||
SPECIFYING DRIVER LOCATION
|
||||
if continues to have trouble finding driver:
|
||||
var driver = new ChromeDriver(@"C:\my\path\to\chromedriver\directory");
|
||||
var chromeDriverService = ChromeDriverService.CreateDefaultService(@"C:\my\path\to\chromedriver\directory");
|
||||
*/
|
||||
#endregion
|
||||
|
||||
protected IWebDriver Driver { get; }
|
||||
Humanizer humanizer { get; } = new Humanizer();
|
||||
|
||||
protected SeleniumRetriever()
|
||||
{
|
||||
Driver = new ChromeDriver(ctorCreateChromeOptions());
|
||||
}
|
||||
|
||||
/// <summary>no auth. retain no chrome cookies</summary>
|
||||
protected virtual ChromeOptions ctorCreateChromeOptions() => new ChromeOptions();
|
||||
|
||||
protected async Task AudibleLinkClickAsync(IWebElement element)
|
||||
{
|
||||
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
|
||||
await humanizer.Wait();
|
||||
|
||||
await Task.Run(() => Driver.Click(element));
|
||||
|
||||
await waitForSpinnerAsync();
|
||||
|
||||
// sometimes these clicks just take a while. add a few more seconds
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
|
||||
By spinnerLocator { get; } = By.Id("library-main-overlay");
|
||||
private async Task waitForSpinnerAsync()
|
||||
{
|
||||
// if loading overlay w/spinner exists: pause, wait for it to end
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
if (Driver.FindElements(spinnerLocator).Count > 0)
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until(ExpectedConditions.InvisibilityOfElementLocated(spinnerLocator));
|
||||
}
|
||||
|
||||
private bool isFirstRun = true;
|
||||
protected virtual async Task FirstRunAsync()
|
||||
{
|
||||
// load with no beginning wait. then wait 7 seconds to allow for page flicker. it usually happens after ~5 seconds. can happen irrespective of login state
|
||||
await Task.Run(() => Driver.Navigate().GoToUrl("http://www.audible.com/"));
|
||||
await Task.Delay(7000);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null)
|
||||
{
|
||||
if (isFirstRun)
|
||||
{
|
||||
await FirstRunAsync();
|
||||
isFirstRun = false;
|
||||
}
|
||||
|
||||
await initFirstPageAsync(audiblePage, pageId);
|
||||
|
||||
return await processUrl(audiblePage, pageId);
|
||||
}
|
||||
|
||||
private async Task initFirstPageAsync(AudiblePageType audiblePage, string pageId)
|
||||
{
|
||||
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
|
||||
await humanizer.Wait();
|
||||
|
||||
var url = audiblePage.GetAudiblePageRobust().GetUrl(pageId);
|
||||
await Task.Run(() => Driver.Navigate().GoToUrl(url));
|
||||
|
||||
await waitForSpinnerAsync();
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<AudiblePageSource>> processUrl(AudiblePageType audiblePage, string pageId)
|
||||
{
|
||||
var pageSources = new List<AudiblePageSource>();
|
||||
do
|
||||
{
|
||||
pageSources.Add(new AudiblePageSource(audiblePage, Driver.PageSource, pageId));
|
||||
}
|
||||
while (await hasMorePagesAsync());
|
||||
|
||||
return pageSources;
|
||||
}
|
||||
|
||||
#region has more pages
|
||||
/// <summary>if no more pages, return false. else, navigate to next page and return true</summary>
|
||||
private async Task<bool> hasMorePagesAsync()
|
||||
{
|
||||
var next = //old_hasMorePages() ??
|
||||
new_hasMorePages();
|
||||
if (next == null)
|
||||
return false;
|
||||
|
||||
await AudibleLinkClickAsync(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
private IWebElement old_hasMorePages()
|
||||
{
|
||||
var parentElements = Driver.FindElements(By.ClassName("adbl-page-next"));
|
||||
if (parentElements.Count == 0)
|
||||
return null;
|
||||
|
||||
var childElements = parentElements[0].FindElements(By.LinkText("NEXT"));
|
||||
if (childElements.Count != 1)
|
||||
return null;
|
||||
|
||||
return childElements[0];
|
||||
}
|
||||
|
||||
// ~ oct 2017
|
||||
private IWebElement new_hasMorePages()
|
||||
{
|
||||
// get all active/enabled navigation links
|
||||
var pageNavLinks = Driver.FindElements(By.ClassName("library-load-page"));
|
||||
if (pageNavLinks.Count == 0)
|
||||
return null;
|
||||
|
||||
// get only the right chevron if active.
|
||||
// note: there are also right chevrons which are not for wish list navigation which is why we first filter by library-load-page
|
||||
var nextLink = pageNavLinks
|
||||
.Where(p => p.FindElements(By.ClassName("bc-icon-chevron-right")).Count > 0)
|
||||
.ToList(); // cut-off delayed execution
|
||||
if (nextLink.Count == 0)
|
||||
return null;
|
||||
|
||||
return nextLink.Single().FindElement(By.TagName("button"));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region IDisposable pattern
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && Driver != null)
|
||||
{
|
||||
// Quit() does cleanup AND disposes
|
||||
Driver.Quit();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
/// <summary>for user collections: lib, WL</summary>
|
||||
public abstract class AuthSeleniumRetriever : SeleniumRetriever
|
||||
{
|
||||
protected bool IsLoggedIn => GetListenerPageLink() != null;
|
||||
|
||||
// needed?
|
||||
protected AuthSeleniumRetriever() : base() { }
|
||||
|
||||
protected IWebElement GetListenerPageLink()
|
||||
{
|
||||
var listenerPageElement = Driver.FindElements(By.XPath("//a[contains(@href, '/review-by-author')]"));
|
||||
if (listenerPageElement.Count > 0)
|
||||
return listenerPageElement[0];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleDotCom;
|
||||
using CookieMonster;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Humanizer;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
public class BrowserlessRetriever : IPageRetriever
|
||||
{
|
||||
Humanizer humanizer { get; } = new Humanizer();
|
||||
|
||||
public async Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null)
|
||||
{
|
||||
switch (audiblePage)
|
||||
{
|
||||
case AudiblePageType.Library: return await getLibraryPageSourcesAsync();
|
||||
default: throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<AudiblePageSource>> getLibraryPageSourcesAsync()
|
||||
{
|
||||
var collection = new List<AudiblePageSource>();
|
||||
|
||||
var cookies = await getAudibleCookiesAsync();
|
||||
|
||||
var currPageNum = 1;
|
||||
bool hasMorePages;
|
||||
do
|
||||
{
|
||||
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
|
||||
await humanizer.Wait();
|
||||
|
||||
var html = await getLibraryPageAsync(cookies, currPageNum);
|
||||
var pageSource = new AudiblePageSource(AudiblePageType.Library, html, null);
|
||||
collection.Add(pageSource);
|
||||
|
||||
hasMorePages = getHasMorePages(pageSource.Source);
|
||||
|
||||
currPageNum++;
|
||||
} while (hasMorePages);
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
private static async Task<CookieContainer> getAudibleCookiesAsync()
|
||||
{
|
||||
var liveCookies = await CookiesHelper.GetLiveCookieValuesAsync();
|
||||
|
||||
var audibleCookies = liveCookies.Where(c
|
||||
=> c.Domain.ContainsInsensitive("audible.com")
|
||||
|| c.Domain.ContainsInsensitive("adbl")
|
||||
|| c.Domain.ContainsInsensitive("amazon.com"))
|
||||
.ToList();
|
||||
|
||||
var cookies = new CookieContainer();
|
||||
foreach (var c in audibleCookies)
|
||||
cookies.Add(new Cookie(c.Name, c.Value, "/", c.Domain));
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
private static bool getHasMorePages(string html)
|
||||
{
|
||||
var doc = new HtmlAgilityPack.HtmlDocument();
|
||||
doc.LoadHtml(html);
|
||||
|
||||
// final page, invalid page:
|
||||
// <span class="bc-button
|
||||
// bc-button-secondary
|
||||
// nextButton
|
||||
// bc-button-disabled">
|
||||
// only page: ???
|
||||
// has more pages:
|
||||
// <span class="bc-button
|
||||
// bc-button-secondary
|
||||
// refinementFormButton
|
||||
// nextButton">
|
||||
var next_active_link = doc
|
||||
.DocumentNode
|
||||
.Descendants()
|
||||
.FirstOrDefault(n =>
|
||||
n.HasClass("nextButton") &&
|
||||
!n.HasClass("bc-button-disabled"));
|
||||
|
||||
return next_active_link != null;
|
||||
}
|
||||
|
||||
private static async Task<string> getLibraryPageAsync(CookieContainer cookies, int pageNum)
|
||||
{
|
||||
#region // POST example (from 2017 ajax)
|
||||
// var destination = "https://www.audible.com/lib-ajax";
|
||||
// var webRequest = (HttpWebRequest)WebRequest.Create(destination);
|
||||
// webRequest.Method = "POST";
|
||||
// webRequest.Accept = "*/*";
|
||||
// webRequest.AllowAutoRedirect = false;
|
||||
// webRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.0.3705)";
|
||||
// webRequest.ContentType = "application/x-www-form-urlencoded; charset=UTF-8";
|
||||
// webRequest.Credentials = null;
|
||||
//
|
||||
// webRequest.CookieContainer = new CookieContainer();
|
||||
// webRequest.CookieContainer.Add(cookies.GetCookies(new Uri(destination)));
|
||||
//
|
||||
// var postData = $"progType=all&timeFilter=all&itemsPerPage={itemsPerPage}&searchTerm=&searchType=&sortColumn=&sortType=down&page={pageNum}&mode=normal&subId=&subTitle=";
|
||||
// var data = Encoding.UTF8.GetBytes(postData);
|
||||
// webRequest.ContentLength = data.Length;
|
||||
// using var dataStream = webRequest.GetRequestStream();
|
||||
// dataStream.Write(data, 0, data.Length);
|
||||
#endregion
|
||||
|
||||
var destination = "https://" + $"www.audible.com/lib?purchaseDateFilter=all&programFilter=all&sortBy=PURCHASE_DATE.dsc&page={pageNum}";
|
||||
var webRequest = (HttpWebRequest)WebRequest.Create(destination);
|
||||
webRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.0.3705)";
|
||||
|
||||
webRequest.CookieContainer = new CookieContainer();
|
||||
webRequest.CookieContainer.Add(cookies.GetCookies(new Uri(destination)));
|
||||
|
||||
var webResponse = await webRequest.GetResponseAsync();
|
||||
return new StreamReader(webResponse.GetResponseStream()).ReadToEnd();
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
/// <summary>online. get auth by logging in with provided username and password
|
||||
/// retain no chrome cookies. enter user + pw login</summary>
|
||||
public class ManualLoginSeleniumRetriever : AuthSeleniumRetriever
|
||||
{
|
||||
string _username;
|
||||
string _password;
|
||||
public ManualLoginSeleniumRetriever(string username, string password) : base()
|
||||
{
|
||||
_username = username;
|
||||
_password = password;
|
||||
}
|
||||
protected override async Task FirstRunAsync()
|
||||
{
|
||||
await base.FirstRunAsync();
|
||||
|
||||
// can't extract this into AuthSeleniumRetriever ctor. can't use username/pw until prev ctors are complete
|
||||
|
||||
// click login link
|
||||
await AudibleLinkClickAsync(getLoginLink());
|
||||
|
||||
// wait until login page loads
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60)).Until(ExpectedConditions.ElementIsVisible(By.Id("ap_email")));
|
||||
|
||||
// insert credentials
|
||||
Driver
|
||||
.FindElement(By.Id("ap_email"))
|
||||
.SendKeys(_username);
|
||||
Driver
|
||||
.FindElement(By.Id("ap_password"))
|
||||
.SendKeys(_password);
|
||||
|
||||
// submit
|
||||
var submitElement
|
||||
= Driver.FindElements(By.Id("signInSubmit")).FirstOrDefault()
|
||||
?? Driver.FindElement(By.Id("signInSubmit-input"));
|
||||
await AudibleLinkClickAsync(submitElement);
|
||||
|
||||
// wait until audible page loads
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until(d => GetListenerPageLink());
|
||||
|
||||
if (!IsLoggedIn)
|
||||
throw new Exception("not logged in");
|
||||
}
|
||||
private IWebElement getLoginLink()
|
||||
{
|
||||
{
|
||||
var loginLinkElements1 = Driver.FindElements(By.XPath("//a[contains(@href, '/signin')]"));
|
||||
if (loginLinkElements1.Any())
|
||||
return loginLinkElements1[0];
|
||||
}
|
||||
|
||||
//
|
||||
// ADD ADDITIONAL ACCEPTABLE PATTERNS HERE
|
||||
//
|
||||
//{
|
||||
// var loginLinkElements2 = Driver.FindElements(By.XPath("//a[contains(@href, '/signin')]"));
|
||||
// if (loginLinkElements2.Any())
|
||||
// return loginLinkElements2[0];
|
||||
//}
|
||||
|
||||
throw new NotFoundException("Cannot locate login link");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium.Chrome;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
/// <summary>online. load auth, cookies etc from user data</summary>
|
||||
public class UserDataSeleniumRetriever : AuthSeleniumRetriever
|
||||
{
|
||||
public UserDataSeleniumRetriever() : base()
|
||||
{
|
||||
// can't extract this into AuthSeleniumRetriever ctor. can't use username/pw until prev ctors are complete
|
||||
if (!IsLoggedIn)
|
||||
throw new Exception("not logged in");
|
||||
}
|
||||
|
||||
/// <summary>Use current user data/chrome cookies. DO NOT use if chrome is already open</summary>
|
||||
protected override ChromeOptions ctorCreateChromeOptions()
|
||||
{
|
||||
var options = base.ctorCreateChromeOptions();
|
||||
|
||||
// load user data incl cookies. default on windows:
|
||||
// %LOCALAPPDATA%\Google\Chrome\User Data
|
||||
// C:\Users\username\AppData\Local\Google\Chrome\User Data
|
||||
var chromeDefaultWindowsUserDataDir = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Google",
|
||||
"Chrome",
|
||||
"User Data");
|
||||
options.AddArguments($"user-data-dir={chromeDefaultWindowsUserDataDir}");
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleDotCom;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
public interface IPageRetriever : IDisposable
|
||||
{
|
||||
Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
|
||||
namespace AudibleDotComAutomation.Examples
|
||||
{
|
||||
public class SeleniumExamples
|
||||
{
|
||||
public IWebDriver Driver { get; set; }
|
||||
|
||||
IWebElement GetListenerPageLink()
|
||||
{
|
||||
var listenerPageElement = Driver.FindElements(By.XPath("//a[contains(@href, '/review-by-author')]"));
|
||||
if (listenerPageElement.Count > 0)
|
||||
return listenerPageElement[0];
|
||||
return null;
|
||||
}
|
||||
void wait_examples()
|
||||
{
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until(ExpectedConditions.ElementIsVisible(By.Id("mast-member-acct-name")));
|
||||
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until(d => GetListenerPageLink());
|
||||
|
||||
// https://stackoverflow.com/questions/21339339/how-to-add-custom-expectedconditions-for-selenium
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until((d) =>
|
||||
{
|
||||
// could be refactored into OR, AND per the java selenium library
|
||||
|
||||
// check 1
|
||||
var e1 = Driver.FindElements(By.Id("mast-member-acct-name"));
|
||||
if (e1.Count > 0)
|
||||
return e1[0];
|
||||
// check 2
|
||||
var e2 = Driver.FindElements(By.Id("header-account-info-0"));
|
||||
if (e2.Count > 0)
|
||||
return e2[0];
|
||||
return null;
|
||||
});
|
||||
}
|
||||
void XPath_examples()
|
||||
{
|
||||
// <tr>
|
||||
// <td>1</td>
|
||||
// <td>2</td>
|
||||
// </tr>
|
||||
// <tr>
|
||||
// <td>3</td>
|
||||
// <td>4</td>
|
||||
// </tr>
|
||||
|
||||
ReadOnlyCollection<IWebElement> all_tr = Driver.FindElements(By.XPath("/tr"));
|
||||
IWebElement first_tr = Driver.FindElement(By.XPath("/tr"));
|
||||
IWebElement second_tr = Driver.FindElement(By.XPath("/tr[2]"));
|
||||
// beginning with a single / starts from root
|
||||
IWebElement ERROR_not_at_root = Driver.FindElement(By.XPath("/td"));
|
||||
// 2 slashes searches all, NOT just descendants
|
||||
IWebElement td1 = Driver.FindElement(By.XPath("//td"));
|
||||
|
||||
// 2 slashes still searches all, NOT just descendants
|
||||
IWebElement still_td1 = first_tr.FindElement(By.XPath("//td"));
|
||||
|
||||
// dot operator starts from current node specified by first_tr
|
||||
// single slash: immediate descendant
|
||||
IWebElement td3 = first_tr.FindElement(By.XPath(
|
||||
".//td"));
|
||||
// double slash: descendant at any depth
|
||||
IWebElement td3_also = first_tr.FindElement(By.XPath(
|
||||
"./td"));
|
||||
|
||||
// <input type="hidden" name="asin" value="ABCD1234">
|
||||
IWebElement find_anywhere_in_doc = first_tr.FindElement(By.XPath(
|
||||
"//input[@name='asin']"));
|
||||
IWebElement find_in_subsection = first_tr.FindElement(By.XPath(
|
||||
".//input[@name='asin']"));
|
||||
|
||||
// search entire page. useful for:
|
||||
// - RulesLocator to find something that only appears once on the page
|
||||
// - non-list pages. eg: product details
|
||||
var onePerPageRules = new RuleFamily
|
||||
{
|
||||
RowsLocator = By.XPath("/*"), // search entire page
|
||||
Rules = new RuleSet {
|
||||
(row, productItem) => productItem.CustomerId = row.FindElement(By.XPath("//input[@name='cust_id']")).GetValue(),
|
||||
(row, productItem) => productItem.UserName = row.FindElement(By.XPath("//input[@name='user_name']")).GetValue()
|
||||
}
|
||||
};
|
||||
// - applying conditionals to entire page
|
||||
var ruleFamily = new RuleFamily
|
||||
{
|
||||
RowsLocator = By.XPath("//*[starts-with(@id,'adbl-library-content-row-')]"),
|
||||
// Rules = getRuleSet()
|
||||
};
|
||||
}
|
||||
#region Rules classes stubs
|
||||
public class RuleFamily { public By RowsLocator; public IRuleClass Rules; }
|
||||
public interface IRuleClass { }
|
||||
public class RuleSet : IRuleClass, IEnumerable<IRuleClass>
|
||||
{
|
||||
public void Add(IRuleClass ruleClass) { }
|
||||
public void Add(RuleAction action) { }
|
||||
|
||||
public IEnumerator<IRuleClass> GetEnumerator() => throw new NotImplementedException();
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => throw new NotImplementedException();
|
||||
}
|
||||
public delegate void RuleAction(IWebElement row, ProductItem productItem);
|
||||
public class ProductItem { public string CustomerId; public string UserName; }
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Interactions;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
public static class IWebElementExt
|
||||
{
|
||||
// allows getting Text from elements even if hidden
|
||||
// this only works on visible elements: webElement.Text
|
||||
// http://yizeng.me/2014/04/08/get-text-from-hidden-elements-using-selenium-webdriver/#c-sharp
|
||||
//
|
||||
public static string GetText(this IWebElement webElement) => webElement.GetAttribute("textContent");
|
||||
|
||||
public static string GetValue(this IWebElement webElement) => webElement.GetAttribute("value");
|
||||
}
|
||||
|
||||
public static class IWebDriverExt
|
||||
{
|
||||
/// <summary>Use this instead of element.Click() to ensure that the element is clicked even if it's not currently scrolled into view</summary>
|
||||
public static void Click(this IWebDriver driver, IWebElement element)
|
||||
{
|
||||
// from: https://stackoverflow.com/questions/12035023/selenium-webdriver-cant-click-on-a-link-outside-the-page
|
||||
|
||||
|
||||
//// this works but isn't really the same
|
||||
//element.SendKeys(Keys.Enter);
|
||||
|
||||
|
||||
//// didn't work for me
|
||||
//new Actions(driver)
|
||||
// .MoveToElement(element)
|
||||
// .Click()
|
||||
// .Build()
|
||||
// .Perform();
|
||||
|
||||
driver.ScrollIntoView(element);
|
||||
element.Click();
|
||||
}
|
||||
public static void ScrollIntoView(this IWebDriver driver, IWebElement element)
|
||||
=> ((IJavaScriptExecutor)driver).ExecuteScript($"window.scroll({element.Location.X}, {element.Location.Y})");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.112" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="4.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SQLite;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using FileManager;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
internal class Chrome : IBrowser
|
||||
{
|
||||
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
|
||||
{
|
||||
var col = new List<CookieValue>();
|
||||
|
||||
var strPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Google\Chrome\User Data\Default\Cookies");
|
||||
if (!FileUtility.FileExists(strPath))
|
||||
return col;
|
||||
|
||||
//
|
||||
// IF WE GET AN ERROR HERE
|
||||
// then add a reference to sqlite core in the project which is ultimately calling this.
|
||||
// a project which directly references CookieMonster doesn't need to also ref sqlite.
|
||||
// however, for any further number of abstractions, the project needs to directly ref sqlite.
|
||||
// eg: this will not work unless the winforms proj adds sqlite to ref.s:
|
||||
// LibationWinForm > AudibleDotComAutomation > CookieMonster
|
||||
//
|
||||
using var conn = new SQLiteConnection("Data Source=" + strPath + ";pooling=false");
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT host_key, name, value, encrypted_value, last_access_utc, expires_utc FROM cookies;";
|
||||
|
||||
conn.Open();
|
||||
using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
while (reader.Read())
|
||||
{
|
||||
var host_key = reader.GetString(0);
|
||||
var name = reader.GetString(1);
|
||||
var value = reader.GetString(2);
|
||||
var last_access_utc = reader.GetInt64(4);
|
||||
var expires_utc = reader.GetInt64(5);
|
||||
|
||||
// https://stackoverflow.com/a/25874366
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
var encrypted_value = (byte[])reader[3];
|
||||
var decodedData = System.Security.Cryptography.ProtectedData.Unprotect(encrypted_value, null, System.Security.Cryptography.DataProtectionScope.CurrentUser);
|
||||
value = Encoding.ASCII.GetString(decodedData);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// if something goes wrong in this step (eg: a cookie has an invalid filetime), then just skip this cookie
|
||||
col.Add(new CookieValue { Browser = "chrome", Domain = host_key, Name = name, Value = value, LastAccess = chromeTimeToDateTimeUtc(last_access_utc), Expires = chromeTimeToDateTimeUtc(expires_utc) });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
// Chrome uses 1601-01-01 00:00:00 UTC as the epoch (ie the starting point for the millisecond time counter).
|
||||
// this is the same as "FILETIME" in Win32 except FILETIME uses 100ns ticks instead of ms.
|
||||
private static DateTime chromeTimeToDateTimeUtc(long time) => DateTime.SpecifyKind(DateTime.FromFileTime(time * 10), DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SQLite;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using FileManager;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
internal class FireFox : IBrowser
|
||||
{
|
||||
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
|
||||
{
|
||||
var col = new List<CookieValue>();
|
||||
|
||||
string strPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @"Mozilla\Firefox\Profiles");
|
||||
if (!FileUtility.FileExists(strPath))
|
||||
return col;
|
||||
var dirs = new DirectoryInfo(strPath).GetDirectories("*.default");
|
||||
if (dirs.Length != 1)
|
||||
return col;
|
||||
strPath = Path.Combine(strPath, dirs[0].Name, "cookies.sqlite");
|
||||
if (!FileUtility.FileExists(strPath))
|
||||
return col;
|
||||
|
||||
// First copy the cookie jar so that we can read the cookies from unlocked copy while FireFox is running
|
||||
var strTemp = strPath + ".temp";
|
||||
|
||||
File.Copy(strPath, strTemp, true);
|
||||
|
||||
// Now open the temporary cookie jar and extract Value from the cookie if we find it.
|
||||
using var conn = new SQLiteConnection("Data Source=" + strTemp + ";pooling=false");
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT host, name, value, lastAccessed, expiry FROM moz_cookies; ";
|
||||
|
||||
conn.Open();
|
||||
using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
while (reader.Read())
|
||||
{
|
||||
var host_key = reader.GetString(0);
|
||||
var name = reader.GetString(1);
|
||||
var value = reader.GetString(2);
|
||||
var lastAccessed = reader.GetInt32(3);
|
||||
var expiry = reader.GetInt32(4);
|
||||
|
||||
col.Add(new CookieValue { Browser = "firefox", Domain = host_key, Name = name, Value = value, LastAccess = lastAccessedToDateTime(lastAccessed), Expires = expiryToDateTime(expiry) });
|
||||
}
|
||||
|
||||
if (FileUtility.FileExists(strTemp))
|
||||
File.Delete(strTemp);
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
// time is in microseconds since unix epoch
|
||||
private static DateTime lastAccessedToDateTime(int time) => new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(time);
|
||||
|
||||
// time is in normal seconds since unix epoch
|
||||
private static DateTime expiryToDateTime(int time) => new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc).AddSeconds(time);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
internal interface IBrowser
|
||||
{
|
||||
Task<IEnumerable<CookieValue>> GetAllCookiesAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
internal class InternetExplorer : IBrowser
|
||||
{
|
||||
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
|
||||
{
|
||||
// real locations of Windows Cookies folders
|
||||
//
|
||||
// Windows 7:
|
||||
// C:\Users\username\AppData\Roaming\Microsoft\Windows\Cookies
|
||||
// C:\Users\username\AppData\Roaming\Microsoft\Windows\Cookies\Low
|
||||
//
|
||||
// Windows 8, Windows 8.1, Windows 10:
|
||||
// C:\Users\username\AppData\Local\Microsoft\Windows\INetCookies
|
||||
// C:\Users\username\AppData\Local\Microsoft\Windows\INetCookies\Low
|
||||
|
||||
var strPath = Environment.GetFolderPath(Environment.SpecialFolder.Cookies);
|
||||
|
||||
var col = (await getIECookiesAsync(strPath).ConfigureAwait(false)).ToList();
|
||||
col = col.Concat(await getIECookiesAsync(Path.Combine(strPath, "Low"))).ToList();
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<CookieValue>> getIECookiesAsync(string strPath)
|
||||
{
|
||||
var cookies = new List<CookieValue>();
|
||||
|
||||
var files = await Task.Run(() => Directory.EnumerateFiles(strPath, "*.txt"));
|
||||
foreach (string path in files)
|
||||
{
|
||||
var cookiesInFile = new List<CookieValue>();
|
||||
|
||||
var cookieLines = File.ReadAllLines(path);
|
||||
CookieValue currCookieVal = null;
|
||||
for (var i = 0; i < cookieLines.Length; i++)
|
||||
{
|
||||
var line = cookieLines[i];
|
||||
|
||||
// IE cookie format
|
||||
// 0 Cookie name
|
||||
// 1 Cookie value
|
||||
// 2 Host / path for the web server setting the cookie
|
||||
// 3 Flags
|
||||
// 4 Expiration time (low int)
|
||||
// 5 Expiration time (high int)
|
||||
// 6 Creation time (low int)
|
||||
// 7 Creation time (high int)
|
||||
// 8 Record delimiter == "*"
|
||||
var pos = i % 9;
|
||||
long expLoTemp = 0;
|
||||
long creatLoTemp = 0;
|
||||
if (pos == 0)
|
||||
{
|
||||
currCookieVal = new CookieValue { Browser = "ie", Name = line };
|
||||
cookiesInFile.Add(currCookieVal);
|
||||
}
|
||||
else if (pos == 1)
|
||||
currCookieVal.Value = line;
|
||||
else if (pos == 2)
|
||||
currCookieVal.Domain = line;
|
||||
else if (pos == 4)
|
||||
expLoTemp = Int64.Parse(line);
|
||||
else if (pos == 5)
|
||||
currCookieVal.Expires = LoHiToDateTime(expLoTemp, Int64.Parse(line));
|
||||
else if (pos == 6)
|
||||
creatLoTemp = Int64.Parse(line);
|
||||
else if (pos == 7)
|
||||
currCookieVal.LastAccess = LoHiToDateTime(creatLoTemp, Int64.Parse(line));
|
||||
}
|
||||
|
||||
cookies.AddRange(cookiesInFile);
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
private static DateTime LoHiToDateTime(long lo, long hi) => DateTime.FromFileTimeUtc(((hi << 32) + lo));
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
public class CookieValue
|
||||
{
|
||||
public string Browser { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
public string Value { get; set; }
|
||||
public string Domain { get; set; }
|
||||
|
||||
public DateTime LastAccess { get; set; }
|
||||
public DateTime Expires { get; set; }
|
||||
|
||||
public bool IsValid
|
||||
{
|
||||
get
|
||||
{
|
||||
// sanity check. datetimes are stored weird in each cookie type. make sure i haven't converted these incredibly wrong.
|
||||
// some early conversion attempts produced years like 42, 1955, 4033
|
||||
var _5yearsPast = DateTime.UtcNow.AddYears(-5);
|
||||
if (LastAccess < _5yearsPast || LastAccess > DateTime.UtcNow)
|
||||
return false;
|
||||
// don't check expiry. some sites are setting stupid values for year. eg: 9999
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasExpired => Expires < DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
public static class CookiesHelper
|
||||
{
|
||||
internal static IEnumerable<IBrowser> GetBrowsers()
|
||||
=> AppDomain.CurrentDomain
|
||||
.GetAssemblies()
|
||||
.SelectMany(s => s.GetTypes())
|
||||
.Where(p => typeof(IBrowser).IsAssignableFrom(p) && !p.IsAbstract && !p.IsInterface)
|
||||
.Select(t => Activator.CreateInstance(t) as IBrowser)
|
||||
.ToList();
|
||||
|
||||
/// <summary>all. including expired</summary>
|
||||
public static async Task<IEnumerable<CookieValue>> GetAllCookieValuesAsync()
|
||||
{
|
||||
//// foreach{await} runs in serial
|
||||
//var allCookies = new List<CookieValue>();
|
||||
//foreach (var b in GetBrowsers())
|
||||
//{
|
||||
// var browserCookies = await b.GetAllCookiesAsync().ConfigureAwait(false);
|
||||
// allCookies.AddRange(browserCookies);
|
||||
//}
|
||||
|
||||
//// WhenAll runs in parallel
|
||||
// this 1st step LOOKS like a bug which runs each method until completion. However, since we don't use await, it's actually returning a Task. That resulting task is awaited asynchronously
|
||||
var browserTasks = GetBrowsers().Select(b => b.GetAllCookiesAsync());
|
||||
var results = await Task.WhenAll(browserTasks).ConfigureAwait(false);
|
||||
var allCookies = results.SelectMany(a => a).ToList();
|
||||
|
||||
if (allCookies.Any(c => !c.IsValid))
|
||||
throw new Exception("some date time was converted way too far");
|
||||
|
||||
foreach (var c in allCookies)
|
||||
c.Domain = c.Domain.TrimEnd('/');
|
||||
|
||||
// for each domain+name, only keep the 1 with the most recent access
|
||||
var sortedCookies = allCookies
|
||||
.OrderByDescending(c => c.LastAccess)
|
||||
.DistinctBy(c => new { c.Domain, c.Name })
|
||||
.ToList();
|
||||
|
||||
return sortedCookies;
|
||||
}
|
||||
|
||||
/// <summary>not expired</summary>
|
||||
public static async Task<IEnumerable<CookieValue>> GetLiveCookieValuesAsync()
|
||||
=> (await GetAllCookieValuesAsync().ConfigureAwait(false))
|
||||
.Where(c => !c.HasExpired)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netcoreapp3.0;netstandard2.1</TargetFrameworks>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -12,13 +12,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.0.0">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.0.0">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -26,11 +26,13 @@ namespace DataLayer
|
||||
public string Description { get; private set; }
|
||||
public int LengthInMinutes { get; private set; }
|
||||
|
||||
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
|
||||
public string Locale { get; private set; }
|
||||
|
||||
// mutable
|
||||
public string PictureId { get; set; }
|
||||
|
||||
// book details
|
||||
public bool HasBookDetails { get; private set; }
|
||||
public bool IsAbridged { get; private set; }
|
||||
public DateTime? DatePublished { get; private set; }
|
||||
|
||||
@@ -62,12 +64,19 @@ namespace DataLayer
|
||||
string title,
|
||||
string description,
|
||||
int lengthInMinutes,
|
||||
IEnumerable<Contributor> authors)
|
||||
IEnumerable<Contributor> authors,
|
||||
IEnumerable<Contributor> narrators,
|
||||
Category category, string localeName)
|
||||
{
|
||||
// validate
|
||||
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
|
||||
var productId = audibleProductId.Id;
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
|
||||
|
||||
// assign as soon as possible. stuff below relies on this
|
||||
AudibleProductId = productId;
|
||||
Locale = localeName;
|
||||
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title));
|
||||
|
||||
// non-ef-ctor init.s
|
||||
@@ -76,24 +85,17 @@ namespace DataLayer
|
||||
_seriesLink = new HashSet<SeriesBook>();
|
||||
_supplements = new HashSet<Supplement>();
|
||||
|
||||
// since category/id is never null, nullity means it hasn't been loaded
|
||||
CategoryId = Category.GetEmpty().CategoryId;
|
||||
Category = category;
|
||||
|
||||
// simple assigns
|
||||
AudibleProductId = productId;
|
||||
Title = title;
|
||||
Description = description;
|
||||
LengthInMinutes = lengthInMinutes;
|
||||
|
||||
// assigns with biz logic
|
||||
ReplaceAuthors(authors);
|
||||
//ReplaceNarrators(narrators);
|
||||
|
||||
// import previously saved tags
|
||||
// do this immediately. any save occurs before reloading tags will overwrite persistent tags with new blank entries; all old persisted tags will be lost
|
||||
// if refactoring, DO NOT use "ProductId" before it's assigned to. to be safe, just use "productId"
|
||||
UserDefinedItem = new UserDefinedItem(this) { Tags = FileManager.TagsPersistence.GetTags(productId) };
|
||||
}
|
||||
ReplaceNarrators(narrators);
|
||||
}
|
||||
|
||||
#region contributors, authors, narrators
|
||||
// use uninitialised backing fields - this means we can detect if the collection was loaded
|
||||
@@ -125,16 +127,10 @@ namespace DataLayer
|
||||
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
|
||||
|
||||
// the edge cases of doing local-loaded vs remote-only got weird. just load it
|
||||
if (_contributorsLink == null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
if (!context.Entry(this).IsKeySet)
|
||||
throw new InvalidOperationException("Could not add contributors");
|
||||
if (_contributorsLink is null)
|
||||
getEntry(context).Collection(s => s.ContributorsLink).Load();
|
||||
|
||||
context.Entry(this).Collection(s => s.ContributorsLink).Load();
|
||||
}
|
||||
|
||||
var roleContributions = getContributions(role);
|
||||
var roleContributions = getContributions(role);
|
||||
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
|
||||
if (isIdentical)
|
||||
return;
|
||||
@@ -142,7 +138,8 @@ namespace DataLayer
|
||||
_contributorsLink.RemoveWhere(bc => bc.Role == role);
|
||||
addNewContributors(newContributors, role);
|
||||
}
|
||||
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
||||
|
||||
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
||||
{
|
||||
byte order = 0;
|
||||
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
|
||||
@@ -157,6 +154,18 @@ namespace DataLayer
|
||||
.ToList();
|
||||
#endregion
|
||||
|
||||
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
|
||||
var entry = context.Entry(this);
|
||||
|
||||
if (!entry.IsKeySet)
|
||||
throw new InvalidOperationException("Could not load a valid Book from database");
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
#region series
|
||||
private HashSet<SeriesBook> _seriesLink;
|
||||
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
|
||||
@@ -188,16 +197,10 @@ namespace DataLayer
|
||||
|
||||
// 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 (_seriesLink == null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
if (!context.Entry(this).IsKeySet)
|
||||
throw new InvalidOperationException("Could not add series");
|
||||
if (_seriesLink is null)
|
||||
getEntry(context).Collection(s => s.SeriesLink).Load();
|
||||
|
||||
context.Entry(this).Collection(s => s.SeriesLink).Load();
|
||||
}
|
||||
|
||||
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
|
||||
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
|
||||
if (singleSeriesBook == null)
|
||||
_seriesLink.Add(new SeriesBook(series, this, index));
|
||||
else
|
||||
@@ -208,13 +211,12 @@ namespace DataLayer
|
||||
#region supplements
|
||||
private HashSet<Supplement> _supplements;
|
||||
public IEnumerable<Supplement> Supplements => _supplements?.ToList();
|
||||
public bool HasPdfs => Supplements.Any();
|
||||
public bool HasPdf => Supplements.Any();
|
||||
|
||||
public void AddSupplementDownloadUrl(string url)
|
||||
{
|
||||
// supplements are owned by Book, so no need to Load():
|
||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
|
||||
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
|
||||
|
||||
@@ -231,24 +233,21 @@ namespace DataLayer
|
||||
// don't overwrite with default values
|
||||
IsAbridged |= isAbridged;
|
||||
DatePublished = datePublished ?? DatePublished;
|
||||
|
||||
HasBookDetails = true;
|
||||
}
|
||||
|
||||
public void UpdateCategory(Category category, DbContext context = null)
|
||||
{
|
||||
// since category is never null, nullity means it hasn't been loaded
|
||||
if (Category != null || CategoryId == Category.GetEmpty().CategoryId)
|
||||
{
|
||||
Category = category;
|
||||
return;
|
||||
}
|
||||
{
|
||||
// since category is never null, nullity means it hasn't been loaded
|
||||
if (Category is null)
|
||||
getEntry(context).Reference(s => s.Category).Load();
|
||||
|
||||
if (context == null)
|
||||
throw new Exception("need context");
|
||||
|
||||
context.Entry(this).Reference(s => s.Category).Load();
|
||||
Category = category;
|
||||
Category = category;
|
||||
}
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
public void UpdateLocale(string localeName)
|
||||
=> Locale ??= localeName;
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,7 @@ namespace DataLayer
|
||||
Role = role;
|
||||
Order = order;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Book} {Contributor} {Role} {Order}";
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,7 @@ namespace DataLayer
|
||||
public class Category
|
||||
{
|
||||
// Empty is a special case. use private ctor w/o validation
|
||||
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "", ParentCategory = null };
|
||||
public bool IsEmpty() => string.IsNullOrWhiteSpace(AudibleCategoryId) || string.IsNullOrWhiteSpace(Name) || ParentCategory == null;
|
||||
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "" };
|
||||
|
||||
internal int CategoryId { get; private set; }
|
||||
public string AudibleCategoryId { get; private set; }
|
||||
@@ -48,5 +47,7 @@ namespace DataLayer
|
||||
if (parentCategory != null)
|
||||
ParentCategory = parentCategory;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
|
||||
}
|
||||
}
|
||||
51
DataLayer/EfClasses/Contributor.cs
Normal file
51
DataLayer/EfClasses/Contributor.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public class Contributor
|
||||
{
|
||||
// Empty is a special case. use private ctor w/o validation
|
||||
public static Contributor GetEmpty() => new Contributor { ContributorId = -1, Name = "" };
|
||||
|
||||
// contributors search links are just name with url-encoding. space can be + or %20
|
||||
// author search link: /search?searchAuthor=Robert+Bevan
|
||||
// narrator search link: /search?searchNarrator=Robert+Bevan
|
||||
// can also search multiples. concat with comma before url encode
|
||||
|
||||
// id.s
|
||||
// ----
|
||||
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
|
||||
// goes to summary page
|
||||
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
|
||||
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
|
||||
|
||||
internal int ContributorId { get; private set; }
|
||||
public string Name { get; private set; }
|
||||
|
||||
private HashSet<BookContributor> _booksLink;
|
||||
public IEnumerable<BookContributor> BooksLink => _booksLink?.ToList();
|
||||
|
||||
public string AudibleContributorId { get; private set; }
|
||||
|
||||
private Contributor() { }
|
||||
public Contributor(string name)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
|
||||
_booksLink = new HashSet<BookContributor>();
|
||||
|
||||
Name = name;
|
||||
}
|
||||
public Contributor(string name, string audibleContributorId) : this(name)
|
||||
{
|
||||
// don't overwrite with null or whitespace but not an error
|
||||
if (!string.IsNullOrWhiteSpace(audibleContributorId))
|
||||
AudibleContributorId = audibleContributorId;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
33
DataLayer/EfClasses/LibraryBook.cs
Normal file
33
DataLayer/EfClasses/LibraryBook.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public class LibraryBook
|
||||
{
|
||||
internal int BookId { get; private set; }
|
||||
public Book Book { get; private set; }
|
||||
|
||||
public DateTime DateAdded { get; private set; }
|
||||
|
||||
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
|
||||
public string Account { get; private set; }
|
||||
|
||||
private LibraryBook() { }
|
||||
public LibraryBook(Book book, DateTime dateAdded, string account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
Book = book;
|
||||
DateAdded = dateAdded;
|
||||
Account = account;
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
public void UpdateAccount(string account)
|
||||
=> Account ??= account;
|
||||
|
||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||
}
|
||||
}
|
||||
@@ -72,5 +72,7 @@ namespace DataLayer
|
||||
|
||||
return string.Join("\r\n", items);
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
|
||||
}
|
||||
}
|
||||
@@ -66,5 +66,7 @@ namespace DataLayer
|
||||
if (_booksLink.SingleOrDefault(sb => sb.Book == book) == null)
|
||||
_booksLink.Add(new SeriesBook(this, book, index));
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
@@ -34,5 +34,7 @@ namespace DataLayer
|
||||
if (index.HasValue)
|
||||
Index = index.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"Series={Series} Book={Book}";
|
||||
}
|
||||
}
|
||||
@@ -20,5 +20,7 @@ namespace DataLayer
|
||||
Book = book;
|
||||
Url = url;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Book} {Url.Substring(Url.Length - 4)}";
|
||||
}
|
||||
}
|
||||
@@ -13,29 +13,36 @@ namespace DataLayer
|
||||
|
||||
private UserDefinedItem() { }
|
||||
internal UserDefinedItem(Book book)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
Book = book;
|
||||
}
|
||||
|
||||
// import previously saved tags
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
|
||||
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
||||
}
|
||||
|
||||
private string _tags = "";
|
||||
public string Tags
|
||||
{
|
||||
get => _tags;
|
||||
set => _tags = sanitize(value);
|
||||
}
|
||||
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
|
||||
// only legal chars are letters numbers underscores and separating whitespace
|
||||
//
|
||||
// technically, the only char.s which aren't easily supported are \ [ ]
|
||||
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
|
||||
// it's easy to expand whitelist as needed
|
||||
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
|
||||
//
|
||||
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
|
||||
// full list of characters which must be escaped:
|
||||
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
||||
static Regex regex = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
|
||||
// only legal chars are letters numbers underscores and separating whitespace
|
||||
//
|
||||
// technically, the only char.s which aren't easily supported are \ [ ]
|
||||
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
|
||||
// it's easy to expand whitelist as needed
|
||||
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
|
||||
//
|
||||
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
|
||||
// full list of characters which must be escaped:
|
||||
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
||||
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
|
||||
private static string sanitize(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
@@ -63,8 +70,6 @@ namespace DataLayer
|
||||
|
||||
return string.Join(" ", unique);
|
||||
}
|
||||
|
||||
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
||||
#endregion
|
||||
|
||||
// owned: not an optional one-to-one
|
||||
@@ -73,5 +78,7 @@ namespace DataLayer
|
||||
|
||||
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
|
||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Book} {Rating} {Tags}";
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,10 @@ namespace DataLayer
|
||||
public DbSet<Series> Series { get; private set; }
|
||||
public DbSet<Category> Categories { get; private set; }
|
||||
|
||||
public static LibationContext Create()
|
||||
public static LibationContext Create(string connectionString)
|
||||
{
|
||||
var factory = new LibationContextFactory();
|
||||
var context = factory.Create();
|
||||
var context = factory.Create(connectionString);
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -56,12 +56,17 @@ namespace DataLayer
|
||||
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||
|
||||
// seeds go here. examples in scratch pad
|
||||
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
||||
|
||||
modelBuilder
|
||||
.Entity<Category>()
|
||||
.HasData(Category.GetEmpty());
|
||||
.HasData(Category.GetEmpty());
|
||||
modelBuilder
|
||||
.Entity<Contributor>()
|
||||
.HasData(Contributor.GetEmpty());
|
||||
|
||||
// views are now supported via "query types" (instead of "entity types"): https://docs.microsoft.com/en-us/ef/core/modeling/query-types
|
||||
}
|
||||
}
|
||||
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
|
||||
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ namespace DataLayer
|
||||
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
|
||||
{
|
||||
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlServer(connectionString);
|
||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
|
||||
}
|
||||
}
|
||||
332
DataLayer/Migrations/20191125182309_Fresh.Designer.cs
generated
Normal file
332
DataLayer/Migrations/20191125182309_Fresh.Designer.cs
generated
Normal file
@@ -0,0 +1,332 @@
|
||||
// <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("20191125182309_Fresh")]
|
||||
partial class Fresh
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "3.0.0");
|
||||
|
||||
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>("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<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");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class UpgradeToCore3 : Migration
|
||||
public partial class Fresh : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
@@ -12,7 +12,7 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
CategoryId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AudibleCategoryId = table.Column<string>(nullable: true),
|
||||
Name = table.Column<string>(nullable: true),
|
||||
ParentCategoryCategoryId = table.Column<int>(nullable: true)
|
||||
@@ -33,9 +33,9 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
ContributorId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(nullable: true),
|
||||
AudibleAuthorId = table.Column<string>(nullable: true)
|
||||
AudibleContributorId = table.Column<string>(nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
@@ -47,7 +47,7 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
SeriesId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AudibleSeriesId = table.Column<string>(nullable: true),
|
||||
Name = table.Column<string>(nullable: true)
|
||||
},
|
||||
@@ -61,13 +61,12 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
BookId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AudibleProductId = table.Column<string>(nullable: true),
|
||||
Title = table.Column<string>(nullable: true),
|
||||
Description = table.Column<string>(nullable: true),
|
||||
LengthInMinutes = table.Column<int>(nullable: false),
|
||||
PictureId = table.Column<string>(nullable: true),
|
||||
HasBookDetails = table.Column<bool>(nullable: false),
|
||||
IsAbridged = table.Column<bool>(nullable: false),
|
||||
DatePublished = table.Column<DateTime>(nullable: true),
|
||||
CategoryId = table.Column<int>(nullable: false),
|
||||
@@ -117,8 +116,7 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
BookId = table.Column<int>(nullable: false),
|
||||
DateAdded = table.Column<DateTime>(nullable: false),
|
||||
DownloadBookLink = table.Column<string>(nullable: true)
|
||||
DateAdded = table.Column<DateTime>(nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
@@ -161,7 +159,7 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
SupplementId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
BookId = table.Column<int>(nullable: false),
|
||||
Url = table.Column<string>(nullable: true)
|
||||
},
|
||||
@@ -202,6 +200,11 @@ namespace DataLayer.Migrations
|
||||
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
|
||||
values: new object[] { -1, "", "", null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Contributors",
|
||||
columns: new[] { "ContributorId", "AudibleContributorId", "Name" },
|
||||
values: new object[] { -1, null, "" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookContributor_BookId",
|
||||
table: "BookContributor",
|
||||
@@ -3,57 +3,53 @@ using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20191007202808_UpgradeToCore3")]
|
||||
partial class UpgradeToCore3
|
||||
[Migration("20200812152646_AddLocaleAndAccount")]
|
||||
partial class AddLocaleAndAccount
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "3.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128)
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasAnnotation("ProductVersion", "3.1.7");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("datetime2");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("HasBookDetails")
|
||||
.HasColumnType("bit");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("bit");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
@@ -67,16 +63,16 @@ namespace DataLayer.Migrations
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("tinyint");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
@@ -91,17 +87,16 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
@@ -124,32 +119,38 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleAuthorId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.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("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DownloadBookLink")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
@@ -160,14 +161,13 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
@@ -179,13 +179,13 @@ namespace DataLayer.Migrations
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
@@ -207,18 +207,16 @@ namespace DataLayer.Migrations
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
@@ -232,14 +230,13 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
@@ -254,10 +251,10 @@ namespace DataLayer.Migrations
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
@@ -269,16 +266,16 @@ namespace DataLayer.Migrations
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
31
DataLayer/Migrations/20200812152646_AddLocaleAndAccount.cs
Normal file
31
DataLayer/Migrations/20200812152646_AddLocaleAndAccount.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class AddLocaleAndAccount : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Account",
|
||||
table: "Library",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Locale",
|
||||
table: "Books",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Account",
|
||||
table: "Library");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Locale",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
@@ -15,43 +14,40 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "3.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128)
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasAnnotation("ProductVersion", "3.1.7");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("datetime2");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("HasBookDetails")
|
||||
.HasColumnType("bit");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("bit");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
@@ -65,16 +61,16 @@ namespace DataLayer.Migrations
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("tinyint");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
@@ -89,17 +85,16 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
@@ -122,32 +117,38 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleAuthorId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.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("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DownloadBookLink")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
@@ -158,14 +159,13 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
@@ -177,13 +177,13 @@ namespace DataLayer.Migrations
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
@@ -205,18 +205,16 @@ namespace DataLayer.Migrations
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
@@ -230,14 +228,13 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
@@ -252,10 +249,10 @@ namespace DataLayer.Migrations
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
@@ -267,16 +264,16 @@ namespace DataLayer.Migrations
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
|
||||
@@ -8,22 +8,11 @@ namespace DataLayer
|
||||
{
|
||||
public static class BookQueries
|
||||
{
|
||||
public static int BooksWithoutDetailsCount()
|
||||
{
|
||||
using var context = LibationContext.Create();
|
||||
return context
|
||||
.Books
|
||||
.Count(b => !b.HasBookDetails);
|
||||
}
|
||||
|
||||
public static Book GetBook_Flat_NoTracking(string productId)
|
||||
{
|
||||
using var context = LibationContext.Create();
|
||||
return context
|
||||
public static Book GetBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
=> context
|
||||
.Books
|
||||
.AsNoTracking()
|
||||
.GetBook(productId);
|
||||
}
|
||||
|
||||
public static Book GetBook(this IQueryable<Book> books, string productId)
|
||||
=> books
|
||||
@@ -5,25 +5,25 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace DataLayer
|
||||
{
|
||||
public static class LibraryQueries
|
||||
{
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
|
||||
{
|
||||
using var context = LibationContext.Create();
|
||||
return context
|
||||
{
|
||||
public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
|
||||
=> context
|
||||
.Library
|
||||
.GetLibrary()
|
||||
.ToList();
|
||||
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
|
||||
=> context
|
||||
.Library
|
||||
//.AsNoTracking()
|
||||
.AsNoTracking()
|
||||
.GetLibrary()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(string productId)
|
||||
{
|
||||
using var context = LibationContext.Create();
|
||||
return context
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
=> context
|
||||
.Library
|
||||
//.AsNoTracking()
|
||||
.GetLibraryBook(productId);
|
||||
}
|
||||
.AsNoTracking()
|
||||
.GetLibraryBook(productId);
|
||||
|
||||
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
||||
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
|
||||
30
DataLayer/TagPersistenceInterceptor.cs
Normal file
30
DataLayer/TagPersistenceInterceptor.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
using Dinah.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
internal class TagPersistenceInterceptor : IDbInterceptor
|
||||
{
|
||||
public void Executed(DbContext context) { }
|
||||
|
||||
public void Executing(DbContext context)
|
||||
{
|
||||
var tagsCollection
|
||||
= context
|
||||
.ChangeTracker
|
||||
.Entries()
|
||||
.Where(e => e.State.In(EntityState.Modified, EntityState.Added))
|
||||
.Select(e => e.Entity as UserDefinedItem)
|
||||
.Where(udi => udi != null)
|
||||
// do NOT filter out entires with blank tags. blank is the valid way to show the absence of tags
|
||||
.Select(t => (t.Book.AudibleProductId, t.Tags))
|
||||
.ToList();
|
||||
|
||||
FileManager.TagsPersistence.Save(tagsCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public static class RemoveOrphansCommand
|
||||
{
|
||||
public static int RemoveOrphans(this LibationContext context)
|
||||
=> context.Database.ExecuteSqlRaw(@"
|
||||
delete c
|
||||
from Contributors c
|
||||
left join BookContributor bc on c.ContributorId = bc.ContributorId
|
||||
left join Books b on bc.BookId = b.BookId
|
||||
where bc.ContributorId is null
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public class Contributor
|
||||
{
|
||||
// contributors search links are just name with url-encoding. space can be + or %20
|
||||
// author search link: /search?searchAuthor=Robert+Bevan
|
||||
// narrator search link: /search?searchNarrator=Robert+Bevan
|
||||
// can also search multiples. concat with comma before url encode
|
||||
|
||||
// id.s
|
||||
// ----
|
||||
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
|
||||
// goes to summary page
|
||||
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
|
||||
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
|
||||
|
||||
internal int ContributorId { get; private set; }
|
||||
public string Name { get; private set; }
|
||||
|
||||
private HashSet<BookContributor> _booksLink;
|
||||
public IEnumerable<BookContributor> BooksLink => _booksLink?.ToList();
|
||||
|
||||
private Contributor() { }
|
||||
public Contributor(string name)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
|
||||
_booksLink = new HashSet<BookContributor>();
|
||||
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public string AudibleAuthorId { get; private set; }
|
||||
public void UpdateAudibleAuthorId(string authorId)
|
||||
{
|
||||
// don't overwrite with null or whitespace but not an error
|
||||
if (!string.IsNullOrWhiteSpace(authorId))
|
||||
AudibleAuthorId = authorId;
|
||||
}
|
||||
|
||||
#region // AudibleAuthorId refactor: separate author-specific info. overkill for a single optional string
|
||||
///// <summary>Most authors in Audible have a unique id</summary>
|
||||
//public AudibleAuthorProperty AudibleAuthorProperty { get; private set; }
|
||||
//public void UpdateAuthorId(string authorId, LibationContext context = null)
|
||||
//{
|
||||
// if (authorId == null)
|
||||
// return;
|
||||
// if (AudibleAuthorProperty != null)
|
||||
// {
|
||||
// AudibleAuthorProperty.UpdateAudibleAuthorId(authorId);
|
||||
// return;
|
||||
// }
|
||||
// if (context == null)
|
||||
// throw new ArgumentNullException(nameof(context), "You must provide a context");
|
||||
// if (context.Contributors.Find(ContributorId) == null)
|
||||
// throw new InvalidOperationException("Could not update audible author id.");
|
||||
// var audibleAuthorProperty = new AudibleAuthorProperty();
|
||||
// audibleAuthorProperty.UpdateAudibleAuthorId(authorId);
|
||||
// context.AuthorProperties.Add(audibleAuthorProperty);
|
||||
//}
|
||||
//public class AudibleAuthorProperty
|
||||
//{
|
||||
// public int ContributorId { get; private set; }
|
||||
// public Contributor Contributor { get; set; }
|
||||
|
||||
// public string AudibleAuthorId { get; private set; }
|
||||
|
||||
// public void UpdateAudibleAuthorId(string authorId)
|
||||
// {
|
||||
// if (!string.IsNullOrWhiteSpace(authorId))
|
||||
// AudibleAuthorId = authorId;
|
||||
// }
|
||||
//}
|
||||
//// ...and create EF table config
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public class LibraryBook
|
||||
{
|
||||
internal int BookId { get; private set; }
|
||||
public Book Book { get; private set; }
|
||||
|
||||
public DateTime DateAdded { get; private set; }
|
||||
|
||||
/// <summary>For downloading AAX file</summary>
|
||||
public string DownloadBookLink { get; private set; }
|
||||
|
||||
private LibraryBook() { }
|
||||
public LibraryBook(Book book, DateTime dateAdded
|
||||
, string downloadBookLink = null
|
||||
)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
Book = book;
|
||||
DateAdded = dateAdded;
|
||||
DownloadBookLink = downloadBookLink;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
using Dinah.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
internal class TagPersistenceInterceptor : IDbInterceptor
|
||||
{
|
||||
public void Executing(DbContext context)
|
||||
{
|
||||
doWork__EFCore(context);
|
||||
}
|
||||
|
||||
public void Executed(DbContext context) { }
|
||||
|
||||
static void doWork__EFCore(DbContext context)
|
||||
{
|
||||
// persist tags:
|
||||
var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State.In(EntityState.Modified, EntityState.Added)).ToList();
|
||||
var tagSets = modifiedEntities.Select(e => e.Entity as UserDefinedItem).Where(a => a != null).ToList();
|
||||
foreach (var t in tagSets)
|
||||
FileManager.TagsPersistence.Save(t.Book.AudibleProductId, t.Tags);
|
||||
}
|
||||
|
||||
#region // notes: working with proxies, esp EF 6
|
||||
// EF 6: entities are proxied with lazy loading when collections are virtual
|
||||
// EF Core: lazy loading is supported in 2.1 (there is a version of lazy loading with proxy-wrapping and a proxy-less version with DI) but not on by default and are not supported here
|
||||
|
||||
//static void doWork_EF6(DbContext context)
|
||||
//{
|
||||
// var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State == EntityState.Modified).ToList();
|
||||
// var unproxiedEntities = modifiedEntities.Select(me => UnProxy(context, me.Entity)).ToList();
|
||||
|
||||
// // persist tags
|
||||
// var tagSets = unproxiedEntities.Select(ue => ue as UserDefinedItem).Where(a => a != null).ToList();
|
||||
// foreach (var t in tagSets)
|
||||
// FileManager.TagsPersistence.Save(t.ProductId, t.TagsRaw);
|
||||
//}
|
||||
|
||||
//// https://stackoverflow.com/a/25774651
|
||||
//private static T UnProxy<T>(DbContext context, T proxyObject) where T : class
|
||||
//{
|
||||
// // alternative: https://docs.microsoft.com/en-us/ef/ef6/fundamentals/proxies
|
||||
// var proxyCreationEnabled = context.Configuration.ProxyCreationEnabled;
|
||||
// try
|
||||
// {
|
||||
// context.Configuration.ProxyCreationEnabled = false;
|
||||
// return context.Entry(proxyObject).CurrentValues.ToObject() as T;
|
||||
// }
|
||||
// finally
|
||||
// {
|
||||
// context.Configuration.ProxyCreationEnabled = proxyCreationEnabled;
|
||||
// }
|
||||
//}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using System;
|
||||
using Dinah.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace _scratch_pad
|
||||
{
|
||||
////// to use this as a console, open properties and change from class library => console
|
||||
//// DON'T FORGET TO REVERT IT
|
||||
//public class Program
|
||||
//{
|
||||
// public static void Main(string[] args)
|
||||
// {
|
||||
// var user = new Student() { Name = "Dinah Cheshire" };
|
||||
// var udi = new UserDef { UserDefId = 1, TagsRaw = "my,tags" };
|
||||
|
||||
// using var context = new MyTestContextDesignTimeDbContextFactory().Create();
|
||||
// context.Add(user);
|
||||
// //context.Add(udi);
|
||||
// context.Update(udi);
|
||||
// context.SaveChanges();
|
||||
|
||||
// Console.WriteLine($"Student was saved in the database with id: {user.Id}");
|
||||
// }
|
||||
//}
|
||||
|
||||
public class MyTestContextDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase<MyTestContext>
|
||||
{
|
||||
protected override MyTestContext CreateNewInstance(DbContextOptions<MyTestContext> options) => new MyTestContext(options);
|
||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
|
||||
}
|
||||
|
||||
public class MyTestContext : DbContext
|
||||
{
|
||||
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
|
||||
public MyTestContext(DbContextOptions<MyTestContext> options) : base(options) { }
|
||||
|
||||
#region classes for OnModelCreating() seed example
|
||||
class Blog
|
||||
{
|
||||
public int BlogId { get; set; }
|
||||
public string Url { get; set; }
|
||||
public System.Collections.Generic.ICollection<Post> Posts { get; set; }
|
||||
}
|
||||
class Post
|
||||
{
|
||||
public int PostId { get; set; }
|
||||
public string Content { get; set; }
|
||||
public string Title { get; set; }
|
||||
public int BlogId { get; set; }
|
||||
public Blog Blog { get; set; }
|
||||
public Name AuthorName { get; set; }
|
||||
}
|
||||
class Name
|
||||
{
|
||||
public string First { get; set; }
|
||||
public string Last { get; set; }
|
||||
}
|
||||
#endregion
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// config
|
||||
modelBuilder.Entity<Blog>(entity => entity.Property(e => e.Url).IsRequired());
|
||||
modelBuilder.Entity<Order>().OwnsOne(p => p.OrderDetails, cb =>
|
||||
{
|
||||
cb.OwnsOne(c => c.BillingAddress);
|
||||
cb.OwnsOne(c => c.ShippingAddress);
|
||||
});
|
||||
modelBuilder.Entity<Post>(entity =>
|
||||
entity
|
||||
.HasOne(d => d.Blog)
|
||||
.WithMany(p => p.Posts)
|
||||
.HasForeignKey("BlogId"));
|
||||
|
||||
// BlogSeed
|
||||
modelBuilder.Entity<Blog>().HasData(new Blog { BlogId = 1, Url = "http://sample.com" });
|
||||
|
||||
// PostSeed
|
||||
modelBuilder.Entity<Post>().HasData(new Post() { BlogId = 1, PostId = 1, Title = "First post", Content = "Test 1" });
|
||||
|
||||
// AnonymousPostSeed
|
||||
modelBuilder.Entity<Post>().HasData(new { BlogId = 1, PostId = 2, Title = "Second post", Content = "Test 2" });
|
||||
|
||||
// OwnedTypeSeed
|
||||
modelBuilder.Entity<Post>().OwnsOne(p => p.AuthorName).HasData(
|
||||
new { PostId = 1, First = "Andriy", Last = "Svyryd" },
|
||||
new { PostId = 2, First = "Diego", Last = "Vega" });
|
||||
}
|
||||
|
||||
public DbSet<Student> Students { get; set; }
|
||||
public DbSet<UserDef> UserDefs { get; set; }
|
||||
public DbSet<Order> Orders { get; set; }
|
||||
}
|
||||
|
||||
public class Student
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
public class UserDef
|
||||
{
|
||||
public int UserDefId { get; set; }
|
||||
public string TagsRaw { get; set; }
|
||||
}
|
||||
|
||||
public class Order
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public OrderDetails OrderDetails { get; set; }
|
||||
}
|
||||
public class OrderDetails
|
||||
{
|
||||
public StreetAddress BillingAddress { get; set; }
|
||||
public StreetAddress ShippingAddress { get; set; }
|
||||
}
|
||||
public class StreetAddress
|
||||
{
|
||||
public string Street { get; set; }
|
||||
public string City { get; set; }
|
||||
}
|
||||
}
|
||||
38
DataLayer/Utilities/LocalDatabaseInfo.cs
Normal file
38
DataLayer/Utilities/LocalDatabaseInfo.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
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,6 +1,9 @@
|
||||
HOW TO CREATE: EF CORE PROJECT
|
||||
FOR QUICK MIGRATION INSTRUCTIONS:
|
||||
_DB_NOTES.txt
|
||||
|
||||
|
||||
HOW TO CREATE: EF CORE PROJECT
|
||||
==============================
|
||||
easiest with .NET Core but there's also a work-around for .NET Standard
|
||||
example is for sqlite but the same works with MsSql
|
||||
|
||||
|
||||
@@ -8,15 +11,22 @@ nuget
|
||||
Microsoft.EntityFrameworkCore.Tools (needed for using Package Manager Console)
|
||||
Microsoft.EntityFrameworkCore.Sqlite
|
||||
|
||||
MIGRATIONS require standard, not core
|
||||
using standard instead of core. edit 3 things in csproj
|
||||
1of3: pluralize xml TargetFramework tag to TargetFrameworks
|
||||
2of2: TargetFrameworks from: netstandard2.1
|
||||
to: netcoreapp3.0;netstandard2.1
|
||||
3of3: add
|
||||
<PropertyGroup>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
</PropertyGroup>
|
||||
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'.
|
||||
@@ -26,7 +36,6 @@ set project "Set as StartUp Project"
|
||||
Tools >> Nuget Package Manager >> Package Manager Console
|
||||
default project: Examples\SQLite_NETCore2_0
|
||||
|
||||
note: in EFCore, Enable-Migrations is no longer used. start with add-migration
|
||||
PM> add-migration InitialCreate
|
||||
PM> Update-Database
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
proposed extensible schema to generalize beyond audible
|
||||
|
||||
problems
|
||||
0) reeks of premature optimization
|
||||
- i'm currently only doing audible audiobooks. this adds several layers of abstraction for the sake of possible expansion
|
||||
- there's a good chance that supporting another platform may not conform to this schema, in which case i'd have done this for nothing. genres are one likely pain point
|
||||
- libation is currently single-user. hopefully the below would suffice for adding users, but if i'm wrong it might be all pain and no gain
|
||||
1) very thorough == very complex
|
||||
2) there are some books which would still be difficult to taxonimize
|
||||
- joy of cooking. has become more of a brand
|
||||
- the bible. has different versions that aren't just editions
|
||||
- dictionary. authored by a publisher
|
||||
3) "books" vs "editions" is a confusing problem waiting to happen
|
||||
|
||||
[AIPK=auto increm PK]
|
||||
|
||||
(libation) users [AIPK id, name, join date]
|
||||
audible users [AIPK id, AUDIBLE-PK username]
|
||||
libation audible users [PK user id, PK audible user id -- cluster PK across all FKs]
|
||||
- potential danger in multi-user environment. wouldn't want one libation user getting access to a different libation user's audible info
|
||||
contributors [AIPK id, name]. prev people. incl publishers
|
||||
audible authors [PK/FK contributor id, AUDIBLE-PK author id]
|
||||
roles [AIPK id, name]. seeded: author, narrator, publisher. could expand (eg: translator, editor) without each needing a new table
|
||||
books [AIPK id, title, desc]
|
||||
book contributors [FK book id, FK contributor id, FK role id, order -- cluster PK across all FKs]
|
||||
- likely only authors
|
||||
editions [AIPK id, FK book id, title]. could expand to include year, is first edition, is abridged
|
||||
- reasons for optional different title: "Ender's Game: Special 20th Anniversary Edition", "Harry Potter and the Sorcerer's Stone" vs "Harry Potter and the Philosopher's Stone" vs "Harry Potter y la piedra filosofal", "Midnight Riot" vs "Rivers of London"
|
||||
edition contributors [FK edition id, FK contributor id, FK role id, order -- cluster PK across all FKs]
|
||||
- likely everything except authors. eg narrators, publisher
|
||||
audiobooks [PK/FK edition id, lengthInMinutes]
|
||||
- could expand to other formats by adding other similar tables. eg: print with #pages and isbn, ebook with mb
|
||||
audible origins [AIPK id, name]. seeded: library. detail. json. series
|
||||
audible books [PK/FK edition id, AUDIBLE-PK product id, picture id, sku, 3 ratings, audible category id, audible origin id]
|
||||
- could expand to other vendors by adding other similar tables
|
||||
audible user ratings [PK/FK edition id, audible user id, 3 ratings]
|
||||
audible supplements [AIPK id, FK edition id, download url]
|
||||
- pdfs only. although book download info could be the same format, they're substantially different and subject to change
|
||||
audible book downloads [PK/FK edition id, audible user id, bookdownloadlink]
|
||||
pictures [AIPK id, FK edition id, filename (xyz.jpg -- not incl path)]
|
||||
audible categories [AIPK id, AUDIBLE-PK category id, name, parent]. may only nest 1 deep
|
||||
(libation) library [FK libation user id, FK edition id, date added -- cluster PK across all FKs]
|
||||
(libation) user defined [FK libation user id, FK edition id, tagsRaw (, notes...) -- cluster PK across all FKs]
|
||||
- there's no reason to restrict tags to library items, so don't combine/link this table with library
|
||||
series [AIPK id, name]
|
||||
audible series [FK series id, AUDIBLE-PK series id/asin, audible origin id]
|
||||
- could also include a 'name' field for what audible calls this series
|
||||
series books [FK series id, FK book id (NOT edition id), index -- cluster PK across all FKs]
|
||||
- "index" not "order". display this number; don't just put in this sequence
|
||||
- index is float instead of int to allow for in-between books. eg 2.5
|
||||
- if only using "editions" (ie: getting rid of the "books" table), to show 2 editions as the same book in a series, give them the same index
|
||||
(libation) user shelves [AIPK id, FK libation user id, name, desc]
|
||||
- custom shelf. similar to library but very different in philosophy. likely different in evolving details
|
||||
(libation) shelf books [AIPK id, FK user shelf id, date added, order]
|
||||
- technically, it's no violation to list a book more than once so use AIPK
|
||||
@@ -1,76 +0,0 @@
|
||||
ignore for now:
|
||||
authorProperties [PK/FK contributor id, AUDIBLE-PK author id]
|
||||
notes in Contributor.cs for later refactoring
|
||||
|
||||
c# enum only, not their own tables:
|
||||
roles [AIPK id, name]. seeded: author, narrator, publisher. could expand (eg: translator, editor) without each needing a new table
|
||||
origins [AIPK id, name]. seeded: library. detail. json. series
|
||||
|
||||
|
||||
-- begin SCHEMA ---------------------------------------------------------------------------------------------------------------------
|
||||
any audible keys should be indexed
|
||||
|
||||
SCHEMA
|
||||
======
|
||||
contributors [AIPK id, name]. people and publishers
|
||||
books [AIPK id, AUDIBLE-PK product id, title, desc, lengthInMinutes, picture id, 3 ratings, category id, origin id]
|
||||
- product instances. each edition and version is discrete: unique and disconnected from different editions of the same book
|
||||
- on book re-import
|
||||
update:
|
||||
update book origin and series origin with the new source type
|
||||
overwrite simple fields
|
||||
invoke complex contributor updates
|
||||
details page gets
|
||||
un/abridged
|
||||
release date
|
||||
language
|
||||
publisher
|
||||
series info incl name
|
||||
categories
|
||||
if new == series: ignore. do update series info. do not update book info
|
||||
else if old == json: update (incl if new == json)
|
||||
else if old == library && new == detail: update
|
||||
else: ignore
|
||||
book contributors [FK book id, FK contributor id, FK role id, order -- cluster PK across all FKs]
|
||||
supplements [AIPK id, FK book id, download url]
|
||||
categories [AIPK id, AUDIBLE-PK category id, name, parent]. may only nest 1 deep
|
||||
user defined [PK/FK book id, 3 ratings, tagsRaw]
|
||||
series [AIPK id, AUDIBLE-PK series id/asin, name, origin id]
|
||||
series books [FK series id, FK book id, index -- cluster PK across all FKs]
|
||||
- "index" not "order". display this number; don't just put in this sequence
|
||||
- index is float instead of int to allow for in-between books. eg 2.5
|
||||
- to show 2 editions as the same book in a series, give them the same index
|
||||
- re-import using series page, there will need to be a re-eval of import logic
|
||||
library [PK/FK book id, date added, bookdownloadlink]
|
||||
-- end SCHEMA ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- begin SIMPLIFIED DDD ---------------------------------------------------------------------------------------------------------------------
|
||||
combine domain and persistence (C(r)UD). no repository pattern. encapsulated in domain objects; direct calls to EF Core
|
||||
https://www.thereformedprogrammer.net/creating-domain-driven-design-entity-classes-with-entity-framework-core/
|
||||
// pattern for x-to-many
|
||||
public void AddReview(int numStars, DbContext context = null)
|
||||
{
|
||||
if (_reviews != null) _reviews.Add(new Review(numStars));
|
||||
else if (context == null) throw new Exception("need context");
|
||||
else if (context.Entry(this).IsKeySet) context.Add(new Review(numStars, BookId));
|
||||
else throw new Exception("Could not add");
|
||||
}
|
||||
|
||||
// pattern for optional one-to-one
|
||||
MyPropClass MyProps { get; private set; }
|
||||
public void AddMyProps(string s, int i, DbContext context = null)
|
||||
{
|
||||
// avoid a trip to the db
|
||||
if (MyProps != null) { MyProps.Update(s, i); return; }
|
||||
if (BookId == 0) { MyProps = new MyPropClass(s, i); return; }
|
||||
if (context == null) throw new Exception("need context");
|
||||
// per Jon P Smith, this single trip to db loads the property if there is one
|
||||
// note: .Reference() is for single object references. for collections use .Collection()
|
||||
context.Entry(this).Reference(s => s.MyProps).Load();
|
||||
if (MyProps != null) MyProps.Update(s, i);
|
||||
else MyProps = new MyPropClass(s, i);
|
||||
}
|
||||
|
||||
repository reads are 'query object'-like extension methods
|
||||
https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework-core/#1-query-objects-a-way-to-isolate-and-hide-database-read-code
|
||||
-- and SIMPLIFIED DDD ---------------------------------------------------------------------------------------------------------------------
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"LibationContext": "Server=(LocalDb)\\MSSQLLocalDB;Database=DataLayer.LibationContext;Integrated Security=true;",
|
||||
|
||||
"// on windows sqlite paths accept windows and/or unix slashes": "",
|
||||
"MyTestContext": "Data Source=%DESKTOP%/sample.db"
|
||||
"// this connection string is ONLY used for DataLayer's Migrations. this appsettings.json file is NOT used at all by application; it is overwritten": "",
|
||||
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;"
|
||||
}
|
||||
}
|
||||
@@ -9,29 +9,31 @@ namespace DtoImporterService
|
||||
{
|
||||
public class BookImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new BookValidator().Validate(items);
|
||||
public BookImporter(LibationContext context) : base(context) { }
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new BookValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// pre-req.s
|
||||
new ContributorImporter().Import(items, context);
|
||||
new SeriesImporter().Import(items, context);
|
||||
new CategoryImporter().Import(items, context);
|
||||
new ContributorImporter(DbContext).Import(importItems);
|
||||
new SeriesImporter(DbContext).Import(importItems);
|
||||
new CategoryImporter(DbContext).Import(importItems);
|
||||
|
||||
// get distinct
|
||||
var productIds = items.Select(i => i.ProductId).ToList();
|
||||
var productIds = importItems.Select(i => i.DtoItem.ProductId).ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_books(productIds, context);
|
||||
loadLocal_books(productIds);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertBooks(items, context);
|
||||
var qtyNew = upsertBooks(importItems);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_books(List<string> productIds, LibationContext context)
|
||||
private void loadLocal_books(List<string> productIds)
|
||||
{
|
||||
var localProductIds = context.Books.Local.Select(b => b.AudibleProductId);
|
||||
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId);
|
||||
var remainingProductIds = productIds
|
||||
.Distinct()
|
||||
.Except(localProductIds)
|
||||
@@ -39,87 +41,126 @@ namespace DtoImporterService
|
||||
|
||||
// GetBooks() eager loads Series, category, et al
|
||||
if (remainingProductIds.Any())
|
||||
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertBooks(IEnumerable<Item> items, LibationContext context)
|
||||
private int upsertBooks(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var item in items)
|
||||
foreach (var item in importItems)
|
||||
{
|
||||
var book = context.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.ProductId);
|
||||
var book = DbContext.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
|
||||
if (book is null)
|
||||
{
|
||||
// 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
|
||||
.Authors
|
||||
.Select(a => context.Contributors.Local.Single(c => a.Name == c.Name))
|
||||
.ToList();
|
||||
|
||||
book = context.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId), item.Title, item.Description, item.LengthInMinutes, authors))
|
||||
.Entity;
|
||||
|
||||
book = createNewBook(item);
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
// if no narrators listed, author is the narrator
|
||||
if (item.Narrators is null || !item.Narrators.Any())
|
||||
item.Narrators = item.Authors;
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
var narrators = item
|
||||
.Narrators
|
||||
.Select(n => context.Contributors.Local.Single(c => n.Name == c.Name))
|
||||
.ToList();
|
||||
// not all books have narrators. these will already be using author as narrator. don't undo this
|
||||
if (narrators.Any())
|
||||
book.ReplaceNarrators(narrators);
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
book.PictureId = item.PictureId;
|
||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
|
||||
book.AddSupplementDownloadUrl(item.SupplementUrl);
|
||||
|
||||
var publisherName = item.Publisher;
|
||||
if (!string.IsNullOrWhiteSpace(publisherName))
|
||||
{
|
||||
var publisher = context.Contributors.Local.Single(c => publisherName == c.Name);
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
//
|
||||
// this was round 1 when it was a 2 step process
|
||||
//
|
||||
//// update series even for existing books. these are occasionally updated
|
||||
//var seriesIds = item.Series.Select(kvp => kvp.SeriesId).ToList();
|
||||
//var allSeries = context.Series.Local.Where(c => seriesIds.Contains(c.AudibleSeriesId)).ToList();
|
||||
//foreach (var series in allSeries)
|
||||
// book.UpsertSeries(series);
|
||||
|
||||
// these will upsert over library-scraped series, but will not leave orphans
|
||||
if (item.Series != null)
|
||||
{
|
||||
foreach (var seriesEntry in item.Series)
|
||||
{
|
||||
var series = context.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||
book.UpsertSeries(series, seriesEntry.Index);
|
||||
}
|
||||
}
|
||||
|
||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == item.Categories.LastOrDefault().CategoryId);
|
||||
if (category != null)
|
||||
book.UpdateCategory(category, context);
|
||||
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
||||
updateBook(item, book);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private Book createNewBook(ImportItem importItem)
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
// absence of authors is very rare, but possible
|
||||
if (!item.Authors?.Any() ?? true)
|
||||
item.Authors = new[] { new Person { Name = "", Asin = null } };
|
||||
|
||||
// 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
|
||||
.Authors
|
||||
.Select(a => DbContext.Contributors.Local.Single(c => a.Name == c.Name))
|
||||
.ToList();
|
||||
|
||||
var narrators
|
||||
= item.Narrators is null || !item.Narrators.Any()
|
||||
// if no narrators listed, author is the narrator
|
||||
? authors
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
: item
|
||||
.Narrators
|
||||
.Select(n => DbContext.Contributors.Local.Single(c => n.Name == c.Name))
|
||||
.ToList();
|
||||
|
||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||
// absence of categories is also possible
|
||||
|
||||
// CATEGORY HACK: only use the 1st 2 categories
|
||||
// (real impl: var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";)
|
||||
var lastCategory
|
||||
= item.Categories.Length == 0 ? ""
|
||||
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
|
||||
// 2+
|
||||
: item.Categories[1].CategoryId;
|
||||
|
||||
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
|
||||
|
||||
var book = DbContext.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.Title,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
authors,
|
||||
narrators,
|
||||
category,
|
||||
importItem.LocaleName)
|
||||
).Entity;
|
||||
|
||||
var publisherName = item.Publisher;
|
||||
if (!string.IsNullOrWhiteSpace(publisherName))
|
||||
{
|
||||
var publisher = DbContext.Contributors.Local.Single(c => publisherName == c.Name);
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
|
||||
book.AddSupplementDownloadUrl(item.SupplementUrl);
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
private void updateBook(ImportItem importItem, Book book)
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
book.PictureId = item.PictureId;
|
||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||
|
||||
// needed during v3 => v4 migration
|
||||
book.UpdateLocale(importItem.LocaleName);
|
||||
|
||||
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
||||
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
||||
|
||||
// update series even for existing books. these are occasionally updated
|
||||
// these will upsert over library-scraped series, but will not leave orphans
|
||||
if (item.Series != null)
|
||||
{
|
||||
foreach (var seriesEntry in item.Series)
|
||||
{
|
||||
var series = DbContext.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||
|
||||
var index = 0f;
|
||||
try
|
||||
{
|
||||
index = seriesEntry.Index;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Error parsing series index. Title: {item.Title}. ASIN: {item.Asin}. Series index: {seriesEntry.Sequence}");
|
||||
}
|
||||
|
||||
book.UpsertSeries(series, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,36 +9,47 @@ namespace DtoImporterService
|
||||
{
|
||||
public class CategoryImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new CategoryValidator().Validate(items);
|
||||
public CategoryImporter(LibationContext context) : base(context) { }
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new CategoryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var categoryIds = items.GetCategoriesDistinct().Select(c => c.CategoryId).ToList();
|
||||
var categoryIds = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetCategoriesDistinct()
|
||||
.Select(c => c.CategoryId).ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_categories(categoryIds, context);
|
||||
loadLocal_categories(categoryIds);
|
||||
|
||||
// upsert
|
||||
var categoryPairs = items.GetCategoryPairsDistinct().ToList();
|
||||
var qtyNew = upsertCategories(categoryPairs, context);
|
||||
var categoryPairs = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetCategoryPairsDistinct()
|
||||
.ToList();
|
||||
var qtyNew = upsertCategories(categoryPairs);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_categories(List<string> categoryIds, LibationContext context)
|
||||
private void loadLocal_categories(List<string> categoryIds)
|
||||
{
|
||||
var localIds = context.Categories.Local.Select(c => c.AudibleCategoryId);
|
||||
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId);
|
||||
var remainingCategoryIds = categoryIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
|
||||
// load existing => local
|
||||
// remember to include default/empty/missing
|
||||
var emptyName = Contributor.GetEmpty().Name;
|
||||
if (remainingCategoryIds.Any())
|
||||
context.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId)).ToList();
|
||||
DbContext.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId) || c.Name == emptyName).ToList();
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertCategories(List<Ladder[]> categoryPairs, LibationContext context)
|
||||
private int upsertCategories(List<Ladder[]> categoryPairs)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
@@ -46,17 +57,21 @@ namespace DtoImporterService
|
||||
{
|
||||
for (var i = 0; i < pair.Length; i++)
|
||||
{
|
||||
// CATEGORY HACK: not yet supported: depth beyond 0 and 1
|
||||
if (i > 1)
|
||||
break;
|
||||
|
||||
var id = pair[i].CategoryId;
|
||||
var name = pair[i].CategoryName;
|
||||
|
||||
Category parentCategory = null;
|
||||
if (i == 1)
|
||||
parentCategory = context.Categories.Local.Single(c => c.AudibleCategoryId == pair[0].CategoryId);
|
||||
parentCategory = DbContext.Categories.Local.Single(c => c.AudibleCategoryId == pair[0].CategoryId);
|
||||
|
||||
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == id);
|
||||
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == id);
|
||||
if (category is null)
|
||||
{
|
||||
category = context.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
|
||||
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,84 +9,89 @@ namespace DtoImporterService
|
||||
{
|
||||
public class ContributorImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new ContributorValidator().Validate(items);
|
||||
public ContributorImporter(LibationContext context) : base(context) { }
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new ContributorValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var authors = items.GetAuthorsDistinct().ToList();
|
||||
var narrators = items.GetNarratorsDistinct().ToList();
|
||||
var publishers = items.GetPublishersDistinct().ToList();
|
||||
var authors = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetAuthorsDistinct()
|
||||
.ToList();
|
||||
var narrators = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetNarratorsDistinct()
|
||||
.ToList();
|
||||
var publishers = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetPublishersDistinct()
|
||||
.ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
var allNames = authors
|
||||
.Select(a => a.Name)
|
||||
var allNames = publishers
|
||||
.Union(authors.Select(n => n.Name))
|
||||
.Union(narrators.Select(n => n.Name))
|
||||
.Union(publishers)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.ToList();
|
||||
loadLocal_contributors(allNames, context);
|
||||
loadLocal_contributors(allNames);
|
||||
|
||||
// upsert
|
||||
var qtyNew = 0;
|
||||
qtyNew += upsertPeople(authors, context);
|
||||
qtyNew += upsertPeople(narrators, context);
|
||||
qtyNew += upsertPublishers(publishers, context);
|
||||
qtyNew += upsertPeople(authors);
|
||||
qtyNew += upsertPeople(narrators);
|
||||
qtyNew += upsertPublishers(publishers);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_contributors(List<string> contributorNames, LibationContext context)
|
||||
private void loadLocal_contributors(List<string> contributorNames)
|
||||
{
|
||||
contributorNames.Remove(null);
|
||||
contributorNames.Remove("");
|
||||
|
||||
//// BAD: very inefficient
|
||||
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
|
||||
|
||||
// GOOD: Except() is efficient. Due to hashing, it's close to O(n)
|
||||
var localNames = context.Contributors.Local.Select(c => c.Name);
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name);
|
||||
var remainingContribNames = contributorNames
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
|
||||
// load existing => local
|
||||
// remember to include default/empty/missing
|
||||
var emptyName = Contributor.GetEmpty().Name;
|
||||
if (remainingContribNames.Any())
|
||||
context.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList();
|
||||
// _________________________________^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
// i tried to extract this pattern, but this part prohibits doing so
|
||||
// wouldn't work anyway for Books.GetBooks()
|
||||
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name) || c.Name == emptyName).ToList();
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPeople(List<Person> people, LibationContext context)
|
||||
private int upsertPeople(List<Person> people)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var p in people)
|
||||
{
|
||||
var person = context.Contributors.Local.SingleOrDefault(c => c.Name == p.Name);
|
||||
var person = DbContext.Contributors.Local.SingleOrDefault(c => c.Name == p.Name);
|
||||
if (person == null)
|
||||
{
|
||||
person = context.Contributors.Add(new Contributor(p.Name)).Entity;
|
||||
person = DbContext.Contributors.Add(new Contributor(p.Name, p.Asin)).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
person.UpdateAudibleAuthorId(p.Asin);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPublishers(List<string> publishers, LibationContext context)
|
||||
private int upsertPublishers(List<string> publishers)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var publisherName in publishers)
|
||||
{
|
||||
if (context.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
|
||||
if (DbContext.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
|
||||
{
|
||||
context.Contributors.Add(new Contributor(publisherName));
|
||||
DbContext.Contributors.Add(new Contributor(publisherName));
|
||||
qtyNew++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,44 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public interface IContextRunner<T>
|
||||
public abstract class ImporterBase<T>
|
||||
{
|
||||
public TResult Run<TResult>(Func<T, LibationContext, TResult> func, T param, LibationContext context = null)
|
||||
protected LibationContext DbContext { get; }
|
||||
|
||||
protected ImporterBase(LibationContext context)
|
||||
{
|
||||
if (context is null)
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
DbContext = context;
|
||||
}
|
||||
|
||||
/// <summary>LONG RUNNING. call with await Task.Run</summary>
|
||||
public int Import(T param) => Run(DoImport, param);
|
||||
|
||||
public TResult Run<TResult>(Func<T, TResult> func, T param)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (context = LibationContext.Create())
|
||||
{
|
||||
var r = Run(func, param, context);
|
||||
context.SaveChanges();
|
||||
return r;
|
||||
}
|
||||
var exceptions = Validate(param);
|
||||
if (exceptions != null && exceptions.Any())
|
||||
throw new AggregateException($"Importer validation failed", exceptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Import error: validation");
|
||||
throw;
|
||||
}
|
||||
|
||||
var exceptions = Validate(param);
|
||||
if (exceptions != null && exceptions.Any())
|
||||
throw new AggregateException($"Device Jobs Service configuration validation failed", exceptions);
|
||||
|
||||
var result = func(param, context);
|
||||
return result;
|
||||
try
|
||||
{
|
||||
var result = func(param);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Import error: post-validation importing");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
IEnumerable<Exception> Validate(T param);
|
||||
}
|
||||
|
||||
public abstract class ImporterBase<T> : IContextRunner<T>
|
||||
{
|
||||
/// <summary>LONG RUNNING. call with await Task.Run</summary>
|
||||
public int Import(T param, LibationContext context = null)
|
||||
=> ((IContextRunner<T>)this).Run(DoImport, param, context);
|
||||
|
||||
protected abstract int DoImport(T elements, LibationContext context);
|
||||
protected abstract int DoImport(T elements);
|
||||
public abstract IEnumerable<Exception> Validate(T param);
|
||||
}
|
||||
|
||||
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<Item>> { }
|
||||
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<ImportItem>>
|
||||
{
|
||||
protected ItemsImporterBase(LibationContext context) : base(context) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,30 +9,50 @@ namespace DtoImporterService
|
||||
{
|
||||
public class LibraryImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new LibraryValidator().Validate(items);
|
||||
public LibraryImporter(LibationContext context) : base(context) { }
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext 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().Import(items, context);
|
||||
new BookImporter(DbContext).Import(importItems);
|
||||
|
||||
var qtyNew = upsertLibraryBooks(items, context);
|
||||
var qtyNew = upsertLibraryBooks(importItems);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private int upsertLibraryBooks(IEnumerable<Item> items, LibationContext context)
|
||||
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
var currentLibraryProductIds = context.Library.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var newItems = items.Where(dto => !currentLibraryProductIds.Contains(dto.ProductId)).ToList();
|
||||
// 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(
|
||||
context.Books.Local.Single(b => b.AudibleProductId == newItem.ProductId),
|
||||
newItem.DateAdded
|
||||
// needed for scraping
|
||||
//,FileManager.FileUtility.RestoreDeclawed(newLibraryDTO.DownloadBookLink)
|
||||
);
|
||||
context.Library.Add(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;
|
||||
|
||||
@@ -9,44 +9,49 @@ namespace DtoImporterService
|
||||
{
|
||||
public class SeriesImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new SeriesValidator().Validate(items);
|
||||
public SeriesImporter(LibationContext context) : base(context) { }
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new SeriesValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var series = items.GetSeriesDistinct().ToList();
|
||||
var series = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetSeriesDistinct()
|
||||
.ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
var seriesIds = series.Select(s => s.SeriesId).ToList();
|
||||
loadLocal_series(seriesIds, context);
|
||||
loadLocal_series(series);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertSeries(series, context);
|
||||
var qtyNew = upsertSeries(series);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_series(List<string> seriesIds, LibationContext context)
|
||||
private void loadLocal_series(List<AudibleApiDTOs.Series> series)
|
||||
{
|
||||
var localIds = context.Series.Local.Select(s => s.AudibleSeriesId);
|
||||
var seriesIds = series.Select(s => s.SeriesId).ToList();
|
||||
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
|
||||
var remainingSeriesIds = seriesIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
|
||||
if (remainingSeriesIds.Any())
|
||||
context.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
|
||||
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries, LibationContext context)
|
||||
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var s in requestedSeries)
|
||||
{
|
||||
var series = context.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == s.SeriesId);
|
||||
var series = DbContext.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == s.SeriesId);
|
||||
if (series is null)
|
||||
{
|
||||
series = context.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
||||
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
series.UpdateName(s.SeriesName);
|
||||
|
||||
65
FileLiberator/BackupBook.cs
Normal file
65
FileLiberator/BackupBook.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
/// <summary>
|
||||
/// Download DRM book and decrypt audiobook files
|
||||
///
|
||||
/// Processes:
|
||||
/// Download: download aax file: the DRM encrypted audiobook
|
||||
/// Decrypt: remove DRM encryption from audiobook. Store final book
|
||||
/// Backup: perform all steps (downloaded, decrypt) still needed to get final book
|
||||
/// </summary>
|
||||
public class BackupBook : IProcessable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public DownloadBook DownloadBook { get; } = new DownloadBook();
|
||||
public DecryptBook DecryptBook { get; } = new DecryptBook();
|
||||
public DownloadPdf DownloadPdf { get; } = new DownloadPdf();
|
||||
|
||||
public bool Validate(LibraryBook libraryBook)
|
||||
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
|
||||
|
||||
// do NOT use ConfigureAwait(false) on ProcessAsync()
|
||||
// often calls events which prints to forms in the UI context
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
{
|
||||
var statusHandler = await DownloadBook.TryProcessAsync(libraryBook);
|
||||
if (statusHandler.HasErrors)
|
||||
return statusHandler;
|
||||
}
|
||||
|
||||
{
|
||||
var statusHandler = await DecryptBook.TryProcessAsync(libraryBook);
|
||||
if (statusHandler.HasErrors)
|
||||
return statusHandler;
|
||||
}
|
||||
|
||||
{
|
||||
var statusHandler = await DownloadPdf.TryProcessAsync(libraryBook);
|
||||
if (statusHandler.HasErrors)
|
||||
return statusHandler;
|
||||
}
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
205
FileLiberator/DecryptBook.cs
Normal file
205
FileLiberator/DecryptBook.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AaxDecrypter;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
/// <summary>
|
||||
/// Decrypt audiobook files
|
||||
///
|
||||
/// Processes:
|
||||
/// Download: download aax file: the DRM encrypted audiobook
|
||||
/// Decrypt: remove DRM encryption from audiobook. Store final book
|
||||
/// Backup: perform all steps (downloaded, decrypt) still needed to get final book
|
||||
/// </summary>
|
||||
public class DecryptBook : IDecryptable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
public event EventHandler<string> DecryptBegin;
|
||||
|
||||
public event EventHandler<string> TitleDiscovered;
|
||||
public event EventHandler<string> AuthorsDiscovered;
|
||||
public event EventHandler<string> NarratorsDiscovered;
|
||||
public event EventHandler<byte[]> CoverImageFilepathDiscovered;
|
||||
public event EventHandler<int> UpdateProgress;
|
||||
|
||||
public event EventHandler<string> DecryptCompleted;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public bool Validate(LibraryBook libraryBook)
|
||||
=> AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId)
|
||||
&& !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
|
||||
|
||||
// do NOT use ConfigureAwait(false) on ProcessAsync()
|
||||
// often calls events which prints to forms in the UI context
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
var aaxFilename = AudibleFileStorage.AAX.GetPath(libraryBook.Book.AudibleProductId);
|
||||
|
||||
if (aaxFilename == null)
|
||||
return new StatusHandler { "aaxFilename parameter is null" };
|
||||
if (!File.Exists(aaxFilename))
|
||||
return new StatusHandler { $"Cannot find AAX file: {aaxFilename}" };
|
||||
if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId))
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
var chapters = await downloadChapterNames(libraryBook);
|
||||
|
||||
var outputAudioFilename = await aaxToM4bConverterDecrypt(aaxFilename, libraryBook, chapters);
|
||||
|
||||
// decrypt failed
|
||||
if (outputAudioFilename == null)
|
||||
return new StatusHandler { "Decrypt failed" };
|
||||
|
||||
var destinationDir = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
|
||||
|
||||
var config = Configuration.Instance;
|
||||
if (config.RetainAaxFiles)
|
||||
{
|
||||
var newAaxFilename = FileUtility.GetValidFilename(
|
||||
destinationDir,
|
||||
Path.GetFileNameWithoutExtension(aaxFilename),
|
||||
"aax");
|
||||
File.Move(aaxFilename, newAaxFilename);
|
||||
}
|
||||
else
|
||||
{
|
||||
Dinah.Core.IO.FileExt.SafeDelete(aaxFilename);
|
||||
}
|
||||
|
||||
var finalAudioExists = AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
|
||||
if (!finalAudioExists)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> aaxToM4bConverterDecrypt(string aaxFilename, LibraryBook libraryBook, Chapters chapters = null)
|
||||
{
|
||||
DecryptBegin?.Invoke(this, $"Begin decrypting {aaxFilename}");
|
||||
|
||||
try
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
var account = persister
|
||||
.AccountsSettings
|
||||
.GetAccount(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
var converter = await AaxToM4bConverter.CreateAsync(aaxFilename, account.DecryptKey, chapters);
|
||||
converter.AppName = "Libation";
|
||||
|
||||
TitleDiscovered?.Invoke(this, converter.tags.title);
|
||||
AuthorsDiscovered?.Invoke(this, converter.tags.author);
|
||||
NarratorsDiscovered?.Invoke(this, converter.tags.narrator);
|
||||
CoverImageFilepathDiscovered?.Invoke(this, converter.coverBytes);
|
||||
|
||||
// override default which was set in CreateAsync
|
||||
var proposedOutputFile = Path.Combine(AudibleFileStorage.DecryptInProgress, $"[{libraryBook.Book.AudibleProductId}].m4b");
|
||||
converter.SetOutputFilename(proposedOutputFile);
|
||||
converter.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress);
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
var success = await Task.Run(() => converter.Run());
|
||||
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
return null;
|
||||
|
||||
account.DecryptKey = converter.decryptKey;
|
||||
|
||||
return converter.outputFileName;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DecryptCompleted?.Invoke(this, $"Completed decrypting {aaxFilename}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Chapters> downloadChapterNames(LibraryBook libraryBook)
|
||||
{
|
||||
try
|
||||
{
|
||||
var api = await AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
var contentMetadata = await api.GetLibraryBookMetadataAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
if (contentMetadata?.ChapterInfo != null)
|
||||
return new DownloadedChapters(contentMetadata.ChapterInfo);
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string moveFilesToBooksDir(Book product, string outputAudioFilename)
|
||||
{
|
||||
// create final directory. move each file into it. MOVE AUDIO FILE LAST
|
||||
// new dir: safetitle_limit50char + " [" + productId + "]"
|
||||
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestDir(product.Title, product.AudibleProductId);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
var sortedFiles = getProductFilesSorted(product, outputAudioFilename);
|
||||
|
||||
var musicFileExt = Path.GetExtension(outputAudioFilename).Trim('.');
|
||||
|
||||
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext
|
||||
var audioFileName = FileUtility.GetValidFilename(destinationDir, product.Title, musicFileExt, product.AudibleProductId);
|
||||
|
||||
foreach (var f in sortedFiles)
|
||||
{
|
||||
var dest
|
||||
= AudibleFileStorage.Audio.IsFileTypeMatch(f)
|
||||
? audioFileName
|
||||
// non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext
|
||||
: FileUtility.GetValidFilename(destinationDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt);
|
||||
|
||||
if (Path.GetExtension(dest).Trim('.').ToLower() == "cue")
|
||||
Cue.UpdateFileName(f, audioFileName);
|
||||
|
||||
File.Move(f.FullName, dest);
|
||||
}
|
||||
|
||||
return destinationDir;
|
||||
}
|
||||
|
||||
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
|
||||
{
|
||||
// files are: temp path\author\[asin].ext
|
||||
var m4bDir = new FileInfo(outputAudioFilename).Directory;
|
||||
var files = m4bDir
|
||||
.EnumerateFiles()
|
||||
.Where(f => f.Name.ContainsInsensitive(product.AudibleProductId))
|
||||
.ToList();
|
||||
|
||||
// move audio files to the end of the collection so these files are moved last
|
||||
var musicFiles = files.Where(f => AudibleFileStorage.Audio.IsFileTypeMatch(f));
|
||||
var sortedFiles = files
|
||||
.Except(musicFiles)
|
||||
.Concat(musicFiles)
|
||||
.ToList();
|
||||
|
||||
return sortedFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
122
FileLiberator/DownloadBook.cs
Normal file
122
FileLiberator/DownloadBook.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
/// <summary>
|
||||
/// Download DRM book
|
||||
///
|
||||
/// Processes:
|
||||
/// Download: download aax file: the DRM encrypted audiobook
|
||||
/// Decrypt: remove DRM encryption from audiobook. Store final book
|
||||
/// Backup: perform all steps (downloaded, decrypt) still needed to get final book
|
||||
/// </summary>
|
||||
public class DownloadBook : DownloadableBase
|
||||
{
|
||||
private const string SERVICE_UNAVAILABLE = "Content Delivery Companion Service is not available.";
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId)
|
||||
&& !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId);
|
||||
|
||||
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var tempAaxFilename = getDownloadPath(libraryBook);
|
||||
var actualFilePath = await downloadBookAsync(libraryBook, tempAaxFilename);
|
||||
moveBook(libraryBook, actualFilePath);
|
||||
return verifyDownload(libraryBook);
|
||||
}
|
||||
|
||||
private static string getDownloadPath(LibraryBook libraryBook)
|
||||
=> FileUtility.GetValidFilename(
|
||||
AudibleFileStorage.DownloadsInProgress,
|
||||
libraryBook.Book.Title,
|
||||
"aax",
|
||||
libraryBook.Book.AudibleProductId);
|
||||
|
||||
private async Task<string> downloadBookAsync(LibraryBook libraryBook, string tempAaxFilename)
|
||||
{
|
||||
validate(libraryBook);
|
||||
|
||||
var api = await GetApiAsync(libraryBook);
|
||||
|
||||
var actualFilePath = await PerformDownloadAsync(
|
||||
tempAaxFilename,
|
||||
(p) => api.DownloadAaxWorkaroundAsync(libraryBook.Book.AudibleProductId, tempAaxFilename, p));
|
||||
|
||||
System.Threading.Thread.Sleep(100);
|
||||
// if bad file download, a 0-33 byte file will be created
|
||||
// if service unavailable, a 52 byte string will be saved as file
|
||||
var length = new FileInfo(actualFilePath).Length;
|
||||
|
||||
if (length > 100)
|
||||
return actualFilePath;
|
||||
|
||||
var contents = File.ReadAllText(actualFilePath);
|
||||
File.Delete(actualFilePath);
|
||||
|
||||
var exMsg = contents.StartsWithInsensitive(SERVICE_UNAVAILABLE)
|
||||
? SERVICE_UNAVAILABLE
|
||||
: "Error downloading file";
|
||||
|
||||
var ex = new Exception(exMsg);
|
||||
Serilog.Log.Logger.Error(ex, "Download error {@DebugInfo}", new
|
||||
{
|
||||
libraryBook.Book.Title,
|
||||
libraryBook.Book.AudibleProductId,
|
||||
libraryBook.Book.Locale,
|
||||
Account = libraryBook.Account?.ToMask() ?? "[empty]",
|
||||
tempAaxFilename,
|
||||
actualFilePath,
|
||||
length,
|
||||
contents
|
||||
});
|
||||
throw ex;
|
||||
}
|
||||
|
||||
private static void validate(LibraryBook libraryBook)
|
||||
{
|
||||
string errorString(string field)
|
||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new Exception(errorString("Account"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new Exception(errorString("Locale"));
|
||||
}
|
||||
|
||||
private void moveBook(LibraryBook libraryBook, string actualFilePath)
|
||||
{
|
||||
var newAaxFilename = FileUtility.GetValidFilename(
|
||||
AudibleFileStorage.DownloadsFinal,
|
||||
libraryBook.Book.Title,
|
||||
"aax",
|
||||
libraryBook.Book.AudibleProductId);
|
||||
File.Move(actualFilePath, newAaxFilename);
|
||||
Invoke_StatusUpdate($"Successfully downloaded. Moved to: {newAaxFilename}");
|
||||
}
|
||||
|
||||
private static StatusHandler verifyDownload(LibraryBook libraryBook)
|
||||
=> !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId)
|
||||
? new StatusHandler { "Downloaded AAX file cannot be found" }
|
||||
: new StatusHandler();
|
||||
}
|
||||
}
|
||||
35
FileLiberator/DownloadFile.cs
Normal file
35
FileLiberator/DownloadFile.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
// frustratingly copy pasta from DownloadableBase and DownloadPdf
|
||||
public class DownloadFile : IDownloadable
|
||||
{
|
||||
public event EventHandler<string> DownloadBegin;
|
||||
public event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
public event EventHandler<string> DownloadCompleted;
|
||||
|
||||
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
|
||||
|
||||
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
FileLiberator/DownloadPdf.cs
Normal file
62
FileLiberator/DownloadPdf.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadPdf : DownloadableBase
|
||||
{
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
|
||||
&& !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId);
|
||||
|
||||
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
||||
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
return verifyDownload(libraryBook);
|
||||
}
|
||||
|
||||
private static StatusHandler verifyDownload(LibraryBook libraryBook)
|
||||
=> !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId)
|
||||
? new StatusHandler { "Downloaded PDF cannot be found" }
|
||||
: new StatusHandler();
|
||||
|
||||
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
||||
{
|
||||
// if audio file exists, get it's dir. else return base Book dir
|
||||
var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId));
|
||||
var file = getdownloadUrl(libraryBook);
|
||||
|
||||
if (existingPath != null)
|
||||
return Path.Combine(existingPath, Path.GetFileName(file));
|
||||
|
||||
var full = FileUtility.GetValidFilename(
|
||||
AudibleFileStorage.PDF.StorageDirectory,
|
||||
libraryBook.Book.Title,
|
||||
Path.GetExtension(file),
|
||||
libraryBook.Book.AudibleProductId);
|
||||
return full;
|
||||
}
|
||||
|
||||
private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
{
|
||||
var api = await GetApiAsync(libraryBook);
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
var client = new HttpClient();
|
||||
var actualDownloadedFilePath = await PerformDownloadAsync(
|
||||
proposedDownloadFilePath,
|
||||
(p) => client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, p));
|
||||
}
|
||||
|
||||
private static string getdownloadUrl(LibraryBook libraryBook)
|
||||
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
|
||||
}
|
||||
}
|
||||
64
FileLiberator/DownloadableBase.cs
Normal file
64
FileLiberator/DownloadableBase.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class DownloadableBase : IDownloadableProcessable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public event EventHandler<string> DownloadBegin;
|
||||
public event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
public event EventHandler<string> DownloadCompleted;
|
||||
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
protected void Invoke_StatusUpdate(string message) => StatusUpdate?.Invoke(this, message);
|
||||
|
||||
public abstract bool Validate(LibraryBook libraryBook);
|
||||
|
||||
public abstract Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook);
|
||||
|
||||
// do NOT use ConfigureAwait(false) on ProcessAsync()
|
||||
// often calls events which prints to forms in the UI context
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
return await ProcessItemAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
protected static Task<AudibleApi.Api> GetApiAsync(LibraryBook libraryBook)
|
||||
=> InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
protected async Task<string> PerformDownloadAsync(string proposedDownloadFilePath, Func<Progress<DownloadProgress>, Task<string>> func)
|
||||
{
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
|
||||
|
||||
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await func(progress);
|
||||
StatusUpdate?.Invoke(this, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
FileLiberator/DownloadedChapters.cs
Normal file
22
FileLiberator/DownloadedChapters.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AaxDecrypter;
|
||||
using AudibleApiDTOs;
|
||||
using Dinah.Core.Diagnostics;
|
||||
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadedChapters : Chapters
|
||||
{
|
||||
public DownloadedChapters(ChapterInfo chapterInfo)
|
||||
{
|
||||
AddChapters(chapterInfo.Chapters
|
||||
.Select(c => new AaxDecrypter.Chapter(c.StartOffsetMs / 1000d, (c.StartOffsetMs + c.LengthMs) / 1000d, c.Title)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
||||
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
|
||||
<ProjectReference Include="..\ApplicationService\ApplicationService.csproj" />
|
||||
<ProjectReference Include="..\AudibleDotComAutomation\AudibleDotComAutomation.csproj" />
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace ScrapingDomainServices
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IDecryptable : IProcessable
|
||||
{
|
||||
12
FileLiberator/IDownloadable.cs
Normal file
12
FileLiberator/IDownloadable.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IDownloadable
|
||||
{
|
||||
event EventHandler<string> DownloadBegin;
|
||||
event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
event EventHandler<string> DownloadCompleted;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user