diff --git a/AaxDecrypter/AaxDecrypter.csproj b/AaxDecrypter/AaxDecrypter.csproj
new file mode 100644
index 00000000..cdcc31dc
--- /dev/null
+++ b/AaxDecrypter/AaxDecrypter.csproj
@@ -0,0 +1,123 @@
+
+
+
+ netstandard2.1
+
+
+
+
+ lib\taglib-sharp.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+
+
diff --git a/AaxDecrypter/BytesCrackerLib/alglib1.dll b/AaxDecrypter/BytesCrackerLib/alglib1.dll
new file mode 100644
index 00000000..6da4ca52
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/alglib1.dll differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_0_10000x789935_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_0_10000x789935_0.rtc
new file mode 100644
index 00000000..6359b7a3
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_0_10000x789935_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_1_10000x791425_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_1_10000x791425_0.rtc
new file mode 100644
index 00000000..57aa2b11
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_1_10000x791425_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_2_10000x790991_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_2_10000x790991_0.rtc
new file mode 100644
index 00000000..0ab3c43a
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_2_10000x790991_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_3_10000x792120_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_3_10000x792120_0.rtc
new file mode 100644
index 00000000..8e0438c8
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_3_10000x792120_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_4_10000x790743_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_4_10000x790743_0.rtc
new file mode 100644
index 00000000..1e169c3e
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_4_10000x790743_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_5_10000x790568_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_5_10000x790568_0.rtc
new file mode 100644
index 00000000..77c2a193
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_5_10000x790568_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_6_10000x791458_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_6_10000x791458_0.rtc
new file mode 100644
index 00000000..4f7b21c9
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_6_10000x791458_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_7_10000x791707_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_7_10000x791707_0.rtc
new file mode 100644
index 00000000..f4b7b061
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_7_10000x791707_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_8_10000x790202_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_8_10000x790202_0.rtc
new file mode 100644
index 00000000..e279ee04
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_8_10000x790202_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_9_10000x791022_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_9_10000x791022_0.rtc
new file mode 100644
index 00000000..18e558f2
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_9_10000x791022_0.rtc differ
diff --git a/AaxDecrypter/BytesCrackerLib/ffmpeg.exe b/AaxDecrypter/BytesCrackerLib/ffmpeg.exe
new file mode 100644
index 00000000..57ceafbc
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/ffmpeg.exe differ
diff --git a/AaxDecrypter/BytesCrackerLib/ffprobe.exe b/AaxDecrypter/BytesCrackerLib/ffprobe.exe
new file mode 100644
index 00000000..42721143
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/ffprobe.exe differ
diff --git a/AaxDecrypter/BytesCrackerLib/rcrack.exe b/AaxDecrypter/BytesCrackerLib/rcrack.exe
new file mode 100644
index 00000000..a67724e5
Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/rcrack.exe differ
diff --git a/AaxDecrypter/DecryptLib/AtomicParsley.exe b/AaxDecrypter/DecryptLib/AtomicParsley.exe
new file mode 100644
index 00000000..fd840692
Binary files /dev/null and b/AaxDecrypter/DecryptLib/AtomicParsley.exe differ
diff --git a/AaxDecrypter/DecryptLib/avcodec-57.dll b/AaxDecrypter/DecryptLib/avcodec-57.dll
new file mode 100644
index 00000000..4efd87aa
Binary files /dev/null and b/AaxDecrypter/DecryptLib/avcodec-57.dll differ
diff --git a/AaxDecrypter/DecryptLib/avdevice-57.dll b/AaxDecrypter/DecryptLib/avdevice-57.dll
new file mode 100644
index 00000000..3b91e4bc
Binary files /dev/null and b/AaxDecrypter/DecryptLib/avdevice-57.dll differ
diff --git a/AaxDecrypter/DecryptLib/avfilter-6.dll b/AaxDecrypter/DecryptLib/avfilter-6.dll
new file mode 100644
index 00000000..7327e65f
Binary files /dev/null and b/AaxDecrypter/DecryptLib/avfilter-6.dll differ
diff --git a/AaxDecrypter/DecryptLib/avformat-57.dll b/AaxDecrypter/DecryptLib/avformat-57.dll
new file mode 100644
index 00000000..a5c286f0
Binary files /dev/null and b/AaxDecrypter/DecryptLib/avformat-57.dll differ
diff --git a/AaxDecrypter/DecryptLib/avutil-55.dll b/AaxDecrypter/DecryptLib/avutil-55.dll
new file mode 100644
index 00000000..9805d769
Binary files /dev/null and b/AaxDecrypter/DecryptLib/avutil-55.dll differ
diff --git a/AaxDecrypter/DecryptLib/cygcrypto-1.0.0.dll b/AaxDecrypter/DecryptLib/cygcrypto-1.0.0.dll
new file mode 100644
index 00000000..7a01721d
Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygcrypto-1.0.0.dll differ
diff --git a/AaxDecrypter/DecryptLib/cyggcc_s-1.dll b/AaxDecrypter/DecryptLib/cyggcc_s-1.dll
new file mode 100644
index 00000000..732e75a4
Binary files /dev/null and b/AaxDecrypter/DecryptLib/cyggcc_s-1.dll differ
diff --git a/AaxDecrypter/DecryptLib/cygmp4v2-2.dll b/AaxDecrypter/DecryptLib/cygmp4v2-2.dll
new file mode 100644
index 00000000..1d851fed
Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygmp4v2-2.dll differ
diff --git a/AaxDecrypter/DecryptLib/cygstdc++-6.dll b/AaxDecrypter/DecryptLib/cygstdc++-6.dll
new file mode 100644
index 00000000..9d4468dc
Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygstdc++-6.dll differ
diff --git a/AaxDecrypter/DecryptLib/cygwin1.dll b/AaxDecrypter/DecryptLib/cygwin1.dll
new file mode 100644
index 00000000..c96cd624
Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygwin1.dll differ
diff --git a/AaxDecrypter/DecryptLib/cygz.dll b/AaxDecrypter/DecryptLib/cygz.dll
new file mode 100644
index 00000000..874be5ec
Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygz.dll differ
diff --git a/AaxDecrypter/DecryptLib/ffmpeg.exe b/AaxDecrypter/DecryptLib/ffmpeg.exe
new file mode 100644
index 00000000..9d759ebc
Binary files /dev/null and b/AaxDecrypter/DecryptLib/ffmpeg.exe differ
diff --git a/AaxDecrypter/DecryptLib/ffprobe.exe b/AaxDecrypter/DecryptLib/ffprobe.exe
new file mode 100644
index 00000000..2863d735
Binary files /dev/null and b/AaxDecrypter/DecryptLib/ffprobe.exe differ
diff --git a/AaxDecrypter/DecryptLib/mp4trackdump.exe b/AaxDecrypter/DecryptLib/mp4trackdump.exe
new file mode 100644
index 00000000..98abb027
Binary files /dev/null and b/AaxDecrypter/DecryptLib/mp4trackdump.exe differ
diff --git a/AaxDecrypter/DecryptLib/postproc-54.dll b/AaxDecrypter/DecryptLib/postproc-54.dll
new file mode 100644
index 00000000..5ec9448e
Binary files /dev/null and b/AaxDecrypter/DecryptLib/postproc-54.dll differ
diff --git a/AaxDecrypter/DecryptLib/swresample-2.dll b/AaxDecrypter/DecryptLib/swresample-2.dll
new file mode 100644
index 00000000..591f8414
Binary files /dev/null and b/AaxDecrypter/DecryptLib/swresample-2.dll differ
diff --git a/AaxDecrypter/DecryptLib/swscale-4.dll b/AaxDecrypter/DecryptLib/swscale-4.dll
new file mode 100644
index 00000000..ec30e0bf
Binary files /dev/null and b/AaxDecrypter/DecryptLib/swscale-4.dll differ
diff --git a/AaxDecrypter/DecryptLib/taglib-sharp.dll b/AaxDecrypter/DecryptLib/taglib-sharp.dll
new file mode 100644
index 00000000..87ec4bf1
Binary files /dev/null and b/AaxDecrypter/DecryptLib/taglib-sharp.dll differ
diff --git a/AaxDecrypter/UNTESTED/AaxToM4bConverter.cs b/AaxDecrypter/UNTESTED/AaxToM4bConverter.cs
new file mode 100644
index 00000000..39458e9d
--- /dev/null
+++ b/AaxDecrypter/UNTESTED/AaxToM4bConverter.cs
@@ -0,0 +1,355 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Dinah.Core;
+using Dinah.Core.Diagnostics;
+using Dinah.Core.IO;
+using Dinah.Core.StepRunner;
+
+namespace AaxDecrypter
+{
+ public interface ISimpleAaxToM4bConverter
+ {
+ event EventHandler DecryptProgressUpdate;
+
+ bool Run();
+
+ string AppName { get; set; }
+ string inputFileName { get; }
+ byte[] coverBytes { get; }
+ string outDir { get; }
+ string outputFileName { get; }
+
+ Chapters chapters { get; }
+ Tags tags { get; }
+ EncodingInfo encodingInfo { get; }
+
+ void SetOutputFilename(string outFileName);
+ }
+ public interface IAdvancedAaxToM4bConverter : ISimpleAaxToM4bConverter
+ {
+ bool Step1_CreateDir();
+ bool Step2_DecryptAax();
+ bool Step3_Chapterize();
+ bool Step4_InsertCoverArt();
+ bool Step5_Cleanup();
+ bool Step6_AddTags();
+ bool End_CreateCue();
+ bool End_CreateNfo();
+ }
+ /// full c# app. integrated logging. no UI
+ public class AaxToM4bConverter : IAdvancedAaxToM4bConverter
+ {
+ public event EventHandler DecryptProgressUpdate;
+
+ public string inputFileName { get; }
+ public string decryptKey { get; private set; }
+
+ private StepSequence steps { get; }
+ public byte[] coverBytes { get; private set; }
+
+ public string AppName { get; set; } = nameof(AaxToM4bConverter);
+
+ public string outDir { get; private set; }
+ public string outputFileName { get; private set; }
+
+ public Chapters chapters { get; private set; }
+ public Tags tags { get; private set; }
+ public EncodingInfo encodingInfo { get; private set; }
+
+ public static async Task CreateAsync(string inputFile, string decryptKey)
+ {
+ var converter = new AaxToM4bConverter(inputFile, decryptKey);
+ await converter.prelimProcessing();
+ converter.printPrelim();
+
+ return converter;
+ }
+ 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");
+
+ steps = new StepSequence
+ {
+ Name = "Convert Aax To M4b",
+
+ ["Step 1: Create Dir"] = Step1_CreateDir,
+ ["Step 2: Decrypt Aax"] = Step2_DecryptAax,
+ ["Step 3: Chapterize and tag"] = Step3_Chapterize,
+ ["Step 4: Insert Cover Art"] = Step4_InsertCoverArt,
+ ["Step 5: Cleanup"] = Step5_Cleanup,
+ ["Step 6: Add Tags"] = Step6_AddTags,
+ ["End: Create Cue"] = End_CreateCue,
+ ["End: Create Nfo"] = End_CreateNfo
+ };
+
+ this.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);
+
+ var defaultFilename = Path.Combine(
+ Path.GetDirectoryName(this.inputFileName),
+ getASCIITag(this.tags.author),
+ getASCIITag(this.tags.title) + ".m4b"
+ );
+ 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;
+ }
+
+ private void printPrelim()
+ {
+ 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");
+ }
+
+ public bool Run()
+ {
+ var (IsSuccess, Elapsed) = steps.Run();
+
+ if (!IsSuccess)
+ {
+ Console.WriteLine("WARNING-Conversion failed");
+ return false;
+ }
+
+ var speedup = (int)(tags.duration.TotalSeconds / (long)Elapsed.TotalSeconds);
+ Console.WriteLine("Speedup is " + speedup + "x realtime.");
+ Console.WriteLine("Done");
+ return true;
+ }
+
+ public void SetOutputFilename(string outFileName)
+ {
+ this.outputFileName = outFileName;
+
+ if (Path.GetExtension(this.outputFileName) != ".m4b")
+ this.outputFileName = outputFileWithNewExt(".m4b");
+
+ this.outDir = Path.GetDirectoryName(this.outputFileName);
+ }
+
+ private string outputFileWithNewExt(string extension)
+ => Path.Combine(this.outDir, Path.GetFileNameWithoutExtension(this.outputFileName) + '.' + extension.Trim('.'));
+
+ public bool Step1_CreateDir()
+ {
+ ProcessRunner.WorkingDir = this.outDir;
+ Directory.CreateDirectory(this.outDir);
+ return true;
+ }
+
+ public bool Step2_DecryptAax()
+ {
+ DecryptProgressUpdate?.Invoke(this, 0);
+
+ var tempRipFile = Path.Combine(this.outDir, "funny.aac");
+
+ var fail = "WARNING-Decrypt failure. ";
+
+ int returnCode;
+ if (string.IsNullOrWhiteSpace(decryptKey))
+ {
+ returnCode = getKey_decrypt(tempRipFile);
+ }
+ else
+ {
+ returnCode = decrypt(tempRipFile);
+ if (returnCode == -99)
+ {
+ Console.WriteLine($"{fail}Incorrect decrypt key: {decryptKey}");
+ this.decryptKey = null;
+ returnCode = getKey_decrypt(tempRipFile);
+ }
+ }
+
+ if (returnCode == 100)
+ Console.WriteLine($"{fail}Thread completed without changing return code. This shouldn't be possible");
+ else if (returnCode == 0)
+ {
+ // success!
+ FileExt.SafeMove(tempRipFile, outputFileWithNewExt(".mp4"));
+ DecryptProgressUpdate?.Invoke(this, 100);
+ return true;
+ }
+ else if (returnCode == -99)
+ Console.WriteLine($"{fail}Incorrect decrypt key: {decryptKey}");
+ else // any other returnCode
+ Console.WriteLine($"{fail}Unknown failure code: {returnCode}");
+
+ FileExt.SafeDelete(tempRipFile);
+ DecryptProgressUpdate?.Invoke(this, 0);
+ return false;
+ }
+
+ private int getKey_decrypt(string tempRipFile)
+ {
+ getKey();
+ return decrypt(tempRipFile);
+ }
+ private void getKey()
+ {
+ Console.WriteLine("Discovering decrypt key");
+
+ Console.WriteLine("Getting file hash");
+ var checksum = BytesCracker.GetChecksum(inputFileName);
+ Console.WriteLine("File hash calculated: " + checksum);
+
+ Console.WriteLine("Cracking activation bytes");
+ var activation_bytes = BytesCracker.GetActivationBytes(checksum);
+ this.decryptKey = activation_bytes;
+ Console.WriteLine("Activation bytes cracked. Decrypt key: " + activation_bytes);
+ }
+
+ private int decrypt(string tempRipFile)
+ {
+ FileExt.SafeDelete(tempRipFile);
+
+ Console.WriteLine("Decrypting with key " + decryptKey);
+
+ var returnCode = 100;
+ var thread = new Thread(() => returnCode = this.ngDecrypt());
+ thread.Start();
+
+ double fileLen = new FileInfo(this.inputFileName).Length;
+ while (thread.IsAlive && returnCode == 100)
+ {
+ Thread.Sleep(500);
+ if (File.Exists(tempRipFile))
+ {
+ double tempLen = new FileInfo(tempRipFile).Length;
+ var percentProgress = tempLen / fileLen * 100.0;
+ DecryptProgressUpdate?.Invoke(this, (int)percentProgress);
+ }
+ }
+
+ return returnCode;
+ }
+
+ private int ngDecrypt()
+ {
+ var info = new ProcessStartInfo
+ {
+ FileName = DecryptSupportLibraries.mp4trackdumpPath,
+ Arguments = "-c " + this.encodingInfo.channels + " -r " + this.encodingInfo.sampleRate + " \"" + this.inputFileName + "\""
+ };
+ info.EnvironmentVariables["VARIABLE"] = decryptKey;
+
+ var (output, exitCode) = info.RunHidden();
+
+ // bad checksum -- bad decrypt key
+ if (output.Contains("checksums mismatch, aborting!"))
+ return -99;
+
+ return exitCode;
+ }
+
+ // temp file names for steps 3, 4, 5
+ string tempChapsPath => Path.Combine(this.outDir, "tempChaps.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)
+ {
+ str1 = " -ss " + this.chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (this.chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " ";
+ }
+
+ string ffmpegTags = this.tags.GenerateFfmpegTags();
+ string ffmpegChapters = this.chapters.GenerateFfmpegChapters();
+ File.WriteAllText(ff_txt_file, ffmpegTags + ffmpegChapters);
+
+ var tagAndChapterInfo = new ProcessStartInfo
+ {
+ FileName = DecryptSupportLibraries.ffmpegPath,
+ Arguments = "-y -i \"" + mp4_file + "\" -f ffmetadata -i \"" + ff_txt_file + "\" -map_metadata 1 -bsf:a aac_adtstoasc -c:a copy" + str1 + " -map 0 \"" + tempChapsPath + "\""
+ };
+ tagAndChapterInfo.RunHidden();
+
+ return true;
+ }
+
+ 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 insertCoverArtInfo = new ProcessStartInfo
+ {
+ FileName = DecryptSupportLibraries.atomicParsleyPath,
+ Arguments = "\"" + tempChapsPath + "\" --encodingTool \"" + AppName + "\" --artwork \"" + coverPath + "\" --overWrite"
+ };
+ insertCoverArtInfo.RunHidden();
+
+ // delete temp file
+ FileExt.SafeDelete(coverPath);
+
+ return true;
+ }
+
+ public bool Step5_Cleanup()
+ {
+ FileExt.SafeDelete(mp4_file);
+ FileExt.SafeDelete(ff_txt_file);
+ FileExt.SafeMove(tempChapsPath, this.outputFileName);
+
+ return true;
+ }
+
+ public bool Step6_AddTags()
+ {
+ this.tags.AddAppleTags(this.outputFileName);
+ return true;
+ }
+
+ public bool End_CreateCue()
+ {
+ File.WriteAllText(outputFileWithNewExt(".cue"), this.chapters.GetCuefromChapters(Path.GetFileName(this.outputFileName)));
+ return true;
+ }
+
+ public bool End_CreateNfo()
+ {
+ File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, this.tags, this.encodingInfo, this.chapters));
+ return true;
+ }
+ }
+}
diff --git a/AaxDecrypter/UNTESTED/BytesCracker.cs b/AaxDecrypter/UNTESTED/BytesCracker.cs
new file mode 100644
index 00000000..24cffbac
--- /dev/null
+++ b/AaxDecrypter/UNTESTED/BytesCracker.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using Dinah.Core;
+using Dinah.Core.Diagnostics;
+
+namespace AaxDecrypter
+{
+ public static class BytesCracker
+ {
+ public static string GetChecksum(string aaxPath)
+ {
+ var info = new ProcessStartInfo
+ {
+ FileName = BytesCrackerSupportLibraries.ffprobePath,
+ Arguments = aaxPath.SurroundWithQuotes(),
+ WorkingDirectory = Directory.GetCurrentDirectory()
+ };
+
+ // 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;
+
+ // example checksum line:
+ // ... [aax] file checksum == 0c527840c4f18517157eb0b4f9d6f9317ce60cd1
+ var checksum = ffprobeStderr.ExtractString("file checksum == ", 40);
+
+ return checksum;
+ }
+
+ /// use checksum to get activation bytes. activation bytes are unique per audible customer. only have to do this 1x/customer
+ public static string GetActivationBytes(string checksum)
+ {
+ var info = new ProcessStartInfo
+ {
+ FileName = BytesCrackerSupportLibraries.rcrackPath,
+ Arguments = @". -h " + checksum,
+ WorkingDirectory = Directory.GetCurrentDirectory()
+ };
+
+ var rcrackStdout = info.RunHidden().Output;
+
+ // example result
+ // 0c527840c4f18517157eb0b4f9d6f9317ce60cd1 \xbd\x89X\x09 hex:bd895809
+ var activation_bytes = rcrackStdout.ExtractString("hex:", 8);
+
+ return activation_bytes;
+ }
+ }
+}
diff --git a/AaxDecrypter/UNTESTED/BytesCrackerSupportLibraries.cs b/AaxDecrypter/UNTESTED/BytesCrackerSupportLibraries.cs
new file mode 100644
index 00000000..256b97cb
--- /dev/null
+++ b/AaxDecrypter/UNTESTED/BytesCrackerSupportLibraries.cs
@@ -0,0 +1,28 @@
+using System.IO;
+
+namespace AaxDecrypter
+{
+ public static class BytesCrackerSupportLibraries
+ {
+ // GetActivationBytes dependencies
+ // rcrack.exe
+ // alglib1.dll
+ // RainbowCrack files to recover your own Audible activation data (activation_bytes) in an offline manner
+ // audible_byte#4-4_0_10000x789935_0.rtc
+ // audible_byte#4-4_1_10000x791425_0.rtc
+ // audible_byte#4-4_2_10000x790991_0.rtc
+ // audible_byte#4-4_3_10000x792120_0.rtc
+ // audible_byte#4-4_4_10000x790743_0.rtc
+ // audible_byte#4-4_5_10000x790568_0.rtc
+ // audible_byte#4-4_6_10000x791458_0.rtc
+ // audible_byte#4-4_7_10000x791707_0.rtc
+ // audible_byte#4-4_8_10000x790202_0.rtc
+ // audible_byte#4-4_9_10000x791022_0.rtc
+
+ private static string appPath_ { get; } = Path.GetDirectoryName(Dinah.Core.Exe.FileLocationOnDisk);
+ private static string bytesCrackerLib_ { get; } = Path.Combine(appPath_, "BytesCrackerLib");
+
+ public static string ffprobePath { get; } = Path.Combine(bytesCrackerLib_, "ffprobe.exe");
+ public static string rcrackPath { get; } = Path.Combine(bytesCrackerLib_, "rcrack.exe");
+ }
+}
diff --git a/AaxDecrypter/UNTESTED/Chapters.cs b/AaxDecrypter/UNTESTED/Chapters.cs
new file mode 100644
index 00000000..e92f563e
--- /dev/null
+++ b/AaxDecrypter/UNTESTED/Chapters.cs
@@ -0,0 +1,95 @@
+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 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 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()
+ .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();
+ }
+ }
+}
diff --git a/AaxDecrypter/UNTESTED/DecryptSupportLibraries.cs b/AaxDecrypter/UNTESTED/DecryptSupportLibraries.cs
new file mode 100644
index 00000000..66238676
--- /dev/null
+++ b/AaxDecrypter/UNTESTED/DecryptSupportLibraries.cs
@@ -0,0 +1,21 @@
+using System.IO;
+
+namespace AaxDecrypter
+{
+ public static class DecryptSupportLibraries
+ {
+ // OTHER EXTERNAL DEPENDENCIES
+ // ffprobe has these pre-req.s as I'm using it:
+ // avcodec-57.dll, avdevice-57.dll, avfilter-6.dll, avformat-57.dll, avutil-55.dll, postproc-54.dll, swresample-2.dll, swscale-4.dll, taglib-sharp.dll
+ //
+ // something else needs the cygwin files (cyg*.dll)
+
+ private static string appPath_ { get; } = Path.GetDirectoryName(Dinah.Core.Exe.FileLocationOnDisk);
+ private static string decryptLib_ { get; } = Path.Combine(appPath_, "DecryptLib");
+
+ public static string ffmpegPath { get; } = Path.Combine(decryptLib_, "ffmpeg.exe");
+ public static string ffprobePath { get; } = Path.Combine(decryptLib_, "ffprobe.exe");
+ public static string atomicParsleyPath { get; } = Path.Combine(decryptLib_, "AtomicParsley.exe");
+ public static string mp4trackdumpPath { get; } = Path.Combine(decryptLib_, "mp4trackdump.exe");
+ }
+}
diff --git a/AaxDecrypter/UNTESTED/EncodingInfo.cs b/AaxDecrypter/UNTESTED/EncodingInfo.cs
new file mode 100644
index 00000000..d09a0fb1
--- /dev/null
+++ b/AaxDecrypter/UNTESTED/EncodingInfo.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Diagnostics;
+using Dinah.Core.Diagnostics;
+
+namespace AaxDecrypter
+{
+ public class EncodingInfo
+ {
+ public int sampleRate { get; } = 44100;
+ public int channels { get; } = 2;
+ public int originalBitrate { get; }
+
+ public EncodingInfo(string file)
+ {
+ var info = new ProcessStartInfo
+ {
+ FileName = DecryptSupportLibraries.ffprobePath,
+ Arguments = "-loglevel panic -show_streams -print_format flat \"" + file + "\""
+ };
+ var end = info.RunHidden().Output;
+
+ foreach (string str2 in end.Split('\n'))
+ {
+ string[] strArray = str2.Split('=');
+ switch (strArray[0])
+ {
+ case "streams.stream.0.channels":
+ this.channels = int.Parse(strArray[1].Replace("\"", "").TrimEnd('\r', '\n'));
+ break;
+ case "streams.stream.0.sample_rate":
+ this.sampleRate = int.Parse(strArray[1].Replace("\"", "").TrimEnd('\r', '\n'));
+ break;
+ case "streams.stream.0.bit_rate":
+ string s = strArray[1].Replace("\"", "").TrimEnd('\r', '\n');
+ this.originalBitrate = (int)Math.Round(double.Parse(s) / 1000.0, MidpointRounding.AwayFromZero);
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/AaxDecrypter/UNTESTED/NFO.cs b/AaxDecrypter/UNTESTED/NFO.cs
new file mode 100644
index 00000000..4e80a1ef
--- /dev/null
+++ b/AaxDecrypter/UNTESTED/NFO.cs
@@ -0,0 +1,56 @@
+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;
+ }
+ }
+}
diff --git a/AaxDecrypter/UNTESTED/Tags.cs b/AaxDecrypter/UNTESTED/Tags.cs
new file mode 100644
index 00000000..be2f4023
--- /dev/null
+++ b/AaxDecrypter/UNTESTED/Tags.cs
@@ -0,0 +1,78 @@
+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();
+ }
+ }
+}
diff --git a/AudibleDotCom/AudibleDotCom.csproj b/AudibleDotCom/AudibleDotCom.csproj
new file mode 100644
index 00000000..a1930702
--- /dev/null
+++ b/AudibleDotCom/AudibleDotCom.csproj
@@ -0,0 +1,11 @@
+
+
+
+ netstandard2.1
+
+
+
+
+
+
+
diff --git a/AudibleDotCom/UNTESTED/AudiblePage.cs b/AudibleDotCom/UNTESTED/AudiblePage.cs
new file mode 100644
index 00000000..4b993cd3
--- /dev/null
+++ b/AudibleDotCom/UNTESTED/AudiblePage.cs
@@ -0,0 +1,66 @@
+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
+ {
+ // useful for generic classes:
+ // public abstract class PageScraper 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);
+
+ /// For pages which need a param, the param is marked with {0}
+ protected abstract string Url { get; }
+ public string GetUrl(string id) => string.Format(Url, id);
+
+ public string Abbreviation => DisplayName;
+ }
+ public abstract partial class AudiblePage : Enumeration
+ {
+ 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
+ {
+ 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}";
+ }
+ }
+}
diff --git a/AudibleDotCom/UNTESTED/AudiblePageSource.cs b/AudibleDotCom/UNTESTED/AudiblePageSource.cs
new file mode 100644
index 00000000..05bc5bbc
--- /dev/null
+++ b/AudibleDotCom/UNTESTED/AudiblePageSource.cs
@@ -0,0 +1,43 @@
+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;
+ }
+
+ /// 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
+ public AudiblePageSource Declawed() => new AudiblePageSource(AudiblePage, FileUtility.Declaw(Source), PageId);
+
+ public string Serialized() => $"\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);
+ }
+ }
+}
diff --git a/AudibleDotComAutomation/AudibleDotComAutomation.csproj b/AudibleDotComAutomation/AudibleDotComAutomation.csproj
new file mode 100644
index 00000000..35a53f2b
--- /dev/null
+++ b/AudibleDotComAutomation/AudibleDotComAutomation.csproj
@@ -0,0 +1,23 @@
+
+
+
+ netstandard2.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/Abstract_SeleniumRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/Abstract_SeleniumRetriever.cs
new file mode 100644
index 00000000..29354f96
--- /dev/null
+++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/Abstract_SeleniumRetriever.cs
@@ -0,0 +1,184 @@
+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
+{
+ /// 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
+ 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());
+ }
+
+ /// no auth. retain no chrome cookies
+ 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> 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> processUrl(AudiblePageType audiblePage, string pageId)
+ {
+ var pageSources = new List();
+ do
+ {
+ pageSources.Add(new AudiblePageSource(audiblePage, Driver.PageSource, pageId));
+ }
+ while (await hasMorePagesAsync());
+
+ return pageSources;
+ }
+
+ #region has more pages
+ /// if no more pages, return false. else, navigate to next page and return true
+ private async Task 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
+ }
+}
diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/AuthSeleniumRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/AuthSeleniumRetriever.cs
new file mode 100644
index 00000000..5e32f4b4
--- /dev/null
+++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/AuthSeleniumRetriever.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using OpenQA.Selenium;
+
+namespace AudibleDotComAutomation
+{
+ /// for user collections: lib, WL
+ 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;
+ }
+ }
+}
diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/BrowserlessRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/BrowserlessRetriever.cs
new file mode 100644
index 00000000..9224dd66
--- /dev/null
+++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/BrowserlessRetriever.cs
@@ -0,0 +1,130 @@
+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> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null)
+ {
+ switch (audiblePage)
+ {
+case AudiblePageType.Library: return await getLibraryPageSourcesAsync();
+ default: throw new NotImplementedException();
+ }
+ }
+
+ private async Task> getLibraryPageSourcesAsync()
+ {
+ var collection = new List();
+
+ 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 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:
+ //
+ // only page: ???
+ // has more pages:
+ //
+ 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 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() { }
+ }
+}
diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/ManualLoginSeleniumRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/ManualLoginSeleniumRetriever.cs
new file mode 100644
index 00000000..caa00502
--- /dev/null
+++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/ManualLoginSeleniumRetriever.cs
@@ -0,0 +1,75 @@
+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
+{
+ /// online. get auth by logging in with provided username and password
+ /// retain no chrome cookies. enter user + pw login
+ 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");
+ }
+ }
+}
diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/UserDataSeleniumRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/UserDataSeleniumRetriever.cs
new file mode 100644
index 00000000..33219974
--- /dev/null
+++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/UserDataSeleniumRetriever.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using OpenQA.Selenium.Chrome;
+
+namespace AudibleDotComAutomation
+{
+ /// online. load auth, cookies etc from user data
+ 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");
+ }
+
+ /// Use current user data/chrome cookies. DO NOT use if chrome is already open
+ 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;
+ }
+ }
+}
diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/_IPageRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/_IPageRetriever.cs
new file mode 100644
index 00000000..70ca5596
--- /dev/null
+++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/_IPageRetriever.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AudibleDotCom;
+
+namespace AudibleDotComAutomation
+{
+ public interface IPageRetriever : IDisposable
+ {
+ Task> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null);
+ }
+}
diff --git a/AudibleDotComAutomation/UNTESTED/Selenium.Examples.cs b/AudibleDotComAutomation/UNTESTED/Selenium.Examples.cs
new file mode 100644
index 00000000..03a509c6
--- /dev/null
+++ b/AudibleDotComAutomation/UNTESTED/Selenium.Examples.cs
@@ -0,0 +1,115 @@
+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()
+ {
+ //
+ // | 1 |
+ // 2 |
+ //
+ //
+ // | 3 |
+ // 4 |
+ //
+
+ ReadOnlyCollection 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"));
+
+ //
+ 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
+ {
+ public void Add(IRuleClass ruleClass) { }
+ public void Add(RuleAction action) { }
+
+ public IEnumerator 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
+ }
+}
diff --git a/AudibleDotComAutomation/UNTESTED/SeleniumExt.cs b/AudibleDotComAutomation/UNTESTED/SeleniumExt.cs
new file mode 100644
index 00000000..386cecbb
--- /dev/null
+++ b/AudibleDotComAutomation/UNTESTED/SeleniumExt.cs
@@ -0,0 +1,47 @@
+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
+ {
+ /// Use this instead of element.Click() to ensure that the element is clicked even if it's not currently scrolled into view
+ 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})");
+ }
+}
diff --git a/AudibleDotComAutomation/chromedriver.exe b/AudibleDotComAutomation/chromedriver.exe
new file mode 100644
index 00000000..f8b34f1b
Binary files /dev/null and b/AudibleDotComAutomation/chromedriver.exe differ
diff --git a/CookieMonster/CookieMonster.csproj b/CookieMonster/CookieMonster.csproj
new file mode 100644
index 00000000..84fe44ec
--- /dev/null
+++ b/CookieMonster/CookieMonster.csproj
@@ -0,0 +1,16 @@
+
+
+
+ netstandard2.1
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CookieMonster/UNTESTED/Browsers/Chrome.cs b/CookieMonster/UNTESTED/Browsers/Chrome.cs
new file mode 100644
index 00000000..fb0034c3
--- /dev/null
+++ b/CookieMonster/UNTESTED/Browsers/Chrome.cs
@@ -0,0 +1,65 @@
+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> GetAllCookiesAsync()
+ {
+ var col = new List();
+
+ 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);
+ }
+
+ col.Add(new CookieValue { Browser = "chrome", Domain = host_key, Name = name, Value = value, LastAccess = chromeTimeToDateTimeUtc(last_access_utc), Expires = chromeTimeToDateTimeUtc(expires_utc) });
+ }
+ }
+ }
+
+ 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);
+ }
+}
diff --git a/CookieMonster/UNTESTED/Browsers/FireFox.cs b/CookieMonster/UNTESTED/Browsers/FireFox.cs
new file mode 100644
index 00000000..086f9426
--- /dev/null
+++ b/CookieMonster/UNTESTED/Browsers/FireFox.cs
@@ -0,0 +1,65 @@
+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> GetAllCookiesAsync()
+ {
+ var col = new List();
+
+ 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);
+ }
+}
diff --git a/CookieMonster/UNTESTED/Browsers/IBrowser.cs b/CookieMonster/UNTESTED/Browsers/IBrowser.cs
new file mode 100644
index 00000000..cb37cece
--- /dev/null
+++ b/CookieMonster/UNTESTED/Browsers/IBrowser.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace CookieMonster
+{
+ internal interface IBrowser
+ {
+ Task> GetAllCookiesAsync();
+ }
+}
diff --git a/CookieMonster/UNTESTED/Browsers/InternetExplorer.cs b/CookieMonster/UNTESTED/Browsers/InternetExplorer.cs
new file mode 100644
index 00000000..39f95c7f
--- /dev/null
+++ b/CookieMonster/UNTESTED/Browsers/InternetExplorer.cs
@@ -0,0 +1,87 @@
+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> 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> getIECookiesAsync(string strPath)
+ {
+ var cookies = new List();
+
+ var files = await Task.Run(() => Directory.EnumerateFiles(strPath, "*.txt"));
+ foreach (string path in files)
+ {
+ var cookiesInFile = new List();
+
+ 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));
+ }
+}
diff --git a/CookieMonster/UNTESTED/CookieValue.cs b/CookieMonster/UNTESTED/CookieValue.cs
new file mode 100644
index 00000000..c1fdde97
--- /dev/null
+++ b/CookieMonster/UNTESTED/CookieValue.cs
@@ -0,0 +1,32 @@
+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;
+ }
+}
diff --git a/CookieMonster/UNTESTED/CookiesHelper.cs b/CookieMonster/UNTESTED/CookiesHelper.cs
new file mode 100644
index 00000000..3e7ad1f7
--- /dev/null
+++ b/CookieMonster/UNTESTED/CookiesHelper.cs
@@ -0,0 +1,57 @@
+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 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();
+
+ /// all. including expired
+ public static async Task> GetAllCookieValuesAsync()
+ {
+ //// foreach{await} runs in serial
+ //var allCookies = new List();
+ //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;
+ }
+
+ /// not expired
+ public static async Task> GetLiveCookieValuesAsync()
+ => (await GetAllCookieValuesAsync().ConfigureAwait(false))
+ .Where(c => !c.HasExpired)
+ .ToList();
+ }
+}
diff --git a/DataLayer/DataLayer.csproj b/DataLayer/DataLayer.csproj
new file mode 100644
index 00000000..4252ca1c
--- /dev/null
+++ b/DataLayer/DataLayer.csproj
@@ -0,0 +1,38 @@
+
+
+
+ netcoreapp3.0;netstandard2.1
+
+
+
+ true
+
+ Library
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/DataLayer/UNTESTED/Commands/RemoveOrphans.cs b/DataLayer/UNTESTED/Commands/RemoveOrphans.cs
new file mode 100644
index 00000000..60fd1b4d
--- /dev/null
+++ b/DataLayer/UNTESTED/Commands/RemoveOrphans.cs
@@ -0,0 +1,19 @@
+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.ExecuteSqlCommand(@"
+ 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
+ ");
+ }
+}
diff --git a/DataLayer/UNTESTED/Configurations/BookConfig.cs b/DataLayer/UNTESTED/Configurations/BookConfig.cs
new file mode 100644
index 00000000..d9e00457
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/BookConfig.cs
@@ -0,0 +1,62 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class BookConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(b => b.BookId);
+ entity.HasIndex(b => b.AudibleProductId);
+
+ entity.OwnsOne(b => b.Rating);
+
+ //
+ // CRUCIAL: ignore unmapped collections, even get-only
+ //
+ entity.Ignore(nameof(Book.Authors));
+ entity.Ignore(nameof(Book.Narrators));
+ //// these don't seem to matter
+ //entity.Ignore(nameof(Book.AuthorNames));
+ //entity.Ignore(nameof(Book.NarratorNames));
+ //entity.Ignore(nameof(Book.HasPdfs));
+
+ // 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."
+ entity.OwnsMany(b => b.Supplements);
+ // even though it's owned, we need to map its backing field
+ entity
+ .Metadata
+ .FindNavigation(nameof(Book.Supplements))
+ .SetPropertyAccessMode(PropertyAccessMode.Field);
+
+ // owns it 1:1, but store in separate table
+ entity
+ .OwnsOne(b => b.UserDefinedItem, b_udi => b_udi.ToTable(nameof(Book.UserDefinedItem)));
+ // UserDefinedItem must link back to book so we know how to log changed tags.
+ // ie: when a tag changes, we need to get the parent book's product id
+ entity
+ .HasOne(b => b.UserDefinedItem)
+ .WithOne(udi => udi.Book)
+ .HasForeignKey(udi => udi.BookId);
+
+ entity
+ .Metadata
+ .FindNavigation(nameof(Book.ContributorsLink))
+ // PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
+ .SetPropertyAccessMode(PropertyAccessMode.Field);
+
+ entity
+ .Metadata
+ .FindNavigation(nameof(Book.SeriesLink))
+ // PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field
+ .SetPropertyAccessMode(PropertyAccessMode.Field);
+
+ entity
+ .HasOne(b => b.Category)
+ .WithMany()
+ .HasForeignKey(b => b.CategoryId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DataLayer/UNTESTED/Configurations/BookContributorConfig.cs b/DataLayer/UNTESTED/Configurations/BookContributorConfig.cs
new file mode 100644
index 00000000..f3bc09c7
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/BookContributorConfig.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class BookContributorConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(bc => new { bc.BookId, bc.ContributorId, bc.Role });
+
+ entity.HasIndex(b => b.BookId);
+ entity.HasIndex(b => b.ContributorId);
+
+ entity
+ .HasOne(bc => bc.Book)
+ .WithMany(b => b.ContributorsLink)
+ .HasForeignKey(bc => bc.BookId);
+ entity
+ .HasOne(bc => bc.Contributor)
+ .WithMany(c => c.BooksLink)
+ .HasForeignKey(bc => bc.ContributorId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DataLayer/UNTESTED/Configurations/CategoryConfig.cs b/DataLayer/UNTESTED/Configurations/CategoryConfig.cs
new file mode 100644
index 00000000..3b1fcde7
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/CategoryConfig.cs
@@ -0,0 +1,14 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class CategoryConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(c => c.CategoryId);
+ entity.HasIndex(c => c.AudibleCategoryId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DataLayer/UNTESTED/Configurations/ContributorConfig.cs b/DataLayer/UNTESTED/Configurations/ContributorConfig.cs
new file mode 100644
index 00000000..581b4e01
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/ContributorConfig.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class ContributorConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(c => c.ContributorId);
+ entity.HasIndex(c => c.Name);
+
+ //entity.OwnsOne(b => b.AuthorProperty);
+ // ... in separate table
+
+ entity
+ .Metadata
+ .FindNavigation(nameof(Contributor.BooksLink))
+ .SetPropertyAccessMode(PropertyAccessMode.Field);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DataLayer/UNTESTED/Configurations/LibraryBookConfig.cs b/DataLayer/UNTESTED/Configurations/LibraryBookConfig.cs
new file mode 100644
index 00000000..b66f5ea6
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/LibraryBookConfig.cs
@@ -0,0 +1,18 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class LibraryBookConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(b => b.BookId);
+
+ entity
+ .HasOne(le => le.Book)
+ .WithOne()
+ .HasForeignKey(le => le.BookId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DataLayer/UNTESTED/Configurations/SeriesBookConfig.cs b/DataLayer/UNTESTED/Configurations/SeriesBookConfig.cs
new file mode 100644
index 00000000..c848fa9a
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/SeriesBookConfig.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class SeriesBookConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(bc => new { bc.SeriesId, bc.BookId });
+
+ entity.HasIndex(b => b.SeriesId);
+ entity.HasIndex(b => b.BookId);
+
+ entity
+ .HasOne(sb => sb.Series)
+ .WithMany(s => s.BooksLink)
+ .HasForeignKey(sb => sb.SeriesId);
+ entity
+ .HasOne(sb => sb.Book)
+ .WithMany(b => b.SeriesLink)
+ .HasForeignKey(sb => sb.BookId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DataLayer/UNTESTED/Configurations/SeriesConfig.cs b/DataLayer/UNTESTED/Configurations/SeriesConfig.cs
new file mode 100644
index 00000000..7d067cf4
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/SeriesConfig.cs
@@ -0,0 +1,19 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class SeriesConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(b => b.SeriesId);
+ entity.HasIndex(b => b.AudibleSeriesId);
+
+ entity
+ .Metadata
+ .FindNavigation(nameof(Series.BooksLink))
+ .SetPropertyAccessMode(PropertyAccessMode.Field);
+ }
+ }
+}
\ No newline at end of file
diff --git a/DataLayer/UNTESTED/Configurations/SupplementConfig.cs b/DataLayer/UNTESTED/Configurations/SupplementConfig.cs
new file mode 100644
index 00000000..6106090a
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/SupplementConfig.cs
@@ -0,0 +1,17 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class SupplementConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.HasKey(s => s.SupplementId);
+ entity
+ .HasOne(s => s.Book)
+ .WithMany(b => b.Supplements)
+ .HasForeignKey(s => s.BookId);
+ }
+ }
+}
diff --git a/DataLayer/UNTESTED/Configurations/UserDefinedItemConfig.cs b/DataLayer/UNTESTED/Configurations/UserDefinedItemConfig.cs
new file mode 100644
index 00000000..ead79725
--- /dev/null
+++ b/DataLayer/UNTESTED/Configurations/UserDefinedItemConfig.cs
@@ -0,0 +1,13 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace DataLayer.Configurations
+{
+ internal class UserDefinedItemConfig : IEntityTypeConfiguration
+ {
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.OwnsOne(p => p.Rating);
+ }
+ }
+}
diff --git a/DataLayer/UNTESTED/EfClasses/Book.cs b/DataLayer/UNTESTED/EfClasses/Book.cs
new file mode 100644
index 00000000..adc2a8ee
--- /dev/null
+++ b/DataLayer/UNTESTED/EfClasses/Book.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Dinah.Core;
+using Microsoft.EntityFrameworkCore;
+
+namespace DataLayer
+{
+ public class AudibleProductId
+ {
+ public string Id { get; }
+ public AudibleProductId(string id)
+ {
+ ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id));
+ Id = id;
+ }
+ }
+ public class Book
+ {
+ // implementation detail. set by db only. only used by data layer
+ internal int BookId { get; private set; }
+
+ // immutable
+ public string AudibleProductId { get; private set; }
+ public string Title { get; private set; }
+ public string Description { get; private set; }
+ public int LengthInMinutes { 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; }
+
+ // non-null. use "empty pattern"
+ internal int CategoryId { get; private set; }
+ public Category Category { get; private set; }
+ public string[] CategoriesNames
+ => Category == null ? new string[0]
+ : Category.ParentCategory == null ? new[] { Category.Name }
+ : new[] { Category.ParentCategory.Name, Category.Name };
+ public string[] CategoriesIds
+ => Category == null ? null
+ : Category.ParentCategory == null ? new[] { Category.AudibleCategoryId }
+ : new[] { Category.ParentCategory.AudibleCategoryId, Category.AudibleCategoryId };
+
+ // is owned, not optional 1:1
+ public UserDefinedItem UserDefinedItem { get; private set; }
+
+ // is owned, not optional 1:1
+ /// The product's aggregate community rating
+ public Rating Rating { get; private set; } = new Rating(0, 0, 0);
+
+ // ef-ctor
+ private Book() { }
+ // non-ef ctor
+ /// special id class b/c it's too easy to get string order mixed up
+ public Book(
+ AudibleProductId audibleProductId,
+ string title,
+ string description,
+ int lengthInMinutes,
+ IEnumerable authors,
+ IEnumerable narrators)
+ {
+ // validate
+ ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
+ var productId = audibleProductId.Id;
+ ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
+ ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title));
+
+ // non-ef-ctor init.s
+ UserDefinedItem = new UserDefinedItem(this);
+ _contributorsLink = new HashSet();
+ _seriesLink = new HashSet();
+ _supplements = new HashSet();
+
+ // since category/id is never null, nullity means it hasn't been loaded
+ CategoryId = Category.GetEmpty().CategoryId;
+
+ // 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) };
+ }
+
+ #region contributors, authors, narrators
+ // use uninitialised backing fields - this means we can detect if the collection was loaded
+ private HashSet _contributorsLink;
+ // i'd like this to be internal but migration throws this exception when i try:
+ // Value cannot be null.
+ // Parameter name: property
+ public IEnumerable ContributorsLink
+ => _contributorsLink?
+ .OrderBy(bc => bc.Order)
+ .ToList();
+
+ public IEnumerable Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList();
+ public string AuthorNames => string.Join(", ", Authors.Select(a => a.Name));
+
+ public IEnumerable Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList();
+ public string NarratorNames => string.Join(", ", Narrators.Select(n => n.Name));
+
+ public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name;
+
+ public void ReplaceAuthors(IEnumerable authors, DbContext context = null)
+ => replaceContributors(authors, Role.Author, context);
+ public void ReplaceNarrators(IEnumerable narrators, DbContext context = null)
+ => replaceContributors(narrators, Role.Narrator, context);
+ public void ReplacePublisher(Contributor publisher, DbContext context = null)
+ => replaceContributors(new List { publisher }, Role.Publisher, context);
+ private void replaceContributors(IEnumerable newContributors, Role role, DbContext context = null)
+ {
+ 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");
+
+ context.Entry(this).Collection(s => s.ContributorsLink).Load();
+ }
+
+ var roleContributions = getContributions(role);
+ var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
+ if (isIdentical)
+ return;
+
+ _contributorsLink.RemoveWhere(bc => bc.Role == role);
+ addNewContributors(newContributors, role);
+ }
+ private void addNewContributors(IEnumerable newContributors, Role role)
+ {
+ byte order = 0;
+ var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
+ var newContributions = new HashSet(newContributionsEnum);
+ _contributorsLink.UnionWith(newContributions);
+ }
+
+ private List getContributions(Role role)
+ => ContributorsLink
+ .Where(a => a.Role == role)
+ .OrderBy(a => a.Order)
+ .ToList();
+ #endregion
+
+ #region series
+ private HashSet _seriesLink;
+ public IEnumerable SeriesLink => _seriesLink?.ToList();
+ public string SeriesNames
+ {
+ get
+ {
+ // first: alphabetical by name
+ var withNames = _seriesLink
+ .Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
+ .Select(s => s.Series.Name)
+ .OrderBy(a => a)
+ .ToList();
+ // then un-named are alpha by series id
+ var nullNames = _seriesLink
+ .Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
+ .Select(s => s.Series.AudibleSeriesId)
+ .OrderBy(a => a)
+ .ToList();
+
+ var all = withNames.Union(nullNames).ToList();
+ return string.Join(", ", all);
+ }
+ }
+
+ public void UpsertSeries(Series series, float? index = null, DbContext context = null)
+ {
+ ArgumentValidator.EnsureNotNull(series, nameof(series));
+
+ // 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");
+
+ context.Entry(this).Collection(s => s.SeriesLink).Load();
+ }
+
+ var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
+ if (singleSeriesBook == null)
+ _seriesLink.Add(new SeriesBook(series, this, index));
+ else
+ singleSeriesBook.UpdateIndex(index);
+ }
+ #endregion
+
+ #region supplements
+ private HashSet _supplements;
+ public IEnumerable Supplements => _supplements?.ToList();
+ public bool HasPdfs => 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."
+
+ ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
+ url = FileManager.FileUtility.RestoreDeclawed(url);
+
+ if (!_supplements.Any(s => url.EqualsInsensitive(url)))
+ _supplements.Add(new Supplement(this, url));
+ }
+ #endregion
+
+ public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
+ => Rating.Update(overallRating, performanceRating, storyRating);
+
+ public void UpdateBookDetails(bool isAbridged, DateTime? datePublished)
+ {
+ // 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;
+ }
+
+ if (context == null)
+ throw new Exception("need context");
+
+ context.Entry(this).Reference(s => s.Category).Load();
+ Category = category;
+ }
+ }
+}
diff --git a/DataLayer/UNTESTED/EfClasses/BookContributor.cs b/DataLayer/UNTESTED/EfClasses/BookContributor.cs
new file mode 100644
index 00000000..56e5e020
--- /dev/null
+++ b/DataLayer/UNTESTED/EfClasses/BookContributor.cs
@@ -0,0 +1,27 @@
+using Dinah.Core;
+
+namespace DataLayer
+{
+ public class BookContributor
+ {
+ internal int BookId { get; private set; }
+ internal int ContributorId { get; private set; }
+ public Role Role { get; private set; }
+ public byte Order { get; private set; }
+
+ public Book Book { get; private set; }
+ public Contributor Contributor { get; private set; }
+
+ private BookContributor() { }
+ internal BookContributor(Book book, Contributor contributor, Role role, byte order)
+ {
+ ArgumentValidator.EnsureNotNull(book, nameof(book));
+ ArgumentValidator.EnsureNotNull(contributor, nameof(contributor));
+
+ Book = book;
+ Contributor = contributor;
+ Role = role;
+ Order = order;
+ }
+ }
+}
diff --git a/DataLayer/UNTESTED/EfClasses/Category.cs b/DataLayer/UNTESTED/EfClasses/Category.cs
new file mode 100644
index 00000000..db8af11c
--- /dev/null
+++ b/DataLayer/UNTESTED/EfClasses/Category.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Dinah.Core;
+using Microsoft.EntityFrameworkCore;
+
+namespace DataLayer
+{
+ public class AudibleCategoryId
+ {
+ public string Id { get; }
+ public AudibleCategoryId(string id)
+ {
+ ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id));
+ Id = id;
+ }
+ }
+ 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;
+
+ internal int CategoryId { get; private set; }
+ public string AudibleCategoryId { get; private set; }
+
+ public string Name { get; private set; }
+ public Category ParentCategory { get; private set; }
+
+ private Category() { }
+ /// special id class b/c it's too easy to get string order mixed up
+ public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null)
+ {
+ ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
+ var id = audibleSeriesId.Id;
+ ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id));
+ ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
+
+ AudibleCategoryId = id;
+ Name = name;
+
+ UpdateParentCategory(parentCategory);
+ }
+
+ public void UpdateParentCategory(Category parentCategory)
+ {
+ // don't overwrite with null but not an error
+ if (parentCategory != null)
+ ParentCategory = parentCategory;
+ }
+ }
+}
diff --git a/DataLayer/UNTESTED/EfClasses/Contributor.cs b/DataLayer/UNTESTED/EfClasses/Contributor.cs
new file mode 100644
index 00000000..587f38c6
--- /dev/null
+++ b/DataLayer/UNTESTED/EfClasses/Contributor.cs
@@ -0,0 +1,82 @@
+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 _booksLink;
+ public IEnumerable BooksLink => _booksLink?.ToList();
+
+ private Contributor() { }
+ public Contributor(string name)
+ {
+ ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
+
+ _booksLink = new HashSet();
+
+ 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
+ ///// Most authors in Audible have a unique id
+ //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
+ }
+}
diff --git a/DataLayer/UNTESTED/EfClasses/LibraryBook.cs b/DataLayer/UNTESTED/EfClasses/LibraryBook.cs
new file mode 100644
index 00000000..3146e921
--- /dev/null
+++ b/DataLayer/UNTESTED/EfClasses/LibraryBook.cs
@@ -0,0 +1,25 @@
+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; }
+
+ /// For downloading AAX file
+ public string DownloadBookLink { get; private set; }
+
+ private LibraryBook() { }
+ public LibraryBook(Book book, DateTime dateAdded, string downloadBookLink)
+ {
+ ArgumentValidator.EnsureNotNull(book, nameof(book));
+ Book = book;
+ DateAdded = dateAdded;
+ DownloadBookLink = downloadBookLink;
+ }
+ }
+}
diff --git a/DataLayer/UNTESTED/EfClasses/Rating.cs b/DataLayer/UNTESTED/EfClasses/Rating.cs
new file mode 100644
index 00000000..22adb22b
--- /dev/null
+++ b/DataLayer/UNTESTED/EfClasses/Rating.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using Dinah.Core;
+
+namespace DataLayer
+{
+ /// Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable
+ public class Rating : ValueObject_Static
+ {
+ public float OverallRating { get; private set; }
+ public float PerformanceRating { get; private set; }
+ public float StoryRating { get; private set; }
+
+ private Rating() { }
+ internal Rating(float overallRating, float performanceRating, float storyRating)
+ {
+ OverallRating = overallRating;
+ PerformanceRating = performanceRating;
+ StoryRating = storyRating;
+ }
+
+ // EF magically tracks this owned object. by replacing it with a new() immutable object, stuff gets weird. update instead
+ internal void Update(float overallRating, float performanceRating, float storyRating)
+ {
+ // don't overwrite with all 0
+ if (overallRating + performanceRating + storyRating == 0)
+ return;
+
+ OverallRating = overallRating;
+ PerformanceRating = performanceRating;
+ StoryRating = storyRating;
+ }
+
+ protected override IEnumerable