mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-24 06:28:02 -05:00
Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d91e02db29 | ||
|
|
cd604d03b1 | ||
|
|
d5cd569319 | ||
|
|
a58f51a8ce | ||
|
|
d24c10ddf5 | ||
|
|
a12391f0ab | ||
|
|
60f1d8117d | ||
|
|
20b6f28cb5 | ||
|
|
9a1fa89f6f | ||
|
|
2a294f4f85 | ||
|
|
0938c84929 | ||
|
|
99cc6a6425 | ||
|
|
0025825d5c | ||
|
|
81b6833118 | ||
|
|
a51e76d44d | ||
|
|
755a7338e9 | ||
|
|
56732a5365 | ||
|
|
dd3b032b21 | ||
|
|
dd25792864 | ||
|
|
6979ab4450 | ||
|
|
4b31207f91 | ||
|
|
84a847a838 | ||
|
|
6900a68b9d | ||
|
|
743644c4e9 | ||
|
|
e9e380dbe6 | ||
|
|
515dfceb73 | ||
|
|
3941906d72 | ||
|
|
6407d15fe0 | ||
|
|
be84fb317e | ||
|
|
3af010c1f5 | ||
|
|
714bb2ba50 | ||
|
|
2e5360f0ba | ||
|
|
258775ff3f | ||
|
|
82318ffab7 | ||
|
|
901572e7bb | ||
|
|
cfa938360a | ||
|
|
80017ce9fd | ||
|
|
c67972a327 | ||
|
|
57ee150d3c | ||
|
|
57302e1b5c | ||
|
|
09dbc67914 | ||
|
|
b768362eae | ||
|
|
04a32533cb | ||
|
|
1ad2135a3f | ||
|
|
643ae09b2b | ||
|
|
8391e43b03 | ||
|
|
8a54eda4a0 | ||
|
|
e0406378cb | ||
|
|
e1299331cc | ||
|
|
248b336867 | ||
|
|
b7d96ae447 | ||
|
|
8ab2af1c5d | ||
|
|
2d459bb2cf | ||
|
|
aeb0d2a82b | ||
|
|
f50dab94a4 | ||
|
|
efa5cefa23 | ||
|
|
2e4a97fde7 | ||
|
|
2f241806fa | ||
|
|
e417f60a36 | ||
|
|
b00f2bd908 | ||
|
|
220cda42e7 | ||
|
|
f992a7ec64 | ||
|
|
c54c45df33 | ||
|
|
a8b9e187e6 | ||
|
|
53f252e56f | ||
|
|
2827bc8904 | ||
|
|
98a775fc5a | ||
|
|
f28a729d36 | ||
|
|
00a6a4bf50 | ||
|
|
fdefa7c3bf | ||
|
|
244862299f | ||
|
|
4decf9d3b7 | ||
|
|
83f538d304 | ||
|
|
9e0e06e436 | ||
|
|
f27ac279b2 | ||
|
|
ed03fd2451 | ||
|
|
ccb60ae367 | ||
|
|
6ad541c199 | ||
|
|
9606acda26 | ||
|
|
9abb9e376d | ||
|
|
f93498bfe3 | ||
|
|
a13e1f27bb | ||
|
|
c7c1b4505b | ||
|
|
d9e0f1aedf | ||
|
|
d8a0124b68 | ||
|
|
79e0a8fba7 | ||
|
|
8497987967 | ||
|
|
717fefd2c0 | ||
|
|
066cae8e33 | ||
|
|
9083574a77 | ||
|
|
6b1ab9c777 | ||
|
|
3be7c87c8e | ||
|
|
8694d3206b | ||
|
|
f67f3805c6 | ||
|
|
612dd41b4b | ||
|
|
13378a482d | ||
|
|
352b498c23 | ||
|
|
3a652cfb70 | ||
|
|
93e9ce31ba | ||
|
|
69ed7767b2 | ||
|
|
6fcaa8d551 | ||
|
|
15ece43463 | ||
|
|
25f5f0ed14 | ||
|
|
de66e5b405 | ||
|
|
73c671b7c0 | ||
|
|
4994684690 | ||
|
|
6c757773f7 | ||
|
|
2d0af587d5 | ||
|
|
c7891dc448 | ||
|
|
95ae8335a1 | ||
|
|
2fa5170f28 | ||
|
|
123a32ff9b | ||
|
|
41620352e8 | ||
|
|
54eea8ddae | ||
|
|
bcc237c693 | ||
|
|
65dc273e12 | ||
|
|
7bb4853903 | ||
|
|
f9917d4064 | ||
|
|
0f9f0d9eae | ||
|
|
498aeaac3a | ||
|
|
9534969c2d | ||
|
|
b120bb8a66 | ||
|
|
f8a51f0882 | ||
|
|
7529fdf878 | ||
|
|
f1aacd92ad | ||
|
|
b1b426427c | ||
|
|
0683e5f55b | ||
|
|
5c81441f83 | ||
|
|
57bc74cd23 | ||
|
|
1cecd4ba2e | ||
|
|
7a4bd639fb | ||
|
|
87e6a46808 | ||
|
|
a2e30df51f | ||
|
|
c8e759c067 | ||
|
|
6c9074169a | ||
|
|
1375da2065 | ||
|
|
d5d72a13f6 | ||
|
|
a1ba324166 | ||
|
|
b0139c47be | ||
|
|
80b0ef600d | ||
|
|
f3128b562d | ||
|
|
6734dec55c | ||
|
|
b9314ac678 | ||
|
|
e319326c30 | ||
|
|
5474446f62 | ||
|
|
d53a617bc8 | ||
|
|
9076fae6f6 | ||
|
|
5d4a97cdc4 | ||
|
|
bbe745f487 | ||
|
|
47360c036d | ||
|
|
e69df2abbc | ||
|
|
88d49acdad | ||
|
|
01a914c390 | ||
|
|
0b42b8ee49 | ||
|
|
c598576683 | ||
|
|
b126eed028 | ||
|
|
3020a116cf | ||
|
|
88b9ea2f2d | ||
|
|
159c04c4b1 | ||
|
|
fad0f021ed | ||
|
|
52f21dcab1 | ||
|
|
a6b89ca4c5 | ||
|
|
650c00cf66 | ||
|
|
089edf934e | ||
|
|
efe2b19e24 | ||
|
|
c41dc9a6db | ||
|
|
707cb78dbc | ||
|
|
fc0d97d8e7 | ||
|
|
1494a15a6e | ||
|
|
ac0de2a05e | ||
|
|
3cc80b6a24 | ||
|
|
38b04be6ba | ||
|
|
0c52d443b2 | ||
|
|
aa0ebac50e | ||
|
|
debebf6ee0 | ||
|
|
9034288e7c | ||
|
|
19ee02ced4 | ||
|
|
33723d7412 | ||
|
|
a01a67e34a | ||
|
|
ecdb510513 | ||
|
|
0b08bb3c4a | ||
|
|
22e5dbf83d | ||
|
|
3b33648267 | ||
|
|
8709518cd7 | ||
|
|
3da1dff4d8 | ||
|
|
6aa544b322 | ||
|
|
bd993b4e4d | ||
|
|
4f7b66d64e | ||
|
|
df90fc5361 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -328,3 +328,8 @@ ASALocalRun/
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
|
||||
# manually ignored files
|
||||
/__TODO.txt
|
||||
/DataLayer/LibationContext.db
|
||||
|
||||
@@ -72,8 +72,9 @@ namespace AaxDecrypter
|
||||
}
|
||||
private AaxToM4bConverter(string inputFile, string decryptKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentNullException(nameof(inputFile), "Input file may not be null or whitespace");
|
||||
if (!File.Exists(inputFile)) throw new ArgumentNullException(nameof(inputFile), "File does not exist");
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(inputFile, nameof(inputFile));
|
||||
if (!File.Exists(inputFile))
|
||||
throw new ArgumentNullException(nameof(inputFile), "File does not exist");
|
||||
|
||||
steps = new StepSequence
|
||||
{
|
||||
@@ -89,22 +90,24 @@ namespace AaxDecrypter
|
||||
["End: Create Nfo"] = End_CreateNfo
|
||||
};
|
||||
|
||||
this.inputFileName = inputFile;
|
||||
inputFileName = inputFile;
|
||||
this.decryptKey = decryptKey;
|
||||
}
|
||||
|
||||
private async Task prelimProcessing()
|
||||
{
|
||||
this.tags = new Tags(this.inputFileName);
|
||||
this.encodingInfo = new EncodingInfo(this.inputFileName);
|
||||
this.chapters = new Chapters(this.inputFileName, this.tags.duration.TotalSeconds);
|
||||
tags = new Tags(inputFileName);
|
||||
encodingInfo = new EncodingInfo(inputFileName);
|
||||
chapters = new Chapters(inputFileName, tags.duration.TotalSeconds);
|
||||
|
||||
var defaultFilename = Path.Combine(
|
||||
Path.GetDirectoryName(this.inputFileName),
|
||||
getASCIITag(this.tags.author),
|
||||
getASCIITag(this.tags.title) + ".m4b"
|
||||
Path.GetDirectoryName(inputFileName),
|
||||
getASCIITag(tags.author),
|
||||
getASCIITag(tags.title) + ".m4b"
|
||||
);
|
||||
SetOutputFilename(defaultFilename);
|
||||
|
||||
// set default name
|
||||
SetOutputFilename(defaultFilename);
|
||||
|
||||
await Task.Run(() => saveCover(inputFileName));
|
||||
}
|
||||
@@ -118,7 +121,7 @@ namespace AaxDecrypter
|
||||
private void saveCover(string aaxFile)
|
||||
{
|
||||
using var file = TagLib.File.Create(aaxFile, "audio/mp4", TagLib.ReadStyle.Average);
|
||||
this.coverBytes = file.Tag.Pictures[0].Data.Data;
|
||||
coverBytes = file.Tag.Pictures[0].Data.Data;
|
||||
}
|
||||
|
||||
private void printPrelim()
|
||||
@@ -156,21 +159,24 @@ namespace AaxDecrypter
|
||||
|
||||
public void SetOutputFilename(string outFileName)
|
||||
{
|
||||
this.outputFileName = outFileName;
|
||||
outputFileName = outFileName;
|
||||
|
||||
if (Path.GetExtension(this.outputFileName) != ".m4b")
|
||||
this.outputFileName = outputFileWithNewExt(".m4b");
|
||||
if (Path.GetExtension(outputFileName) != ".m4b")
|
||||
outputFileName = outputFileWithNewExt(".m4b");
|
||||
|
||||
this.outDir = Path.GetDirectoryName(this.outputFileName);
|
||||
if (File.Exists(outputFileName))
|
||||
File.Delete(outputFileName);
|
||||
|
||||
outDir = Path.GetDirectoryName(outputFileName);
|
||||
}
|
||||
|
||||
private string outputFileWithNewExt(string extension)
|
||||
=> Path.Combine(this.outDir, Path.GetFileNameWithoutExtension(this.outputFileName) + '.' + extension.Trim('.'));
|
||||
=> Path.Combine(outDir, Path.GetFileNameWithoutExtension(outputFileName) + '.' + extension.Trim('.'));
|
||||
|
||||
public bool Step1_CreateDir()
|
||||
{
|
||||
ProcessRunner.WorkingDir = this.outDir;
|
||||
Directory.CreateDirectory(this.outDir);
|
||||
ProcessRunner.WorkingDir = outDir;
|
||||
Directory.CreateDirectory(outDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -178,7 +184,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
DecryptProgressUpdate?.Invoke(this, 0);
|
||||
|
||||
var tempRipFile = Path.Combine(this.outDir, "funny.aac");
|
||||
var tempRipFile = Path.Combine(outDir, "funny.aac");
|
||||
|
||||
var fail = "WARNING-Decrypt failure. ";
|
||||
|
||||
@@ -193,7 +199,7 @@ namespace AaxDecrypter
|
||||
if (returnCode == -99)
|
||||
{
|
||||
Console.WriteLine($"{fail}Incorrect decrypt key: {decryptKey}");
|
||||
this.decryptKey = null;
|
||||
decryptKey = null;
|
||||
returnCode = getKey_decrypt(tempRipFile);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +238,7 @@ namespace AaxDecrypter
|
||||
|
||||
Console.WriteLine("Cracking activation bytes");
|
||||
var activation_bytes = BytesCracker.GetActivationBytes(checksum);
|
||||
this.decryptKey = activation_bytes;
|
||||
decryptKey = activation_bytes;
|
||||
Console.WriteLine("Activation bytes cracked. Decrypt key: " + activation_bytes);
|
||||
}
|
||||
|
||||
@@ -243,10 +249,10 @@ namespace AaxDecrypter
|
||||
Console.WriteLine("Decrypting with key " + decryptKey);
|
||||
|
||||
var returnCode = 100;
|
||||
var thread = new Thread(() => returnCode = this.ngDecrypt());
|
||||
var thread = new Thread(() => returnCode = ngDecrypt());
|
||||
thread.Start();
|
||||
|
||||
double fileLen = new FileInfo(this.inputFileName).Length;
|
||||
double fileLen = new FileInfo(inputFileName).Length;
|
||||
while (thread.IsAlive && returnCode == 100)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
@@ -266,34 +272,35 @@ namespace AaxDecrypter
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = DecryptSupportLibraries.mp4trackdumpPath,
|
||||
Arguments = "-c " + this.encodingInfo.channels + " -r " + this.encodingInfo.sampleRate + " \"" + this.inputFileName + "\""
|
||||
Arguments = "-c " + encodingInfo.channels + " -r " + encodingInfo.sampleRate + " \"" + inputFileName + "\""
|
||||
};
|
||||
info.EnvironmentVariables["VARIABLE"] = decryptKey;
|
||||
|
||||
var (output, exitCode) = info.RunHidden();
|
||||
var result = info.RunHidden();
|
||||
|
||||
// bad checksum -- bad decrypt key
|
||||
if (output.Contains("checksums mismatch, aborting!"))
|
||||
if (result.Output.Contains("checksums mismatch, aborting!"))
|
||||
return -99;
|
||||
|
||||
return exitCode;
|
||||
return result.ExitCode;
|
||||
}
|
||||
|
||||
// temp file names for steps 3, 4, 5
|
||||
string tempChapsPath => Path.Combine(this.outDir, "tempChaps.mp4");
|
||||
// temp file names for steps 3, 4, 5
|
||||
string tempChapsGuid { get; } = Guid.NewGuid().ToString().ToUpper().Replace("-", "");
|
||||
string tempChapsPath => Path.Combine(outDir, $"tempChaps_{tempChapsGuid}.mp4");
|
||||
string mp4_file => outputFileWithNewExt(".mp4");
|
||||
string ff_txt_file => mp4_file + ".ff.txt";
|
||||
|
||||
public bool Step3_Chapterize()
|
||||
{
|
||||
string str1 = "";
|
||||
if (this.chapters.FirstChapterStart != 0.0)
|
||||
var str1 = "";
|
||||
if (chapters.FirstChapterStart != 0.0)
|
||||
{
|
||||
str1 = " -ss " + this.chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (this.chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " ";
|
||||
str1 = " -ss " + chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " ";
|
||||
}
|
||||
|
||||
string ffmpegTags = this.tags.GenerateFfmpegTags();
|
||||
string ffmpegChapters = this.chapters.GenerateFfmpegChapters();
|
||||
var ffmpegTags = tags.GenerateFfmpegTags();
|
||||
var ffmpegChapters = chapters.GenerateFfmpegChapters();
|
||||
File.WriteAllText(ff_txt_file, ffmpegTags + ffmpegChapters);
|
||||
|
||||
var tagAndChapterInfo = new ProcessStartInfo
|
||||
@@ -309,8 +316,8 @@ namespace AaxDecrypter
|
||||
public bool Step4_InsertCoverArt()
|
||||
{
|
||||
// save cover image as temp file
|
||||
var coverPath = Path.Combine(this.outDir, "cover-" + Guid.NewGuid() + ".jpg");
|
||||
FileExt.CreateFile(coverPath, this.coverBytes);
|
||||
var coverPath = Path.Combine(outDir, "cover-" + Guid.NewGuid() + ".jpg");
|
||||
FileExt.CreateFile(coverPath, coverBytes);
|
||||
|
||||
var insertCoverArtInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -329,26 +336,26 @@ namespace AaxDecrypter
|
||||
{
|
||||
FileExt.SafeDelete(mp4_file);
|
||||
FileExt.SafeDelete(ff_txt_file);
|
||||
FileExt.SafeMove(tempChapsPath, this.outputFileName);
|
||||
FileExt.SafeMove(tempChapsPath, outputFileName);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Step6_AddTags()
|
||||
{
|
||||
this.tags.AddAppleTags(this.outputFileName);
|
||||
tags.AddAppleTags(outputFileName);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool End_CreateCue()
|
||||
{
|
||||
File.WriteAllText(outputFileWithNewExt(".cue"), this.chapters.GetCuefromChapters(Path.GetFileName(this.outputFileName)));
|
||||
File.WriteAllText(outputFileWithNewExt(".cue"), chapters.GetCuefromChapters(Path.GetFileName(outputFileName)));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool End_CreateNfo()
|
||||
{
|
||||
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, this.tags, this.encodingInfo, this.chapters));
|
||||
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, tags, encodingInfo, chapters));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ namespace AaxDecrypter
|
||||
};
|
||||
|
||||
// checksum is in the debug info. ffprobe's debug info is written to stderr, not stdout
|
||||
var readErrorOutput = true;
|
||||
var ffprobeStderr = info.RunHidden(readErrorOutput).Output;
|
||||
var ffprobeStderr = info.RunHidden().Error;
|
||||
|
||||
// example checksum line:
|
||||
// ... [aax] file checksum == 0c527840c4f18517157eb0b4f9d6f9317ce60cd1
|
||||
|
||||
@@ -17,10 +17,10 @@ namespace AaxDecrypter
|
||||
|
||||
public Chapters(string file, double totalTime)
|
||||
{
|
||||
this.markers = getAAXChapters(file);
|
||||
markers = getAAXChapters(file);
|
||||
|
||||
// add end time
|
||||
this.markers.Add(totalTime);
|
||||
markers.Add(totalTime);
|
||||
}
|
||||
|
||||
private static List<double> getAAXChapters(string file)
|
||||
@@ -42,7 +42,7 @@ namespace AaxDecrypter
|
||||
}
|
||||
|
||||
// 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 int Count() => markers.Count - 1;
|
||||
|
||||
public string GetCuefromChapters(string fileName)
|
||||
{
|
||||
@@ -56,7 +56,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
var chapter = i + 1;
|
||||
|
||||
var timeSpan = TimeSpan.FromSeconds(this.markers[i]);
|
||||
var timeSpan = TimeSpan.FromSeconds(markers[i]);
|
||||
var minutes = Math.Floor(timeSpan.TotalMinutes).ToString();
|
||||
var seconds = timeSpan.Seconds.ToString("D2");
|
||||
var milliseconds = (timeSpan.Milliseconds / 10).ToString("D2");
|
||||
@@ -78,8 +78,8 @@ namespace AaxDecrypter
|
||||
{
|
||||
var chapter = i + 1;
|
||||
|
||||
var start = this.markers[i] * 1000.0;
|
||||
var end = this.markers[i + 1] * 1000.0;
|
||||
var start = markers[i] * 1000.0;
|
||||
var end = markers[i + 1] * 1000.0;
|
||||
var chapterName = chapter.ToString("D3");
|
||||
|
||||
stringBuilder.Append("[CHAPTER]\n");
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
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;
|
||||
@@ -23,52 +18,50 @@ namespace AaxDecrypter
|
||||
public string genre { get; }
|
||||
public TimeSpan duration { get; }
|
||||
|
||||
// input file
|
||||
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;
|
||||
using var tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
|
||||
title = tagLibFile.Tag.Title.Replace(" (Unabridged)", "");
|
||||
album = tagLibFile.Tag.Album.Replace(" (Unabridged)", "");
|
||||
author = tagLibFile.Tag.FirstPerformer ?? "[unknown]";
|
||||
year = tagLibFile.Tag.Year.ToString();
|
||||
comments = tagLibFile.Tag.Comment ?? "";
|
||||
duration = tagLibFile.Properties.Duration;
|
||||
genre = tagLibFile.Tag.FirstGenre ?? "";
|
||||
|
||||
var tag = tagLibFile.GetTag(TagTypes.Apple, true);
|
||||
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;
|
||||
var tag = tagLibFile.GetTag(TagTypes.Apple, true);
|
||||
publisher = tag.Publisher ?? "";
|
||||
narrator = string.IsNullOrWhiteSpace(tagLibFile.Tag.FirstComposer) ? tag.Narrator : tagLibFile.Tag.FirstComposer;
|
||||
comments = !string.IsNullOrWhiteSpace(tag.LongDescription) ? tag.LongDescription : tag.Description;
|
||||
id = tag.AudibleCDEK;
|
||||
}
|
||||
|
||||
// my best guess of what this step is doing:
|
||||
// re-publish the data we read from the input file => output file
|
||||
public void AddAppleTags(string file)
|
||||
{
|
||||
using var 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();
|
||||
using var tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
|
||||
var tag = (AppleTag)tagLibFile.GetTag(TagTypes.Apple, true);
|
||||
tag.Publisher = publisher;
|
||||
tag.LongDescription = comments;
|
||||
tag.Description = comments;
|
||||
tagLibFile.Save();
|
||||
}
|
||||
|
||||
public string GenerateFfmpegTags()
|
||||
{
|
||||
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();
|
||||
}
|
||||
=> $";FFMETADATA1"
|
||||
+ $"\nmajor_brand=aax"
|
||||
+ $"\nminor_version=1"
|
||||
+ $"\ncompatible_brands=aax M4B mp42isom"
|
||||
+ $"\ndate={year}"
|
||||
+ $"\ngenre={genre}"
|
||||
+ $"\ntitle={title}"
|
||||
+ $"\nartist={author}"
|
||||
+ $"\nalbum={album}"
|
||||
+ $"\ncomposer={narrator}"
|
||||
+ $"\ncomment={comments.Truncate(254)}"
|
||||
+ $"\ndescription={comments}"
|
||||
+ $"\n";
|
||||
}
|
||||
}
|
||||
|
||||
16
ApplicationServices/UNTESTED/DbContexts.cs
Normal file
16
ApplicationServices/UNTESTED/DbContexts.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using DataLayer;
|
||||
using FileManager;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class DbContexts
|
||||
{
|
||||
//// idea for future command/query separation
|
||||
// public static LibationContext GetCommandContext() { }
|
||||
// public static LibationContext GetQueryContext() { }
|
||||
|
||||
public static LibationContext GetContext()
|
||||
=> LibationContext.Create(SqliteStorage.ConnectionString);
|
||||
}
|
||||
}
|
||||
108
ApplicationServices/UNTESTED/LibraryCommands.cs
Normal file
108
ApplicationServices/UNTESTED/LibraryCommands.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using DtoImporterService;
|
||||
using InternalUtilities;
|
||||
using Serilog;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class LibraryCommands
|
||||
{
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts)
|
||||
{
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return (0, 0);
|
||||
|
||||
try
|
||||
{
|
||||
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
var newCount = await getNewCountAsync(importItems);
|
||||
Log.Logger.Information($"Import: New count {newCount}");
|
||||
|
||||
await Task.Run(() => SearchEngineCommands.FullReIndex());
|
||||
Log.Logger.Information("FullReIndex: success");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, Account[] accounts)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
var callback = loginCallbackFactoryFunc(account);
|
||||
|
||||
// get APIs in serial, esp b/c of logins
|
||||
var api = await AudibleApiActions.GetApiAsync(callback, account);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(api, account));
|
||||
}
|
||||
|
||||
// import library in parallel
|
||||
var arrayOfLists = await Task.WhenAll(tasks);
|
||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||
return importItems;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(Api api, Account account)
|
||||
{
|
||||
Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
var localeName = account.Locale?.Name;
|
||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||
{
|
||||
account.AccountName,
|
||||
account.AccountId,
|
||||
LocaleName = localeName,
|
||||
});
|
||||
|
||||
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api);
|
||||
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = localeName }).ToList();
|
||||
}
|
||||
|
||||
private static async Task<int> getNewCountAsync(List<ImportItem> importItems)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryImporter = new LibraryImporter(context);
|
||||
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
|
||||
context.SaveChanges();
|
||||
|
||||
return newCount;
|
||||
}
|
||||
|
||||
public static int UpdateTags(this LibationContext context, Book book, string newTags)
|
||||
{
|
||||
try
|
||||
{
|
||||
book.UserDefinedItem.Tags = newTags;
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
|
||||
if (qtyChanges > 0)
|
||||
SearchEngineCommands.UpdateBookTags(book);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error updating tags");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using DtoImporterService;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace ApplicationService
|
||||
{
|
||||
public class LibraryIndexer
|
||||
{
|
||||
public async Task<(int totalCount, int newCount)> IndexAsync(ILoginCallback callback)
|
||||
{
|
||||
var audibleApiActions = new AudibleApiActions();
|
||||
var items = await audibleApiActions.GetAllLibraryItemsAsync(callback);
|
||||
var totalCount = items.Count;
|
||||
|
||||
var libImporter = new LibraryImporter();
|
||||
var newCount = await Task.Run(() => libImporter.Import(items));
|
||||
|
||||
await SearchEngineActions.FullReIndexAsync();
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
|
||||
namespace ApplicationService
|
||||
{
|
||||
public static class SearchEngineActions
|
||||
{
|
||||
public static async Task FullReIndexAsync()
|
||||
{
|
||||
var engine = new LibationSearchEngine.SearchEngine();
|
||||
await engine.CreateNewIndexAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static void UpdateBookTags(Book book)
|
||||
{
|
||||
var engine = new LibationSearchEngine.SearchEngine();
|
||||
engine.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
|
||||
}
|
||||
|
||||
public static async Task ProductReIndexAsync(string productId)
|
||||
{
|
||||
var engine = new LibationSearchEngine.SearchEngine();
|
||||
await engine.UpdateBookAsync(productId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
ApplicationServices/UNTESTED/SearchEngineCommands.cs
Normal file
43
ApplicationServices/UNTESTED/SearchEngineCommands.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.IO;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class SearchEngineCommands
|
||||
{
|
||||
public static void FullReIndex()
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
engine.CreateNewIndex();
|
||||
}
|
||||
|
||||
public static SearchResultSet Search(string searchString)
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
try
|
||||
{
|
||||
return engine.Search(searchString);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex();
|
||||
return engine.Search(searchString);
|
||||
}
|
||||
}
|
||||
|
||||
public static void UpdateBookTags(Book book)
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
try
|
||||
{
|
||||
engine.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex();
|
||||
engine.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AudibleDotCom
|
||||
{
|
||||
public enum AudiblePageType
|
||||
{
|
||||
ProductDetails = 1,
|
||||
|
||||
Library = 2
|
||||
}
|
||||
public static class AudiblePageExt
|
||||
{
|
||||
public static AudiblePage GetAudiblePageRobust(this AudiblePageType audiblePage) => AudiblePage.FromPageType(audiblePage);
|
||||
}
|
||||
|
||||
public abstract partial class AudiblePage : Enumeration<AudiblePage>
|
||||
{
|
||||
// useful for generic classes:
|
||||
// public abstract class PageScraper<T> where T : AudiblePageRobust {
|
||||
// public AudiblePage AudiblePage => AudiblePageRobust.GetAudiblePageFromType(typeof(T));
|
||||
public static AudiblePageType GetAudiblePageFromType(Type audiblePageRobustType)
|
||||
=> (AudiblePageType)GetAll().Single(t => t.GetType() == audiblePageRobustType).Id;
|
||||
|
||||
public AudiblePageType AudiblePageType { get; }
|
||||
|
||||
protected AudiblePage(AudiblePageType audiblePage, string abbreviation) : base((int)audiblePage, abbreviation) => AudiblePageType = audiblePage;
|
||||
|
||||
public static AudiblePage FromPageType(AudiblePageType audiblePage) => FromValue((int)audiblePage);
|
||||
|
||||
/// <summary>For pages which need a param, the param is marked with {0}</summary>
|
||||
protected abstract string Url { get; }
|
||||
public string GetUrl(string id) => string.Format(Url, id);
|
||||
|
||||
public string Abbreviation => DisplayName;
|
||||
}
|
||||
public abstract partial class AudiblePage : Enumeration<AudiblePage>
|
||||
{
|
||||
public static AudiblePage Library { get; } = LibraryPage.Instance;
|
||||
public class LibraryPage : AudiblePage
|
||||
{
|
||||
#region singleton stuff
|
||||
public static LibraryPage Instance { get; } = new LibraryPage();
|
||||
static LibraryPage() { }
|
||||
private LibraryPage() : base(AudiblePageType.Library, "LIB") { }
|
||||
#endregion
|
||||
|
||||
protected override string Url => "http://www.audible.com/lib";
|
||||
}
|
||||
}
|
||||
public abstract partial class AudiblePage : Enumeration<AudiblePage>
|
||||
{
|
||||
public static AudiblePage Product { get; } = ProductDetailPage.Instance;
|
||||
public class ProductDetailPage : AudiblePage
|
||||
{
|
||||
#region singleton stuff
|
||||
public static ProductDetailPage Instance { get; } = new ProductDetailPage();
|
||||
static ProductDetailPage() { }
|
||||
private ProductDetailPage() : base(AudiblePageType.ProductDetails, "PD") { }
|
||||
#endregion
|
||||
|
||||
protected override string Url => "http://www.audible.com/pd/{0}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using FileManager;
|
||||
|
||||
namespace AudibleDotCom
|
||||
{
|
||||
public class AudiblePageSource
|
||||
{
|
||||
public AudiblePageType AudiblePage { get; }
|
||||
public string Source { get; }
|
||||
public string PageId { get; }
|
||||
|
||||
public AudiblePageSource(AudiblePageType audiblePage, string source, string pageId)
|
||||
{
|
||||
AudiblePage = audiblePage;
|
||||
Source = source;
|
||||
PageId = pageId;
|
||||
}
|
||||
|
||||
/// <summary>declawed allows local file to safely be reloaded in chrome
|
||||
/// NOTE ABOUT DECLAWED FILES
|
||||
/// making them safer also breaks functionality
|
||||
/// eg: previously hidden parts become visible. this changes how selenium can parse pages.
|
||||
/// hidden elements don't expose .Text property</summary>
|
||||
public AudiblePageSource Declawed() => new AudiblePageSource(AudiblePage, FileUtility.Declaw(Source), PageId);
|
||||
|
||||
public string Serialized() => $"<!-- |{AudiblePage.GetAudiblePageRobust().Abbreviation}|{(PageId ?? "").Trim()}| -->\r\n" + Source;
|
||||
|
||||
public static AudiblePageSource Deserialize(string serializedSource)
|
||||
{
|
||||
var endOfLine1 = serializedSource.IndexOf('\n');
|
||||
|
||||
var parameters = serializedSource
|
||||
.Substring(0, endOfLine1)
|
||||
.Split('|');
|
||||
var abbrev = parameters[1];
|
||||
var pageId = parameters[2];
|
||||
|
||||
var source = serializedSource.Substring(endOfLine1 + 1);
|
||||
var audiblePage = AudibleDotCom.AudiblePage.FromDisplayName(abbrev).AudiblePageType;
|
||||
|
||||
return new AudiblePageSource(audiblePage, source, pageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Selenium.Support" Version="3.141.0" />
|
||||
<PackageReference Include="Selenium.WebDriver" Version="3.141.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AudibleDotCom\AudibleDotCom.csproj" />
|
||||
<ProjectReference Include="..\CookieMonster\CookieMonster.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="chromedriver.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,184 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleDotCom;
|
||||
using Dinah.Core.Humanizer;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Chrome;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
/// <summary>browser manipulation. web driver access
|
||||
/// browser operators. create and store web driver, browser navigation which can vary depending on whether anon or auth'd
|
||||
///
|
||||
/// this base class: is online. no auth. used for most pages. retain no chrome cookies</summary>
|
||||
public abstract class SeleniumRetriever : IPageRetriever
|
||||
{
|
||||
#region // chrome driver details
|
||||
/*
|
||||
HIDING CHROME CONSOLE WINDOW
|
||||
hiding chrome console window has proven to cause more headaches than it solves. here's how to do it though:
|
||||
// can also use CreateDefaultService() overloads to specify driver path and/or file name
|
||||
var chromeDriverService = ChromeDriverService.CreateDefaultService();
|
||||
chromeDriverService.HideCommandPromptWindow = true;
|
||||
return new ChromeDriver(chromeDriverService, options);
|
||||
|
||||
HEADLESS CHROME
|
||||
this WOULD be how to do headless. but amazon/audible are far too tricksy about their changes and anti-scraping measures
|
||||
which renders 'headless' mode useless
|
||||
var options = new ChromeOptions();
|
||||
options.AddArgument("--headless");
|
||||
|
||||
SPECIFYING DRIVER LOCATION
|
||||
if continues to have trouble finding driver:
|
||||
var driver = new ChromeDriver(@"C:\my\path\to\chromedriver\directory");
|
||||
var chromeDriverService = ChromeDriverService.CreateDefaultService(@"C:\my\path\to\chromedriver\directory");
|
||||
*/
|
||||
#endregion
|
||||
|
||||
protected IWebDriver Driver { get; }
|
||||
Humanizer humanizer { get; } = new Humanizer();
|
||||
|
||||
protected SeleniumRetriever()
|
||||
{
|
||||
Driver = new ChromeDriver(ctorCreateChromeOptions());
|
||||
}
|
||||
|
||||
/// <summary>no auth. retain no chrome cookies</summary>
|
||||
protected virtual ChromeOptions ctorCreateChromeOptions() => new ChromeOptions();
|
||||
|
||||
protected async Task AudibleLinkClickAsync(IWebElement element)
|
||||
{
|
||||
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
|
||||
await humanizer.Wait();
|
||||
|
||||
await Task.Run(() => Driver.Click(element));
|
||||
|
||||
await waitForSpinnerAsync();
|
||||
|
||||
// sometimes these clicks just take a while. add a few more seconds
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
|
||||
By spinnerLocator { get; } = By.Id("library-main-overlay");
|
||||
private async Task waitForSpinnerAsync()
|
||||
{
|
||||
// if loading overlay w/spinner exists: pause, wait for it to end
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
if (Driver.FindElements(spinnerLocator).Count > 0)
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until(ExpectedConditions.InvisibilityOfElementLocated(spinnerLocator));
|
||||
}
|
||||
|
||||
private bool isFirstRun = true;
|
||||
protected virtual async Task FirstRunAsync()
|
||||
{
|
||||
// load with no beginning wait. then wait 7 seconds to allow for page flicker. it usually happens after ~5 seconds. can happen irrespective of login state
|
||||
await Task.Run(() => Driver.Navigate().GoToUrl("http://www.audible.com/"));
|
||||
await Task.Delay(7000);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null)
|
||||
{
|
||||
if (isFirstRun)
|
||||
{
|
||||
await FirstRunAsync();
|
||||
isFirstRun = false;
|
||||
}
|
||||
|
||||
await initFirstPageAsync(audiblePage, pageId);
|
||||
|
||||
return await processUrl(audiblePage, pageId);
|
||||
}
|
||||
|
||||
private async Task initFirstPageAsync(AudiblePageType audiblePage, string pageId)
|
||||
{
|
||||
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
|
||||
await humanizer.Wait();
|
||||
|
||||
var url = audiblePage.GetAudiblePageRobust().GetUrl(pageId);
|
||||
await Task.Run(() => Driver.Navigate().GoToUrl(url));
|
||||
|
||||
await waitForSpinnerAsync();
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<AudiblePageSource>> processUrl(AudiblePageType audiblePage, string pageId)
|
||||
{
|
||||
var pageSources = new List<AudiblePageSource>();
|
||||
do
|
||||
{
|
||||
pageSources.Add(new AudiblePageSource(audiblePage, Driver.PageSource, pageId));
|
||||
}
|
||||
while (await hasMorePagesAsync());
|
||||
|
||||
return pageSources;
|
||||
}
|
||||
|
||||
#region has more pages
|
||||
/// <summary>if no more pages, return false. else, navigate to next page and return true</summary>
|
||||
private async Task<bool> hasMorePagesAsync()
|
||||
{
|
||||
var next = //old_hasMorePages() ??
|
||||
new_hasMorePages();
|
||||
if (next == null)
|
||||
return false;
|
||||
|
||||
await AudibleLinkClickAsync(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
private IWebElement old_hasMorePages()
|
||||
{
|
||||
var parentElements = Driver.FindElements(By.ClassName("adbl-page-next"));
|
||||
if (parentElements.Count == 0)
|
||||
return null;
|
||||
|
||||
var childElements = parentElements[0].FindElements(By.LinkText("NEXT"));
|
||||
if (childElements.Count != 1)
|
||||
return null;
|
||||
|
||||
return childElements[0];
|
||||
}
|
||||
|
||||
// ~ oct 2017
|
||||
private IWebElement new_hasMorePages()
|
||||
{
|
||||
// get all active/enabled navigation links
|
||||
var pageNavLinks = Driver.FindElements(By.ClassName("library-load-page"));
|
||||
if (pageNavLinks.Count == 0)
|
||||
return null;
|
||||
|
||||
// get only the right chevron if active.
|
||||
// note: there are also right chevrons which are not for wish list navigation which is why we first filter by library-load-page
|
||||
var nextLink = pageNavLinks
|
||||
.Where(p => p.FindElements(By.ClassName("bc-icon-chevron-right")).Count > 0)
|
||||
.ToList(); // cut-off delayed execution
|
||||
if (nextLink.Count == 0)
|
||||
return null;
|
||||
|
||||
return nextLink.Single().FindElement(By.TagName("button"));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region IDisposable pattern
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && Driver != null)
|
||||
{
|
||||
// Quit() does cleanup AND disposes
|
||||
Driver.Quit();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
/// <summary>for user collections: lib, WL</summary>
|
||||
public abstract class AuthSeleniumRetriever : SeleniumRetriever
|
||||
{
|
||||
protected bool IsLoggedIn => GetListenerPageLink() != null;
|
||||
|
||||
// needed?
|
||||
protected AuthSeleniumRetriever() : base() { }
|
||||
|
||||
protected IWebElement GetListenerPageLink()
|
||||
{
|
||||
var listenerPageElement = Driver.FindElements(By.XPath("//a[contains(@href, '/review-by-author')]"));
|
||||
if (listenerPageElement.Count > 0)
|
||||
return listenerPageElement[0];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleDotCom;
|
||||
using CookieMonster;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Humanizer;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
public class BrowserlessRetriever : IPageRetriever
|
||||
{
|
||||
Humanizer humanizer { get; } = new Humanizer();
|
||||
|
||||
public async Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null)
|
||||
{
|
||||
switch (audiblePage)
|
||||
{
|
||||
case AudiblePageType.Library: return await getLibraryPageSourcesAsync();
|
||||
default: throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<AudiblePageSource>> getLibraryPageSourcesAsync()
|
||||
{
|
||||
var collection = new List<AudiblePageSource>();
|
||||
|
||||
var cookies = await getAudibleCookiesAsync();
|
||||
|
||||
var currPageNum = 1;
|
||||
bool hasMorePages;
|
||||
do
|
||||
{
|
||||
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
|
||||
await humanizer.Wait();
|
||||
|
||||
var html = await getLibraryPageAsync(cookies, currPageNum);
|
||||
var pageSource = new AudiblePageSource(AudiblePageType.Library, html, null);
|
||||
collection.Add(pageSource);
|
||||
|
||||
hasMorePages = getHasMorePages(pageSource.Source);
|
||||
|
||||
currPageNum++;
|
||||
} while (hasMorePages);
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
private static async Task<CookieContainer> getAudibleCookiesAsync()
|
||||
{
|
||||
var liveCookies = await CookiesHelper.GetLiveCookieValuesAsync();
|
||||
|
||||
var audibleCookies = liveCookies.Where(c
|
||||
=> c.Domain.ContainsInsensitive("audible.com")
|
||||
|| c.Domain.ContainsInsensitive("adbl")
|
||||
|| c.Domain.ContainsInsensitive("amazon.com"))
|
||||
.ToList();
|
||||
|
||||
var cookies = new CookieContainer();
|
||||
foreach (var c in audibleCookies)
|
||||
cookies.Add(new Cookie(c.Name, c.Value, "/", c.Domain));
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
private static bool getHasMorePages(string html)
|
||||
{
|
||||
var doc = new HtmlAgilityPack.HtmlDocument();
|
||||
doc.LoadHtml(html);
|
||||
|
||||
// final page, invalid page:
|
||||
// <span class="bc-button
|
||||
// bc-button-secondary
|
||||
// nextButton
|
||||
// bc-button-disabled">
|
||||
// only page: ???
|
||||
// has more pages:
|
||||
// <span class="bc-button
|
||||
// bc-button-secondary
|
||||
// refinementFormButton
|
||||
// nextButton">
|
||||
var next_active_link = doc
|
||||
.DocumentNode
|
||||
.Descendants()
|
||||
.FirstOrDefault(n =>
|
||||
n.HasClass("nextButton") &&
|
||||
!n.HasClass("bc-button-disabled"));
|
||||
|
||||
return next_active_link != null;
|
||||
}
|
||||
|
||||
private static async Task<string> getLibraryPageAsync(CookieContainer cookies, int pageNum)
|
||||
{
|
||||
#region // POST example (from 2017 ajax)
|
||||
// var destination = "https://www.audible.com/lib-ajax";
|
||||
// var webRequest = (HttpWebRequest)WebRequest.Create(destination);
|
||||
// webRequest.Method = "POST";
|
||||
// webRequest.Accept = "*/*";
|
||||
// webRequest.AllowAutoRedirect = false;
|
||||
// webRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.0.3705)";
|
||||
// webRequest.ContentType = "application/x-www-form-urlencoded; charset=UTF-8";
|
||||
// webRequest.Credentials = null;
|
||||
//
|
||||
// webRequest.CookieContainer = new CookieContainer();
|
||||
// webRequest.CookieContainer.Add(cookies.GetCookies(new Uri(destination)));
|
||||
//
|
||||
// var postData = $"progType=all&timeFilter=all&itemsPerPage={itemsPerPage}&searchTerm=&searchType=&sortColumn=&sortType=down&page={pageNum}&mode=normal&subId=&subTitle=";
|
||||
// var data = Encoding.UTF8.GetBytes(postData);
|
||||
// webRequest.ContentLength = data.Length;
|
||||
// using var dataStream = webRequest.GetRequestStream();
|
||||
// dataStream.Write(data, 0, data.Length);
|
||||
#endregion
|
||||
|
||||
var destination = "https://" + $"www.audible.com/lib?purchaseDateFilter=all&programFilter=all&sortBy=PURCHASE_DATE.dsc&page={pageNum}";
|
||||
var webRequest = (HttpWebRequest)WebRequest.Create(destination);
|
||||
webRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.0.3705)";
|
||||
|
||||
webRequest.CookieContainer = new CookieContainer();
|
||||
webRequest.CookieContainer.Add(cookies.GetCookies(new Uri(destination)));
|
||||
|
||||
var webResponse = await webRequest.GetResponseAsync();
|
||||
return new StreamReader(webResponse.GetResponseStream()).ReadToEnd();
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
/// <summary>online. get auth by logging in with provided username and password
|
||||
/// retain no chrome cookies. enter user + pw login</summary>
|
||||
public class ManualLoginSeleniumRetriever : AuthSeleniumRetriever
|
||||
{
|
||||
string _username;
|
||||
string _password;
|
||||
public ManualLoginSeleniumRetriever(string username, string password) : base()
|
||||
{
|
||||
_username = username;
|
||||
_password = password;
|
||||
}
|
||||
protected override async Task FirstRunAsync()
|
||||
{
|
||||
await base.FirstRunAsync();
|
||||
|
||||
// can't extract this into AuthSeleniumRetriever ctor. can't use username/pw until prev ctors are complete
|
||||
|
||||
// click login link
|
||||
await AudibleLinkClickAsync(getLoginLink());
|
||||
|
||||
// wait until login page loads
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60)).Until(ExpectedConditions.ElementIsVisible(By.Id("ap_email")));
|
||||
|
||||
// insert credentials
|
||||
Driver
|
||||
.FindElement(By.Id("ap_email"))
|
||||
.SendKeys(_username);
|
||||
Driver
|
||||
.FindElement(By.Id("ap_password"))
|
||||
.SendKeys(_password);
|
||||
|
||||
// submit
|
||||
var submitElement
|
||||
= Driver.FindElements(By.Id("signInSubmit")).FirstOrDefault()
|
||||
?? Driver.FindElement(By.Id("signInSubmit-input"));
|
||||
await AudibleLinkClickAsync(submitElement);
|
||||
|
||||
// wait until audible page loads
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until(d => GetListenerPageLink());
|
||||
|
||||
if (!IsLoggedIn)
|
||||
throw new Exception("not logged in");
|
||||
}
|
||||
private IWebElement getLoginLink()
|
||||
{
|
||||
{
|
||||
var loginLinkElements1 = Driver.FindElements(By.XPath("//a[contains(@href, '/signin')]"));
|
||||
if (loginLinkElements1.Any())
|
||||
return loginLinkElements1[0];
|
||||
}
|
||||
|
||||
//
|
||||
// ADD ADDITIONAL ACCEPTABLE PATTERNS HERE
|
||||
//
|
||||
//{
|
||||
// var loginLinkElements2 = Driver.FindElements(By.XPath("//a[contains(@href, '/signin')]"));
|
||||
// if (loginLinkElements2.Any())
|
||||
// return loginLinkElements2[0];
|
||||
//}
|
||||
|
||||
throw new NotFoundException("Cannot locate login link");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium.Chrome;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
/// <summary>online. load auth, cookies etc from user data</summary>
|
||||
public class UserDataSeleniumRetriever : AuthSeleniumRetriever
|
||||
{
|
||||
public UserDataSeleniumRetriever() : base()
|
||||
{
|
||||
// can't extract this into AuthSeleniumRetriever ctor. can't use username/pw until prev ctors are complete
|
||||
if (!IsLoggedIn)
|
||||
throw new Exception("not logged in");
|
||||
}
|
||||
|
||||
/// <summary>Use current user data/chrome cookies. DO NOT use if chrome is already open</summary>
|
||||
protected override ChromeOptions ctorCreateChromeOptions()
|
||||
{
|
||||
var options = base.ctorCreateChromeOptions();
|
||||
|
||||
// load user data incl cookies. default on windows:
|
||||
// %LOCALAPPDATA%\Google\Chrome\User Data
|
||||
// C:\Users\username\AppData\Local\Google\Chrome\User Data
|
||||
var chromeDefaultWindowsUserDataDir = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Google",
|
||||
"Chrome",
|
||||
"User Data");
|
||||
options.AddArguments($"user-data-dir={chromeDefaultWindowsUserDataDir}");
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleDotCom;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
public interface IPageRetriever : IDisposable
|
||||
{
|
||||
Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
|
||||
namespace AudibleDotComAutomation.Examples
|
||||
{
|
||||
public class SeleniumExamples
|
||||
{
|
||||
public IWebDriver Driver { get; set; }
|
||||
|
||||
IWebElement GetListenerPageLink()
|
||||
{
|
||||
var listenerPageElement = Driver.FindElements(By.XPath("//a[contains(@href, '/review-by-author')]"));
|
||||
if (listenerPageElement.Count > 0)
|
||||
return listenerPageElement[0];
|
||||
return null;
|
||||
}
|
||||
void wait_examples()
|
||||
{
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until(ExpectedConditions.ElementIsVisible(By.Id("mast-member-acct-name")));
|
||||
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until(d => GetListenerPageLink());
|
||||
|
||||
// https://stackoverflow.com/questions/21339339/how-to-add-custom-expectedconditions-for-selenium
|
||||
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
|
||||
.Until((d) =>
|
||||
{
|
||||
// could be refactored into OR, AND per the java selenium library
|
||||
|
||||
// check 1
|
||||
var e1 = Driver.FindElements(By.Id("mast-member-acct-name"));
|
||||
if (e1.Count > 0)
|
||||
return e1[0];
|
||||
// check 2
|
||||
var e2 = Driver.FindElements(By.Id("header-account-info-0"));
|
||||
if (e2.Count > 0)
|
||||
return e2[0];
|
||||
return null;
|
||||
});
|
||||
}
|
||||
void XPath_examples()
|
||||
{
|
||||
// <tr>
|
||||
// <td>1</td>
|
||||
// <td>2</td>
|
||||
// </tr>
|
||||
// <tr>
|
||||
// <td>3</td>
|
||||
// <td>4</td>
|
||||
// </tr>
|
||||
|
||||
ReadOnlyCollection<IWebElement> all_tr = Driver.FindElements(By.XPath("/tr"));
|
||||
IWebElement first_tr = Driver.FindElement(By.XPath("/tr"));
|
||||
IWebElement second_tr = Driver.FindElement(By.XPath("/tr[2]"));
|
||||
// beginning with a single / starts from root
|
||||
IWebElement ERROR_not_at_root = Driver.FindElement(By.XPath("/td"));
|
||||
// 2 slashes searches all, NOT just descendants
|
||||
IWebElement td1 = Driver.FindElement(By.XPath("//td"));
|
||||
|
||||
// 2 slashes still searches all, NOT just descendants
|
||||
IWebElement still_td1 = first_tr.FindElement(By.XPath("//td"));
|
||||
|
||||
// dot operator starts from current node specified by first_tr
|
||||
// single slash: immediate descendant
|
||||
IWebElement td3 = first_tr.FindElement(By.XPath(
|
||||
".//td"));
|
||||
// double slash: descendant at any depth
|
||||
IWebElement td3_also = first_tr.FindElement(By.XPath(
|
||||
"./td"));
|
||||
|
||||
// <input type="hidden" name="asin" value="ABCD1234">
|
||||
IWebElement find_anywhere_in_doc = first_tr.FindElement(By.XPath(
|
||||
"//input[@name='asin']"));
|
||||
IWebElement find_in_subsection = first_tr.FindElement(By.XPath(
|
||||
".//input[@name='asin']"));
|
||||
|
||||
// search entire page. useful for:
|
||||
// - RulesLocator to find something that only appears once on the page
|
||||
// - non-list pages. eg: product details
|
||||
var onePerPageRules = new RuleFamily
|
||||
{
|
||||
RowsLocator = By.XPath("/*"), // search entire page
|
||||
Rules = new RuleSet {
|
||||
(row, productItem) => productItem.CustomerId = row.FindElement(By.XPath("//input[@name='cust_id']")).GetValue(),
|
||||
(row, productItem) => productItem.UserName = row.FindElement(By.XPath("//input[@name='user_name']")).GetValue()
|
||||
}
|
||||
};
|
||||
// - applying conditionals to entire page
|
||||
var ruleFamily = new RuleFamily
|
||||
{
|
||||
RowsLocator = By.XPath("//*[starts-with(@id,'adbl-library-content-row-')]"),
|
||||
// Rules = getRuleSet()
|
||||
};
|
||||
}
|
||||
#region Rules classes stubs
|
||||
public class RuleFamily { public By RowsLocator; public IRuleClass Rules; }
|
||||
public interface IRuleClass { }
|
||||
public class RuleSet : IRuleClass, IEnumerable<IRuleClass>
|
||||
{
|
||||
public void Add(IRuleClass ruleClass) { }
|
||||
public void Add(RuleAction action) { }
|
||||
|
||||
public IEnumerator<IRuleClass> GetEnumerator() => throw new NotImplementedException();
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => throw new NotImplementedException();
|
||||
}
|
||||
public delegate void RuleAction(IWebElement row, ProductItem productItem);
|
||||
public class ProductItem { public string CustomerId; public string UserName; }
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Interactions;
|
||||
|
||||
namespace AudibleDotComAutomation
|
||||
{
|
||||
public static class IWebElementExt
|
||||
{
|
||||
// allows getting Text from elements even if hidden
|
||||
// this only works on visible elements: webElement.Text
|
||||
// http://yizeng.me/2014/04/08/get-text-from-hidden-elements-using-selenium-webdriver/#c-sharp
|
||||
//
|
||||
public static string GetText(this IWebElement webElement) => webElement.GetAttribute("textContent");
|
||||
|
||||
public static string GetValue(this IWebElement webElement) => webElement.GetAttribute("value");
|
||||
}
|
||||
|
||||
public static class IWebDriverExt
|
||||
{
|
||||
/// <summary>Use this instead of element.Click() to ensure that the element is clicked even if it's not currently scrolled into view</summary>
|
||||
public static void Click(this IWebDriver driver, IWebElement element)
|
||||
{
|
||||
// from: https://stackoverflow.com/questions/12035023/selenium-webdriver-cant-click-on-a-link-outside-the-page
|
||||
|
||||
|
||||
//// this works but isn't really the same
|
||||
//element.SendKeys(Keys.Enter);
|
||||
|
||||
|
||||
//// didn't work for me
|
||||
//new Actions(driver)
|
||||
// .MoveToElement(element)
|
||||
// .Click()
|
||||
// .Build()
|
||||
// .Perform();
|
||||
|
||||
driver.ScrollIntoView(element);
|
||||
element.Click();
|
||||
}
|
||||
public static void ScrollIntoView(this IWebDriver driver, IWebElement element)
|
||||
=> ((IJavaScriptExecutor)driver).ExecuteScript($"window.scroll({element.Location.X}, {element.Location.Y})");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.112" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="4.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,66 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SQLite;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using FileManager;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
internal class Chrome : IBrowser
|
||||
{
|
||||
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
|
||||
{
|
||||
var col = new List<CookieValue>();
|
||||
|
||||
var strPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Google\Chrome\User Data\Default\Cookies");
|
||||
if (!FileUtility.FileExists(strPath))
|
||||
return col;
|
||||
|
||||
//
|
||||
// IF WE GET AN ERROR HERE
|
||||
// then add a reference to sqlite core in the project which is ultimately calling this.
|
||||
// a project which directly references CookieMonster doesn't need to also ref sqlite.
|
||||
// however, for any further number of abstractions, the project needs to directly ref sqlite.
|
||||
// eg: this will not work unless the winforms proj adds sqlite to ref.s:
|
||||
// LibationWinForm > AudibleDotComAutomation > CookieMonster
|
||||
//
|
||||
using var conn = new SQLiteConnection("Data Source=" + strPath + ";pooling=false");
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT host_key, name, value, encrypted_value, last_access_utc, expires_utc FROM cookies;";
|
||||
|
||||
conn.Open();
|
||||
using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
while (reader.Read())
|
||||
{
|
||||
var host_key = reader.GetString(0);
|
||||
var name = reader.GetString(1);
|
||||
var value = reader.GetString(2);
|
||||
var last_access_utc = reader.GetInt64(4);
|
||||
var expires_utc = reader.GetInt64(5);
|
||||
|
||||
// https://stackoverflow.com/a/25874366
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
var encrypted_value = (byte[])reader[3];
|
||||
var decodedData = System.Security.Cryptography.ProtectedData.Unprotect(encrypted_value, null, System.Security.Cryptography.DataProtectionScope.CurrentUser);
|
||||
value = Encoding.ASCII.GetString(decodedData);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// if something goes wrong in this step (eg: a cookie has an invalid filetime), then just skip this cookie
|
||||
col.Add(new CookieValue { Browser = "chrome", Domain = host_key, Name = name, Value = value, LastAccess = chromeTimeToDateTimeUtc(last_access_utc), Expires = chromeTimeToDateTimeUtc(expires_utc) });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
// Chrome uses 1601-01-01 00:00:00 UTC as the epoch (ie the starting point for the millisecond time counter).
|
||||
// this is the same as "FILETIME" in Win32 except FILETIME uses 100ns ticks instead of ms.
|
||||
private static DateTime chromeTimeToDateTimeUtc(long time) => DateTime.SpecifyKind(DateTime.FromFileTime(time * 10), DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SQLite;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using FileManager;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
internal class FireFox : IBrowser
|
||||
{
|
||||
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
|
||||
{
|
||||
var col = new List<CookieValue>();
|
||||
|
||||
string strPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @"Mozilla\Firefox\Profiles");
|
||||
if (!FileUtility.FileExists(strPath))
|
||||
return col;
|
||||
var dirs = new DirectoryInfo(strPath).GetDirectories("*.default");
|
||||
if (dirs.Length != 1)
|
||||
return col;
|
||||
strPath = Path.Combine(strPath, dirs[0].Name, "cookies.sqlite");
|
||||
if (!FileUtility.FileExists(strPath))
|
||||
return col;
|
||||
|
||||
// First copy the cookie jar so that we can read the cookies from unlocked copy while FireFox is running
|
||||
var strTemp = strPath + ".temp";
|
||||
|
||||
File.Copy(strPath, strTemp, true);
|
||||
|
||||
// Now open the temporary cookie jar and extract Value from the cookie if we find it.
|
||||
using var conn = new SQLiteConnection("Data Source=" + strTemp + ";pooling=false");
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT host, name, value, lastAccessed, expiry FROM moz_cookies; ";
|
||||
|
||||
conn.Open();
|
||||
using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
while (reader.Read())
|
||||
{
|
||||
var host_key = reader.GetString(0);
|
||||
var name = reader.GetString(1);
|
||||
var value = reader.GetString(2);
|
||||
var lastAccessed = reader.GetInt32(3);
|
||||
var expiry = reader.GetInt32(4);
|
||||
|
||||
col.Add(new CookieValue { Browser = "firefox", Domain = host_key, Name = name, Value = value, LastAccess = lastAccessedToDateTime(lastAccessed), Expires = expiryToDateTime(expiry) });
|
||||
}
|
||||
|
||||
if (FileUtility.FileExists(strTemp))
|
||||
File.Delete(strTemp);
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
// time is in microseconds since unix epoch
|
||||
private static DateTime lastAccessedToDateTime(int time) => new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(time);
|
||||
|
||||
// time is in normal seconds since unix epoch
|
||||
private static DateTime expiryToDateTime(int time) => new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc).AddSeconds(time);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
internal interface IBrowser
|
||||
{
|
||||
Task<IEnumerable<CookieValue>> GetAllCookiesAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
internal class InternetExplorer : IBrowser
|
||||
{
|
||||
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
|
||||
{
|
||||
// real locations of Windows Cookies folders
|
||||
//
|
||||
// Windows 7:
|
||||
// C:\Users\username\AppData\Roaming\Microsoft\Windows\Cookies
|
||||
// C:\Users\username\AppData\Roaming\Microsoft\Windows\Cookies\Low
|
||||
//
|
||||
// Windows 8, Windows 8.1, Windows 10:
|
||||
// C:\Users\username\AppData\Local\Microsoft\Windows\INetCookies
|
||||
// C:\Users\username\AppData\Local\Microsoft\Windows\INetCookies\Low
|
||||
|
||||
var strPath = Environment.GetFolderPath(Environment.SpecialFolder.Cookies);
|
||||
|
||||
var col = (await getIECookiesAsync(strPath).ConfigureAwait(false)).ToList();
|
||||
col = col.Concat(await getIECookiesAsync(Path.Combine(strPath, "Low"))).ToList();
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<CookieValue>> getIECookiesAsync(string strPath)
|
||||
{
|
||||
var cookies = new List<CookieValue>();
|
||||
|
||||
var files = await Task.Run(() => Directory.EnumerateFiles(strPath, "*.txt"));
|
||||
foreach (string path in files)
|
||||
{
|
||||
var cookiesInFile = new List<CookieValue>();
|
||||
|
||||
var cookieLines = File.ReadAllLines(path);
|
||||
CookieValue currCookieVal = null;
|
||||
for (var i = 0; i < cookieLines.Length; i++)
|
||||
{
|
||||
var line = cookieLines[i];
|
||||
|
||||
// IE cookie format
|
||||
// 0 Cookie name
|
||||
// 1 Cookie value
|
||||
// 2 Host / path for the web server setting the cookie
|
||||
// 3 Flags
|
||||
// 4 Expiration time (low int)
|
||||
// 5 Expiration time (high int)
|
||||
// 6 Creation time (low int)
|
||||
// 7 Creation time (high int)
|
||||
// 8 Record delimiter == "*"
|
||||
var pos = i % 9;
|
||||
long expLoTemp = 0;
|
||||
long creatLoTemp = 0;
|
||||
if (pos == 0)
|
||||
{
|
||||
currCookieVal = new CookieValue { Browser = "ie", Name = line };
|
||||
cookiesInFile.Add(currCookieVal);
|
||||
}
|
||||
else if (pos == 1)
|
||||
currCookieVal.Value = line;
|
||||
else if (pos == 2)
|
||||
currCookieVal.Domain = line;
|
||||
else if (pos == 4)
|
||||
expLoTemp = Int64.Parse(line);
|
||||
else if (pos == 5)
|
||||
currCookieVal.Expires = LoHiToDateTime(expLoTemp, Int64.Parse(line));
|
||||
else if (pos == 6)
|
||||
creatLoTemp = Int64.Parse(line);
|
||||
else if (pos == 7)
|
||||
currCookieVal.LastAccess = LoHiToDateTime(creatLoTemp, Int64.Parse(line));
|
||||
}
|
||||
|
||||
cookies.AddRange(cookiesInFile);
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
private static DateTime LoHiToDateTime(long lo, long hi) => DateTime.FromFileTimeUtc(((hi << 32) + lo));
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
public class CookieValue
|
||||
{
|
||||
public string Browser { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
public string Value { get; set; }
|
||||
public string Domain { get; set; }
|
||||
|
||||
public DateTime LastAccess { get; set; }
|
||||
public DateTime Expires { get; set; }
|
||||
|
||||
public bool IsValid
|
||||
{
|
||||
get
|
||||
{
|
||||
// sanity check. datetimes are stored weird in each cookie type. make sure i haven't converted these incredibly wrong.
|
||||
// some early conversion attempts produced years like 42, 1955, 4033
|
||||
var _5yearsPast = DateTime.UtcNow.AddYears(-5);
|
||||
if (LastAccess < _5yearsPast || LastAccess > DateTime.UtcNow)
|
||||
return false;
|
||||
// don't check expiry. some sites are setting stupid values for year. eg: 9999
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasExpired => Expires < DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace CookieMonster
|
||||
{
|
||||
public static class CookiesHelper
|
||||
{
|
||||
internal static IEnumerable<IBrowser> GetBrowsers()
|
||||
=> AppDomain.CurrentDomain
|
||||
.GetAssemblies()
|
||||
.SelectMany(s => s.GetTypes())
|
||||
.Where(p => typeof(IBrowser).IsAssignableFrom(p) && !p.IsAbstract && !p.IsInterface)
|
||||
.Select(t => Activator.CreateInstance(t) as IBrowser)
|
||||
.ToList();
|
||||
|
||||
/// <summary>all. including expired</summary>
|
||||
public static async Task<IEnumerable<CookieValue>> GetAllCookieValuesAsync()
|
||||
{
|
||||
//// foreach{await} runs in serial
|
||||
//var allCookies = new List<CookieValue>();
|
||||
//foreach (var b in GetBrowsers())
|
||||
//{
|
||||
// var browserCookies = await b.GetAllCookiesAsync().ConfigureAwait(false);
|
||||
// allCookies.AddRange(browserCookies);
|
||||
//}
|
||||
|
||||
//// WhenAll runs in parallel
|
||||
// this 1st step LOOKS like a bug which runs each method until completion. However, since we don't use await, it's actually returning a Task. That resulting task is awaited asynchronously
|
||||
var browserTasks = GetBrowsers().Select(b => b.GetAllCookiesAsync());
|
||||
var results = await Task.WhenAll(browserTasks).ConfigureAwait(false);
|
||||
var allCookies = results.SelectMany(a => a).ToList();
|
||||
|
||||
if (allCookies.Any(c => !c.IsValid))
|
||||
throw new Exception("some date time was converted way too far");
|
||||
|
||||
foreach (var c in allCookies)
|
||||
c.Domain = c.Domain.TrimEnd('/');
|
||||
|
||||
// for each domain+name, only keep the 1 with the most recent access
|
||||
var sortedCookies = allCookies
|
||||
.OrderByDescending(c => c.LastAccess)
|
||||
.DistinctBy(c => new { c.Domain, c.Name })
|
||||
.ToList();
|
||||
|
||||
return sortedCookies;
|
||||
}
|
||||
|
||||
/// <summary>not expired</summary>
|
||||
public static async Task<IEnumerable<CookieValue>> GetLiveCookieValuesAsync()
|
||||
=> (await GetAllCookieValuesAsync().ConfigureAwait(false))
|
||||
.Where(c => !c.HasExpired)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netcoreapp3.0;netstandard2.1</TargetFrameworks>
|
||||
<TargetFrameworks>netcoreapp3.1;netstandard2.1</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -12,13 +12,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.0.0">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.0.0">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
332
DataLayer/Migrations/20191125182309_Fresh.Designer.cs
generated
Normal file
332
DataLayer/Migrations/20191125182309_Fresh.Designer.cs
generated
Normal file
@@ -0,0 +1,332 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20191125182309_Fresh")]
|
||||
partial class Fresh
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "3.0.0");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("Library");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class UpgradeToCore3 : Migration
|
||||
public partial class Fresh : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
@@ -12,7 +12,7 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
CategoryId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AudibleCategoryId = table.Column<string>(nullable: true),
|
||||
Name = table.Column<string>(nullable: true),
|
||||
ParentCategoryCategoryId = table.Column<int>(nullable: true)
|
||||
@@ -33,9 +33,9 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
ContributorId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(nullable: true),
|
||||
AudibleAuthorId = table.Column<string>(nullable: true)
|
||||
AudibleContributorId = table.Column<string>(nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
@@ -47,7 +47,7 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
SeriesId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AudibleSeriesId = table.Column<string>(nullable: true),
|
||||
Name = table.Column<string>(nullable: true)
|
||||
},
|
||||
@@ -61,13 +61,12 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
BookId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AudibleProductId = table.Column<string>(nullable: true),
|
||||
Title = table.Column<string>(nullable: true),
|
||||
Description = table.Column<string>(nullable: true),
|
||||
LengthInMinutes = table.Column<int>(nullable: false),
|
||||
PictureId = table.Column<string>(nullable: true),
|
||||
HasBookDetails = table.Column<bool>(nullable: false),
|
||||
IsAbridged = table.Column<bool>(nullable: false),
|
||||
DatePublished = table.Column<DateTime>(nullable: true),
|
||||
CategoryId = table.Column<int>(nullable: false),
|
||||
@@ -117,8 +116,7 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
BookId = table.Column<int>(nullable: false),
|
||||
DateAdded = table.Column<DateTime>(nullable: false),
|
||||
DownloadBookLink = table.Column<string>(nullable: true)
|
||||
DateAdded = table.Column<DateTime>(nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
@@ -161,7 +159,7 @@ namespace DataLayer.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
SupplementId = table.Column<int>(nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
BookId = table.Column<int>(nullable: false),
|
||||
Url = table.Column<string>(nullable: true)
|
||||
},
|
||||
@@ -202,6 +200,11 @@ namespace DataLayer.Migrations
|
||||
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
|
||||
values: new object[] { -1, "", "", null });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Contributors",
|
||||
columns: new[] { "ContributorId", "AudibleContributorId", "Name" },
|
||||
values: new object[] { -1, null, "" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookContributor_BookId",
|
||||
table: "BookContributor",
|
||||
@@ -3,57 +3,53 @@ using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20191007202808_UpgradeToCore3")]
|
||||
partial class UpgradeToCore3
|
||||
[Migration("20200812152646_AddLocaleAndAccount")]
|
||||
partial class AddLocaleAndAccount
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "3.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128)
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasAnnotation("ProductVersion", "3.1.7");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("datetime2");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("HasBookDetails")
|
||||
.HasColumnType("bit");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("bit");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
@@ -67,16 +63,16 @@ namespace DataLayer.Migrations
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("tinyint");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
@@ -91,17 +87,16 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
@@ -124,32 +119,38 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleAuthorId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DownloadBookLink")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
@@ -160,14 +161,13 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
@@ -179,13 +179,13 @@ namespace DataLayer.Migrations
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
@@ -207,18 +207,16 @@ namespace DataLayer.Migrations
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
@@ -232,14 +230,13 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
@@ -254,10 +251,10 @@ namespace DataLayer.Migrations
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
@@ -269,16 +266,16 @@ namespace DataLayer.Migrations
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
31
DataLayer/Migrations/20200812152646_AddLocaleAndAccount.cs
Normal file
31
DataLayer/Migrations/20200812152646_AddLocaleAndAccount.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
public partial class AddLocaleAndAccount : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Account",
|
||||
table: "Library",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Locale",
|
||||
table: "Books",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Account",
|
||||
table: "Library");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Locale",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
@@ -15,43 +14,40 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "3.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128)
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasAnnotation("ProductVersion", "3.1.7");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("datetime2");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("HasBookDetails")
|
||||
.HasColumnType("bit");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("bit");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
@@ -65,16 +61,16 @@ namespace DataLayer.Migrations
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("tinyint");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
@@ -89,17 +85,16 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
@@ -122,32 +117,38 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleAuthorId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DownloadBookLink")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
@@ -158,14 +159,13 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
@@ -177,13 +177,13 @@ namespace DataLayer.Migrations
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
@@ -205,18 +205,16 @@ namespace DataLayer.Migrations
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
@@ -230,14 +228,13 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
@@ -252,10 +249,10 @@ namespace DataLayer.Migrations
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
@@ -267,16 +264,16 @@ namespace DataLayer.Migrations
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("int");
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public static class RemoveOrphansCommand
|
||||
{
|
||||
public static int RemoveOrphans(this LibationContext context)
|
||||
=> context.Database.ExecuteSqlRaw(@"
|
||||
delete c
|
||||
from Contributors c
|
||||
left join BookContributor bc on c.ContributorId = bc.ContributorId
|
||||
left join Books b on bc.BookId = b.BookId
|
||||
where bc.ContributorId is null
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,13 @@ namespace DataLayer
|
||||
public string Description { get; private set; }
|
||||
public int LengthInMinutes { get; private set; }
|
||||
|
||||
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
|
||||
public string Locale { get; private set; }
|
||||
|
||||
// mutable
|
||||
public string PictureId { get; set; }
|
||||
|
||||
// book details
|
||||
public bool HasBookDetails { get; private set; }
|
||||
public bool IsAbridged { get; private set; }
|
||||
public DateTime? DatePublished { get; private set; }
|
||||
|
||||
@@ -62,12 +64,19 @@ namespace DataLayer
|
||||
string title,
|
||||
string description,
|
||||
int lengthInMinutes,
|
||||
IEnumerable<Contributor> authors)
|
||||
IEnumerable<Contributor> authors,
|
||||
IEnumerable<Contributor> narrators,
|
||||
Category category, string localeName)
|
||||
{
|
||||
// validate
|
||||
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
|
||||
var productId = audibleProductId.Id;
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
|
||||
|
||||
// assign as soon as possible. stuff below relies on this
|
||||
AudibleProductId = productId;
|
||||
Locale = localeName;
|
||||
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title));
|
||||
|
||||
// non-ef-ctor init.s
|
||||
@@ -76,24 +85,17 @@ namespace DataLayer
|
||||
_seriesLink = new HashSet<SeriesBook>();
|
||||
_supplements = new HashSet<Supplement>();
|
||||
|
||||
// since category/id is never null, nullity means it hasn't been loaded
|
||||
CategoryId = Category.GetEmpty().CategoryId;
|
||||
Category = category;
|
||||
|
||||
// simple assigns
|
||||
AudibleProductId = productId;
|
||||
Title = title;
|
||||
Description = description;
|
||||
LengthInMinutes = lengthInMinutes;
|
||||
|
||||
// assigns with biz logic
|
||||
ReplaceAuthors(authors);
|
||||
//ReplaceNarrators(narrators);
|
||||
|
||||
// import previously saved tags
|
||||
// do this immediately. any save occurs before reloading tags will overwrite persistent tags with new blank entries; all old persisted tags will be lost
|
||||
// if refactoring, DO NOT use "ProductId" before it's assigned to. to be safe, just use "productId"
|
||||
UserDefinedItem = new UserDefinedItem(this) { Tags = FileManager.TagsPersistence.GetTags(productId) };
|
||||
}
|
||||
ReplaceNarrators(narrators);
|
||||
}
|
||||
|
||||
#region contributors, authors, narrators
|
||||
// use uninitialised backing fields - this means we can detect if the collection was loaded
|
||||
@@ -125,16 +127,10 @@ namespace DataLayer
|
||||
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
|
||||
|
||||
// the edge cases of doing local-loaded vs remote-only got weird. just load it
|
||||
if (_contributorsLink == null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
if (!context.Entry(this).IsKeySet)
|
||||
throw new InvalidOperationException("Could not add contributors");
|
||||
if (_contributorsLink is null)
|
||||
getEntry(context).Collection(s => s.ContributorsLink).Load();
|
||||
|
||||
context.Entry(this).Collection(s => s.ContributorsLink).Load();
|
||||
}
|
||||
|
||||
var roleContributions = getContributions(role);
|
||||
var roleContributions = getContributions(role);
|
||||
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
|
||||
if (isIdentical)
|
||||
return;
|
||||
@@ -142,7 +138,8 @@ namespace DataLayer
|
||||
_contributorsLink.RemoveWhere(bc => bc.Role == role);
|
||||
addNewContributors(newContributors, role);
|
||||
}
|
||||
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
||||
|
||||
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
||||
{
|
||||
byte order = 0;
|
||||
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
|
||||
@@ -157,6 +154,18 @@ namespace DataLayer
|
||||
.ToList();
|
||||
#endregion
|
||||
|
||||
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
|
||||
var entry = context.Entry(this);
|
||||
|
||||
if (!entry.IsKeySet)
|
||||
throw new InvalidOperationException("Could not load a valid Book from database");
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
#region series
|
||||
private HashSet<SeriesBook> _seriesLink;
|
||||
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
|
||||
@@ -188,16 +197,10 @@ namespace DataLayer
|
||||
|
||||
// our add() is conditional upon what's already included in the collection.
|
||||
// therefore if not loaded, a trip is required. might as well just load it
|
||||
if (_seriesLink == null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
if (!context.Entry(this).IsKeySet)
|
||||
throw new InvalidOperationException("Could not add series");
|
||||
if (_seriesLink is null)
|
||||
getEntry(context).Collection(s => s.SeriesLink).Load();
|
||||
|
||||
context.Entry(this).Collection(s => s.SeriesLink).Load();
|
||||
}
|
||||
|
||||
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
|
||||
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
|
||||
if (singleSeriesBook == null)
|
||||
_seriesLink.Add(new SeriesBook(series, this, index));
|
||||
else
|
||||
@@ -208,13 +211,12 @@ namespace DataLayer
|
||||
#region supplements
|
||||
private HashSet<Supplement> _supplements;
|
||||
public IEnumerable<Supplement> Supplements => _supplements?.ToList();
|
||||
public bool HasPdfs => Supplements.Any();
|
||||
public bool HasPdf => Supplements.Any();
|
||||
|
||||
public void AddSupplementDownloadUrl(string url)
|
||||
{
|
||||
// supplements are owned by Book, so no need to Load():
|
||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
|
||||
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
|
||||
|
||||
@@ -231,24 +233,21 @@ namespace DataLayer
|
||||
// don't overwrite with default values
|
||||
IsAbridged |= isAbridged;
|
||||
DatePublished = datePublished ?? DatePublished;
|
||||
|
||||
HasBookDetails = true;
|
||||
}
|
||||
|
||||
public void UpdateCategory(Category category, DbContext context = null)
|
||||
{
|
||||
// since category is never null, nullity means it hasn't been loaded
|
||||
if (Category != null || CategoryId == Category.GetEmpty().CategoryId)
|
||||
{
|
||||
Category = category;
|
||||
return;
|
||||
}
|
||||
{
|
||||
// since category is never null, nullity means it hasn't been loaded
|
||||
if (Category is null)
|
||||
getEntry(context).Reference(s => s.Category).Load();
|
||||
|
||||
if (context == null)
|
||||
throw new Exception("need context");
|
||||
|
||||
context.Entry(this).Reference(s => s.Category).Load();
|
||||
Category = category;
|
||||
Category = category;
|
||||
}
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
public void UpdateLocale(string localeName)
|
||||
=> Locale ??= localeName;
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,7 @@ namespace DataLayer
|
||||
Role = role;
|
||||
Order = order;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Book} {Contributor} {Role} {Order}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ namespace DataLayer
|
||||
public class Category
|
||||
{
|
||||
// Empty is a special case. use private ctor w/o validation
|
||||
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "", ParentCategory = null };
|
||||
public bool IsEmpty() => string.IsNullOrWhiteSpace(AudibleCategoryId) || string.IsNullOrWhiteSpace(Name) || ParentCategory == null;
|
||||
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "" };
|
||||
|
||||
internal int CategoryId { get; private set; }
|
||||
public string AudibleCategoryId { get; private set; }
|
||||
@@ -48,5 +47,7 @@ namespace DataLayer
|
||||
if (parentCategory != null)
|
||||
ParentCategory = parentCategory;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,31 @@ 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
|
||||
{
|
||||
// Empty is a special case. use private ctor w/o validation
|
||||
public static Contributor GetEmpty() => new Contributor { ContributorId = -1, Name = "" };
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
||||
internal int ContributorId { get; private set; }
|
||||
// id.s
|
||||
// ----
|
||||
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
|
||||
// goes to summary page
|
||||
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
|
||||
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
|
||||
|
||||
internal int ContributorId { get; private set; }
|
||||
public string Name { get; private set; }
|
||||
|
||||
private HashSet<BookContributor> _booksLink;
|
||||
public IEnumerable<BookContributor> BooksLink => _booksLink?.ToList();
|
||||
|
||||
public string AudibleContributorId { get; private set; }
|
||||
|
||||
private Contributor() { }
|
||||
public Contributor(string name)
|
||||
{
|
||||
@@ -34,49 +39,13 @@ namespace DataLayer
|
||||
|
||||
Name = name;
|
||||
}
|
||||
public Contributor(string name, string audibleContributorId) : this(name)
|
||||
{
|
||||
// don't overwrite with null or whitespace but not an error
|
||||
if (!string.IsNullOrWhiteSpace(audibleContributorId))
|
||||
AudibleContributorId = audibleContributorId;
|
||||
}
|
||||
|
||||
public string AudibleAuthorId { get; private set; }
|
||||
public void UpdateAudibleAuthorId(string authorId)
|
||||
{
|
||||
// don't overwrite with null or whitespace but not an error
|
||||
if (!string.IsNullOrWhiteSpace(authorId))
|
||||
AudibleAuthorId = authorId;
|
||||
}
|
||||
|
||||
#region // AudibleAuthorId refactor: separate author-specific info. overkill for a single optional string
|
||||
///// <summary>Most authors in Audible have a unique id</summary>
|
||||
//public AudibleAuthorProperty AudibleAuthorProperty { get; private set; }
|
||||
//public void UpdateAuthorId(string authorId, LibationContext context = null)
|
||||
//{
|
||||
// if (authorId == null)
|
||||
// return;
|
||||
// if (AudibleAuthorProperty != null)
|
||||
// {
|
||||
// AudibleAuthorProperty.UpdateAudibleAuthorId(authorId);
|
||||
// return;
|
||||
// }
|
||||
// if (context == null)
|
||||
// throw new ArgumentNullException(nameof(context), "You must provide a context");
|
||||
// if (context.Contributors.Find(ContributorId) == null)
|
||||
// throw new InvalidOperationException("Could not update audible author id.");
|
||||
// var audibleAuthorProperty = new AudibleAuthorProperty();
|
||||
// audibleAuthorProperty.UpdateAudibleAuthorId(authorId);
|
||||
// context.AuthorProperties.Add(audibleAuthorProperty);
|
||||
//}
|
||||
//public class AudibleAuthorProperty
|
||||
//{
|
||||
// public int ContributorId { get; private set; }
|
||||
// public Contributor Contributor { get; set; }
|
||||
|
||||
// public string AudibleAuthorId { get; private set; }
|
||||
|
||||
// public void UpdateAudibleAuthorId(string authorId)
|
||||
// {
|
||||
// if (!string.IsNullOrWhiteSpace(authorId))
|
||||
// AudibleAuthorId = authorId;
|
||||
// }
|
||||
//}
|
||||
//// ...and create EF table config
|
||||
#endregion
|
||||
}
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,24 @@ namespace DataLayer
|
||||
|
||||
public DateTime DateAdded { get; private set; }
|
||||
|
||||
/// <summary>For downloading AAX file</summary>
|
||||
public string DownloadBookLink { get; private set; }
|
||||
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
|
||||
public string Account { get; private set; }
|
||||
|
||||
private LibraryBook() { }
|
||||
public LibraryBook(Book book, DateTime dateAdded
|
||||
, string downloadBookLink = null
|
||||
)
|
||||
public LibraryBook(Book book, DateTime dateAdded, string account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
Book = book;
|
||||
DateAdded = dateAdded;
|
||||
DownloadBookLink = downloadBookLink;
|
||||
Account = account;
|
||||
}
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
public void UpdateAccount(string account)
|
||||
=> Account ??= account;
|
||||
|
||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,5 +72,7 @@ namespace DataLayer
|
||||
|
||||
return string.Join("\r\n", items);
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,5 +66,7 @@ namespace DataLayer
|
||||
if (_booksLink.SingleOrDefault(sb => sb.Book == book) == null)
|
||||
_booksLink.Add(new SeriesBook(this, book, index));
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,7 @@ namespace DataLayer
|
||||
if (index.HasValue)
|
||||
Index = index.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"Series={Series} Book={Book}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,7 @@ namespace DataLayer
|
||||
Book = book;
|
||||
Url = url;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Book} {Url.Substring(Url.Length - 4)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,29 +13,36 @@ namespace DataLayer
|
||||
|
||||
private UserDefinedItem() { }
|
||||
internal UserDefinedItem(Book book)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
Book = book;
|
||||
}
|
||||
|
||||
// import previously saved tags
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
|
||||
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
||||
}
|
||||
|
||||
private string _tags = "";
|
||||
public string Tags
|
||||
{
|
||||
get => _tags;
|
||||
set => _tags = sanitize(value);
|
||||
}
|
||||
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
|
||||
// only legal chars are letters numbers underscores and separating whitespace
|
||||
//
|
||||
// technically, the only char.s which aren't easily supported are \ [ ]
|
||||
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
|
||||
// it's easy to expand whitelist as needed
|
||||
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
|
||||
//
|
||||
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
|
||||
// full list of characters which must be escaped:
|
||||
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
||||
static Regex regex = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
|
||||
// only legal chars are letters numbers underscores and separating whitespace
|
||||
//
|
||||
// technically, the only char.s which aren't easily supported are \ [ ]
|
||||
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
|
||||
// it's easy to expand whitelist as needed
|
||||
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
|
||||
//
|
||||
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
|
||||
// full list of characters which must be escaped:
|
||||
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
||||
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
|
||||
private static string sanitize(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
@@ -63,8 +70,6 @@ namespace DataLayer
|
||||
|
||||
return string.Join(" ", unique);
|
||||
}
|
||||
|
||||
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
||||
#endregion
|
||||
|
||||
// owned: not an optional one-to-one
|
||||
@@ -73,5 +78,7 @@ namespace DataLayer
|
||||
|
||||
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
|
||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Book} {Rating} {Tags}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ namespace DataLayer
|
||||
public DbSet<Series> Series { get; private set; }
|
||||
public DbSet<Category> Categories { get; private set; }
|
||||
|
||||
public static LibationContext Create()
|
||||
public static LibationContext Create(string connectionString)
|
||||
{
|
||||
var factory = new LibationContextFactory();
|
||||
var context = factory.Create();
|
||||
var context = factory.Create(connectionString);
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -56,12 +56,16 @@ namespace DataLayer
|
||||
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||
|
||||
// seeds go here. examples in scratch pad
|
||||
modelBuilder
|
||||
.Entity<Category>()
|
||||
.HasData(Category.GetEmpty());
|
||||
// seeds go here. examples in scratch pad
|
||||
modelBuilder
|
||||
.Entity<Category>()
|
||||
.HasData(Category.GetEmpty());
|
||||
modelBuilder
|
||||
.Entity<Contributor>()
|
||||
.HasData(Contributor.GetEmpty());
|
||||
|
||||
// views are now supported via "query types" (instead of "entity types"): https://docs.microsoft.com/en-us/ef/core/modeling/query-types
|
||||
}
|
||||
}
|
||||
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
|
||||
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace DataLayer
|
||||
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
|
||||
{
|
||||
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlServer(connectionString);
|
||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder
|
||||
//.UseSqlServer
|
||||
.UseSqlite
|
||||
(connectionString);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,11 @@ namespace DataLayer
|
||||
{
|
||||
public static class BookQueries
|
||||
{
|
||||
public static int BooksWithoutDetailsCount()
|
||||
{
|
||||
using var context = LibationContext.Create();
|
||||
return context
|
||||
.Books
|
||||
.Count(b => !b.HasBookDetails);
|
||||
}
|
||||
|
||||
public static Book GetBook_Flat_NoTracking(string productId)
|
||||
{
|
||||
using var context = LibationContext.Create();
|
||||
return context
|
||||
public static Book GetBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
=> context
|
||||
.Books
|
||||
.AsNoTracking()
|
||||
.GetBook(productId);
|
||||
}
|
||||
|
||||
public static Book GetBook(this IQueryable<Book> books, string productId)
|
||||
=> books
|
||||
|
||||
@@ -5,25 +5,25 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace DataLayer
|
||||
{
|
||||
public static class LibraryQueries
|
||||
{
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
|
||||
{
|
||||
using var context = LibationContext.Create();
|
||||
return context
|
||||
{
|
||||
public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
|
||||
=> context
|
||||
.Library
|
||||
.GetLibrary()
|
||||
.ToList();
|
||||
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
|
||||
=> context
|
||||
.Library
|
||||
//.AsNoTracking()
|
||||
.AsNoTracking()
|
||||
.GetLibrary()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(string productId)
|
||||
{
|
||||
using var context = LibationContext.Create();
|
||||
return context
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
=> context
|
||||
.Library
|
||||
//.AsNoTracking()
|
||||
.GetLibraryBook(productId);
|
||||
}
|
||||
.AsNoTracking()
|
||||
.GetLibraryBook(productId);
|
||||
|
||||
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
||||
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
|
||||
|
||||
@@ -9,52 +9,22 @@ namespace DataLayer
|
||||
{
|
||||
internal class TagPersistenceInterceptor : IDbInterceptor
|
||||
{
|
||||
public void Executing(DbContext context)
|
||||
{
|
||||
doWork__EFCore(context);
|
||||
}
|
||||
|
||||
public void Executed(DbContext context) { }
|
||||
|
||||
static void doWork__EFCore(DbContext context)
|
||||
{
|
||||
// persist tags:
|
||||
var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State.In(EntityState.Modified, EntityState.Added)).ToList();
|
||||
var tagSets = modifiedEntities.Select(e => e.Entity as UserDefinedItem).Where(a => a != null).ToList();
|
||||
foreach (var t in tagSets)
|
||||
FileManager.TagsPersistence.Save(t.Book.AudibleProductId, t.Tags);
|
||||
}
|
||||
public void Executing(DbContext context)
|
||||
{
|
||||
var tagsCollection
|
||||
= context
|
||||
.ChangeTracker
|
||||
.Entries()
|
||||
.Where(e => e.State.In(EntityState.Modified, EntityState.Added))
|
||||
.Select(e => e.Entity as UserDefinedItem)
|
||||
.Where(udi => udi != null)
|
||||
// do NOT filter out entires with blank tags. blank is the valid way to show the absence of tags
|
||||
.Select(t => (t.Book.AudibleProductId, t.Tags))
|
||||
.ToList();
|
||||
|
||||
#region // notes: working with proxies, esp EF 6
|
||||
// EF 6: entities are proxied with lazy loading when collections are virtual
|
||||
// EF Core: lazy loading is supported in 2.1 (there is a version of lazy loading with proxy-wrapping and a proxy-less version with DI) but not on by default and are not supported here
|
||||
|
||||
//static void doWork_EF6(DbContext context)
|
||||
//{
|
||||
// var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State == EntityState.Modified).ToList();
|
||||
// var unproxiedEntities = modifiedEntities.Select(me => UnProxy(context, me.Entity)).ToList();
|
||||
|
||||
// // persist tags
|
||||
// var tagSets = unproxiedEntities.Select(ue => ue as UserDefinedItem).Where(a => a != null).ToList();
|
||||
// foreach (var t in tagSets)
|
||||
// FileManager.TagsPersistence.Save(t.ProductId, t.TagsRaw);
|
||||
//}
|
||||
|
||||
//// https://stackoverflow.com/a/25774651
|
||||
//private static T UnProxy<T>(DbContext context, T proxyObject) where T : class
|
||||
//{
|
||||
// // alternative: https://docs.microsoft.com/en-us/ef/ef6/fundamentals/proxies
|
||||
// var proxyCreationEnabled = context.Configuration.ProxyCreationEnabled;
|
||||
// try
|
||||
// {
|
||||
// context.Configuration.ProxyCreationEnabled = false;
|
||||
// return context.Entry(proxyObject).CurrentValues.ToObject() as T;
|
||||
// }
|
||||
// finally
|
||||
// {
|
||||
// context.Configuration.ProxyCreationEnabled = proxyCreationEnabled;
|
||||
// }
|
||||
//}
|
||||
#endregion
|
||||
}
|
||||
FileManager.TagsPersistence.Save(tagsCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
DataLayer/UNTESTED/Utilities/LocalDatabaseInfo.cs
Normal file
38
DataLayer/UNTESTED/Utilities/LocalDatabaseInfo.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DataLayer.Utilities
|
||||
{
|
||||
public static class LocalDatabaseInfo
|
||||
{
|
||||
public static List<string> GetLocalDBInstances()
|
||||
{
|
||||
// Start the child process.
|
||||
using var p = new System.Diagnostics.Process
|
||||
{
|
||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
FileName = "cmd.exe",
|
||||
Arguments = "/C sqllocaldb info",
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden
|
||||
}
|
||||
};
|
||||
p.Start();
|
||||
var output = p.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
|
||||
// if LocalDb is not installed then it will return that 'sqllocaldb' is not recognized as an internal or external command operable program or batch file
|
||||
return string.IsNullOrWhiteSpace(output) || output.Contains("not recognized")
|
||||
? new List<string>()
|
||||
: output
|
||||
.Split(new string[] { Environment.NewLine }, StringSplitOptions.None)
|
||||
.Select(i => i.Trim())
|
||||
.Where(i => !string.IsNullOrEmpty(i))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
HOW TO CREATE: EF CORE PROJECT
|
||||
FOR QUICK MIGRATION INSTRUCTIONS:
|
||||
_DB_NOTES.txt
|
||||
|
||||
|
||||
HOW TO CREATE: EF CORE PROJECT
|
||||
==============================
|
||||
easiest with .NET Core but there's also a work-around for .NET Standard
|
||||
example is for sqlite but the same works with MsSql
|
||||
|
||||
|
||||
@@ -8,15 +11,22 @@ nuget
|
||||
Microsoft.EntityFrameworkCore.Tools (needed for using Package Manager Console)
|
||||
Microsoft.EntityFrameworkCore.Sqlite
|
||||
|
||||
MIGRATIONS require standard, not core
|
||||
using standard instead of core. edit 3 things in csproj
|
||||
1of3: pluralize xml TargetFramework tag to TargetFrameworks
|
||||
2of2: TargetFrameworks from: netstandard2.1
|
||||
to: netcoreapp3.0;netstandard2.1
|
||||
3of3: add
|
||||
<PropertyGroup>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
</PropertyGroup>
|
||||
MIGRATIONS
|
||||
require core, not standard
|
||||
this can be a problem b/c standard and framework can only reference standard, not core
|
||||
TO USE MIGRATIONS (core and/or standard)
|
||||
add to csproj
|
||||
<PropertyGroup>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
</PropertyGroup>
|
||||
TO USE MIGRATIONS AS *BOTH* CORE AND STANDARD
|
||||
edit csproj
|
||||
pluralize this xml tag
|
||||
from: TargetFramework
|
||||
to: TargetFrameworks
|
||||
inside of TargetFrameworks
|
||||
from: netstandard2.1
|
||||
to: netcoreapp3.1;netstandard2.1
|
||||
|
||||
run. error
|
||||
SQLite Error 1: 'no such table: Blogs'.
|
||||
@@ -26,7 +36,6 @@ set project "Set as StartUp Project"
|
||||
Tools >> Nuget Package Manager >> Package Manager Console
|
||||
default project: Examples\SQLite_NETCore2_0
|
||||
|
||||
note: in EFCore, Enable-Migrations is no longer used. start with add-migration
|
||||
PM> add-migration InitialCreate
|
||||
PM> Update-Database
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
proposed extensible schema to generalize beyond audible
|
||||
|
||||
problems
|
||||
0) reeks of premature optimization
|
||||
- i'm currently only doing audible audiobooks. this adds several layers of abstraction for the sake of possible expansion
|
||||
- there's a good chance that supporting another platform may not conform to this schema, in which case i'd have done this for nothing. genres are one likely pain point
|
||||
- libation is currently single-user. hopefully the below would suffice for adding users, but if i'm wrong it might be all pain and no gain
|
||||
1) very thorough == very complex
|
||||
2) there are some books which would still be difficult to taxonimize
|
||||
- joy of cooking. has become more of a brand
|
||||
- the bible. has different versions that aren't just editions
|
||||
- dictionary. authored by a publisher
|
||||
3) "books" vs "editions" is a confusing problem waiting to happen
|
||||
|
||||
[AIPK=auto increm PK]
|
||||
|
||||
(libation) users [AIPK id, name, join date]
|
||||
audible users [AIPK id, AUDIBLE-PK username]
|
||||
libation audible users [PK user id, PK audible user id -- cluster PK across all FKs]
|
||||
- potential danger in multi-user environment. wouldn't want one libation user getting access to a different libation user's audible info
|
||||
contributors [AIPK id, name]. prev people. incl publishers
|
||||
audible authors [PK/FK contributor id, AUDIBLE-PK author id]
|
||||
roles [AIPK id, name]. seeded: author, narrator, publisher. could expand (eg: translator, editor) without each needing a new table
|
||||
books [AIPK id, title, desc]
|
||||
book contributors [FK book id, FK contributor id, FK role id, order -- cluster PK across all FKs]
|
||||
- likely only authors
|
||||
editions [AIPK id, FK book id, title]. could expand to include year, is first edition, is abridged
|
||||
- reasons for optional different title: "Ender's Game: Special 20th Anniversary Edition", "Harry Potter and the Sorcerer's Stone" vs "Harry Potter and the Philosopher's Stone" vs "Harry Potter y la piedra filosofal", "Midnight Riot" vs "Rivers of London"
|
||||
edition contributors [FK edition id, FK contributor id, FK role id, order -- cluster PK across all FKs]
|
||||
- likely everything except authors. eg narrators, publisher
|
||||
audiobooks [PK/FK edition id, lengthInMinutes]
|
||||
- could expand to other formats by adding other similar tables. eg: print with #pages and isbn, ebook with mb
|
||||
audible origins [AIPK id, name]. seeded: library. detail. json. series
|
||||
audible books [PK/FK edition id, AUDIBLE-PK product id, picture id, sku, 3 ratings, audible category id, audible origin id]
|
||||
- could expand to other vendors by adding other similar tables
|
||||
audible user ratings [PK/FK edition id, audible user id, 3 ratings]
|
||||
audible supplements [AIPK id, FK edition id, download url]
|
||||
- pdfs only. although book download info could be the same format, they're substantially different and subject to change
|
||||
audible book downloads [PK/FK edition id, audible user id, bookdownloadlink]
|
||||
pictures [AIPK id, FK edition id, filename (xyz.jpg -- not incl path)]
|
||||
audible categories [AIPK id, AUDIBLE-PK category id, name, parent]. may only nest 1 deep
|
||||
(libation) library [FK libation user id, FK edition id, date added -- cluster PK across all FKs]
|
||||
(libation) user defined [FK libation user id, FK edition id, tagsRaw (, notes...) -- cluster PK across all FKs]
|
||||
- there's no reason to restrict tags to library items, so don't combine/link this table with library
|
||||
series [AIPK id, name]
|
||||
audible series [FK series id, AUDIBLE-PK series id/asin, audible origin id]
|
||||
- could also include a 'name' field for what audible calls this series
|
||||
series books [FK series id, FK book id (NOT edition id), index -- cluster PK across all FKs]
|
||||
- "index" not "order". display this number; don't just put in this sequence
|
||||
- index is float instead of int to allow for in-between books. eg 2.5
|
||||
- if only using "editions" (ie: getting rid of the "books" table), to show 2 editions as the same book in a series, give them the same index
|
||||
(libation) user shelves [AIPK id, FK libation user id, name, desc]
|
||||
- custom shelf. similar to library but very different in philosophy. likely different in evolving details
|
||||
(libation) shelf books [AIPK id, FK user shelf id, date added, order]
|
||||
- technically, it's no violation to list a book more than once so use AIPK
|
||||
@@ -1,76 +0,0 @@
|
||||
ignore for now:
|
||||
authorProperties [PK/FK contributor id, AUDIBLE-PK author id]
|
||||
notes in Contributor.cs for later refactoring
|
||||
|
||||
c# enum only, not their own tables:
|
||||
roles [AIPK id, name]. seeded: author, narrator, publisher. could expand (eg: translator, editor) without each needing a new table
|
||||
origins [AIPK id, name]. seeded: library. detail. json. series
|
||||
|
||||
|
||||
-- begin SCHEMA ---------------------------------------------------------------------------------------------------------------------
|
||||
any audible keys should be indexed
|
||||
|
||||
SCHEMA
|
||||
======
|
||||
contributors [AIPK id, name]. people and publishers
|
||||
books [AIPK id, AUDIBLE-PK product id, title, desc, lengthInMinutes, picture id, 3 ratings, category id, origin id]
|
||||
- product instances. each edition and version is discrete: unique and disconnected from different editions of the same book
|
||||
- on book re-import
|
||||
update:
|
||||
update book origin and series origin with the new source type
|
||||
overwrite simple fields
|
||||
invoke complex contributor updates
|
||||
details page gets
|
||||
un/abridged
|
||||
release date
|
||||
language
|
||||
publisher
|
||||
series info incl name
|
||||
categories
|
||||
if new == series: ignore. do update series info. do not update book info
|
||||
else if old == json: update (incl if new == json)
|
||||
else if old == library && new == detail: update
|
||||
else: ignore
|
||||
book contributors [FK book id, FK contributor id, FK role id, order -- cluster PK across all FKs]
|
||||
supplements [AIPK id, FK book id, download url]
|
||||
categories [AIPK id, AUDIBLE-PK category id, name, parent]. may only nest 1 deep
|
||||
user defined [PK/FK book id, 3 ratings, tagsRaw]
|
||||
series [AIPK id, AUDIBLE-PK series id/asin, name, origin id]
|
||||
series books [FK series id, FK book id, index -- cluster PK across all FKs]
|
||||
- "index" not "order". display this number; don't just put in this sequence
|
||||
- index is float instead of int to allow for in-between books. eg 2.5
|
||||
- to show 2 editions as the same book in a series, give them the same index
|
||||
- re-import using series page, there will need to be a re-eval of import logic
|
||||
library [PK/FK book id, date added, bookdownloadlink]
|
||||
-- end SCHEMA ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
-- begin SIMPLIFIED DDD ---------------------------------------------------------------------------------------------------------------------
|
||||
combine domain and persistence (C(r)UD). no repository pattern. encapsulated in domain objects; direct calls to EF Core
|
||||
https://www.thereformedprogrammer.net/creating-domain-driven-design-entity-classes-with-entity-framework-core/
|
||||
// pattern for x-to-many
|
||||
public void AddReview(int numStars, DbContext context = null)
|
||||
{
|
||||
if (_reviews != null) _reviews.Add(new Review(numStars));
|
||||
else if (context == null) throw new Exception("need context");
|
||||
else if (context.Entry(this).IsKeySet) context.Add(new Review(numStars, BookId));
|
||||
else throw new Exception("Could not add");
|
||||
}
|
||||
|
||||
// pattern for optional one-to-one
|
||||
MyPropClass MyProps { get; private set; }
|
||||
public void AddMyProps(string s, int i, DbContext context = null)
|
||||
{
|
||||
// avoid a trip to the db
|
||||
if (MyProps != null) { MyProps.Update(s, i); return; }
|
||||
if (BookId == 0) { MyProps = new MyPropClass(s, i); return; }
|
||||
if (context == null) throw new Exception("need context");
|
||||
// per Jon P Smith, this single trip to db loads the property if there is one
|
||||
// note: .Reference() is for single object references. for collections use .Collection()
|
||||
context.Entry(this).Reference(s => s.MyProps).Load();
|
||||
if (MyProps != null) MyProps.Update(s, i);
|
||||
else MyProps = new MyPropClass(s, i);
|
||||
}
|
||||
|
||||
repository reads are 'query object'-like extension methods
|
||||
https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework-core/#1-query-objects-a-way-to-isolate-and-hide-database-read-code
|
||||
-- and SIMPLIFIED DDD ---------------------------------------------------------------------------------------------------------------------
|
||||
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"LibationContext": "Server=(LocalDb)\\MSSQLLocalDB;Database=DataLayer.LibationContext;Integrated Security=true;",
|
||||
"LibationContext_sqlserver": "Server=(LocalDb)\\MSSQLLocalDB;Database=DataLayer.LibationContext;Integrated Security=true;",
|
||||
|
||||
"// on windows sqlite paths accept windows and/or unix slashes": "",
|
||||
"// this connection string is ONLY used for DataLayer's Migrations. this appsettings.json file is NOT used at all by application; it is overwritten": "",
|
||||
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;",
|
||||
|
||||
"// sqlite notes": "",
|
||||
"// absolute path example": "Data Source=C:/foo/bar/sample.db",
|
||||
"// relative path example": "Data Source=sample.db",
|
||||
"// on windows: sqlite paths accept windows and/or unix slashes": "",
|
||||
"MyTestContext": "Data Source=%DESKTOP%/sample.db"
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class BookImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new BookValidator().Validate(items);
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
{
|
||||
// pre-req.s
|
||||
new ContributorImporter().Import(items, context);
|
||||
new SeriesImporter().Import(items, context);
|
||||
new CategoryImporter().Import(items, context);
|
||||
|
||||
// get distinct
|
||||
var productIds = items.Select(i => i.ProductId).ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_books(productIds, context);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertBooks(items, context);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_books(List<string> productIds, LibationContext context)
|
||||
{
|
||||
var localProductIds = context.Books.Local.Select(b => b.AudibleProductId);
|
||||
var remainingProductIds = productIds
|
||||
.Distinct()
|
||||
.Except(localProductIds)
|
||||
.ToList();
|
||||
|
||||
// GetBooks() eager loads Series, category, et al
|
||||
if (remainingProductIds.Any())
|
||||
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertBooks(IEnumerable<Item> items, LibationContext context)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var book = context.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.ProductId);
|
||||
if (book is null)
|
||||
{
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
var authors = item
|
||||
.Authors
|
||||
.Select(a => context.Contributors.Local.Single(c => a.Name == c.Name))
|
||||
.ToList();
|
||||
|
||||
book = context.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId), item.Title, item.Description, item.LengthInMinutes, authors))
|
||||
.Entity;
|
||||
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
// if no narrators listed, author is the narrator
|
||||
if (item.Narrators is null || !item.Narrators.Any())
|
||||
item.Narrators = item.Authors;
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
var narrators = item
|
||||
.Narrators
|
||||
.Select(n => context.Contributors.Local.Single(c => n.Name == c.Name))
|
||||
.ToList();
|
||||
// not all books have narrators. these will already be using author as narrator. don't undo this
|
||||
if (narrators.Any())
|
||||
book.ReplaceNarrators(narrators);
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
book.PictureId = item.PictureId;
|
||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
|
||||
book.AddSupplementDownloadUrl(item.SupplementUrl);
|
||||
|
||||
var publisherName = item.Publisher;
|
||||
if (!string.IsNullOrWhiteSpace(publisherName))
|
||||
{
|
||||
var publisher = context.Contributors.Local.Single(c => publisherName == c.Name);
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
||||
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
||||
|
||||
//
|
||||
// this was round 1 when it was a 2 step process
|
||||
//
|
||||
//// update series even for existing books. these are occasionally updated
|
||||
//var seriesIds = item.Series.Select(kvp => kvp.SeriesId).ToList();
|
||||
//var allSeries = context.Series.Local.Where(c => seriesIds.Contains(c.AudibleSeriesId)).ToList();
|
||||
//foreach (var series in allSeries)
|
||||
// book.UpsertSeries(series);
|
||||
|
||||
// these will upsert over library-scraped series, but will not leave orphans
|
||||
if (item.Series != null)
|
||||
{
|
||||
foreach (var seriesEntry in item.Series)
|
||||
{
|
||||
var series = context.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||
book.UpsertSeries(series, seriesEntry.Index);
|
||||
}
|
||||
}
|
||||
|
||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == item.Categories.LastOrDefault().CategoryId);
|
||||
if (category != null)
|
||||
book.UpdateCategory(category, context);
|
||||
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class CategoryImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new CategoryValidator().Validate(items);
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
{
|
||||
// get distinct
|
||||
var categoryIds = items.GetCategoriesDistinct().Select(c => c.CategoryId).ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_categories(categoryIds, context);
|
||||
|
||||
// upsert
|
||||
var categoryPairs = items.GetCategoryPairsDistinct().ToList();
|
||||
var qtyNew = upsertCategories(categoryPairs, context);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_categories(List<string> categoryIds, LibationContext context)
|
||||
{
|
||||
var localIds = context.Categories.Local.Select(c => c.AudibleCategoryId);
|
||||
var remainingCategoryIds = categoryIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
|
||||
if (remainingCategoryIds.Any())
|
||||
context.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId)).ToList();
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertCategories(List<Ladder[]> categoryPairs, LibationContext context)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var pair in categoryPairs)
|
||||
{
|
||||
for (var i = 0; i < pair.Length; i++)
|
||||
{
|
||||
var id = pair[i].CategoryId;
|
||||
var name = pair[i].CategoryName;
|
||||
|
||||
Category parentCategory = null;
|
||||
if (i == 1)
|
||||
parentCategory = context.Categories.Local.Single(c => c.AudibleCategoryId == pair[0].CategoryId);
|
||||
|
||||
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == id);
|
||||
if (category is null)
|
||||
{
|
||||
category = context.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
category.UpdateParentCategory(parentCategory);
|
||||
}
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class ContributorImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new ContributorValidator().Validate(items);
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
{
|
||||
// get distinct
|
||||
var authors = items.GetAuthorsDistinct().ToList();
|
||||
var narrators = items.GetNarratorsDistinct().ToList();
|
||||
var publishers = items.GetPublishersDistinct().ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
var allNames = authors
|
||||
.Select(a => a.Name)
|
||||
.Union(narrators.Select(n => n.Name))
|
||||
.Union(publishers)
|
||||
.ToList();
|
||||
loadLocal_contributors(allNames, context);
|
||||
|
||||
// upsert
|
||||
var qtyNew = 0;
|
||||
qtyNew += upsertPeople(authors, context);
|
||||
qtyNew += upsertPeople(narrators, context);
|
||||
qtyNew += upsertPublishers(publishers, context);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_contributors(List<string> contributorNames, LibationContext context)
|
||||
{
|
||||
contributorNames.Remove(null);
|
||||
contributorNames.Remove("");
|
||||
|
||||
//// BAD: very inefficient
|
||||
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
|
||||
|
||||
// GOOD: Except() is efficient. Due to hashing, it's close to O(n)
|
||||
var localNames = context.Contributors.Local.Select(c => c.Name);
|
||||
var remainingContribNames = contributorNames
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
|
||||
// load existing => local
|
||||
if (remainingContribNames.Any())
|
||||
context.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList();
|
||||
// _________________________________^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
// i tried to extract this pattern, but this part prohibits doing so
|
||||
// wouldn't work anyway for Books.GetBooks()
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPeople(List<Person> people, LibationContext context)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var p in people)
|
||||
{
|
||||
var person = context.Contributors.Local.SingleOrDefault(c => c.Name == p.Name);
|
||||
if (person == null)
|
||||
{
|
||||
person = context.Contributors.Add(new Contributor(p.Name)).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
person.UpdateAudibleAuthorId(p.Asin);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPublishers(List<string> publishers, LibationContext context)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var publisherName in publishers)
|
||||
{
|
||||
if (context.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
|
||||
{
|
||||
context.Contributors.Add(new Contributor(publisherName));
|
||||
qtyNew++;
|
||||
}
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public interface IContextRunner<T>
|
||||
{
|
||||
public TResult Run<TResult>(Func<T, LibationContext, TResult> func, T param, LibationContext context = null)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
using (context = LibationContext.Create())
|
||||
{
|
||||
var r = Run(func, param, context);
|
||||
context.SaveChanges();
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
var exceptions = Validate(param);
|
||||
if (exceptions != null && exceptions.Any())
|
||||
throw new AggregateException($"Device Jobs Service configuration validation failed", exceptions);
|
||||
|
||||
var result = func(param, context);
|
||||
return result;
|
||||
}
|
||||
IEnumerable<Exception> Validate(T param);
|
||||
}
|
||||
|
||||
public abstract class ImporterBase<T> : IContextRunner<T>
|
||||
{
|
||||
/// <summary>LONG RUNNING. call with await Task.Run</summary>
|
||||
public int Import(T param, LibationContext context = null)
|
||||
=> ((IContextRunner<T>)this).Run(DoImport, param, context);
|
||||
|
||||
protected abstract int DoImport(T elements, LibationContext context);
|
||||
public abstract IEnumerable<Exception> Validate(T param);
|
||||
}
|
||||
|
||||
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<Item>> { }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class LibraryImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new LibraryValidator().Validate(items);
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
{
|
||||
new BookImporter().Import(items, context);
|
||||
|
||||
var qtyNew = upsertLibraryBooks(items, context);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private int upsertLibraryBooks(IEnumerable<Item> items, LibationContext context)
|
||||
{
|
||||
var currentLibraryProductIds = context.Library.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var newItems = items.Where(dto => !currentLibraryProductIds.Contains(dto.ProductId)).ToList();
|
||||
|
||||
foreach (var newItem in newItems)
|
||||
{
|
||||
var libraryBook = new LibraryBook(
|
||||
context.Books.Local.Single(b => b.AudibleProductId == newItem.ProductId),
|
||||
newItem.DateAdded
|
||||
// needed for scraping
|
||||
//,FileManager.FileUtility.RestoreDeclawed(newLibraryDTO.DownloadBookLink)
|
||||
);
|
||||
context.Library.Add(libraryBook);
|
||||
}
|
||||
|
||||
var qtyNew = newItems.Count;
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class SeriesImporter : ItemsImporterBase
|
||||
{
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new SeriesValidator().Validate(items);
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
|
||||
{
|
||||
// get distinct
|
||||
var series = items.GetSeriesDistinct().ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
var seriesIds = series.Select(s => s.SeriesId).ToList();
|
||||
loadLocal_series(seriesIds, context);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertSeries(series, context);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_series(List<string> seriesIds, LibationContext context)
|
||||
{
|
||||
var localIds = context.Series.Local.Select(s => s.AudibleSeriesId);
|
||||
var remainingSeriesIds = seriesIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
|
||||
if (remainingSeriesIds.Any())
|
||||
context.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries, LibationContext context)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var s in requestedSeries)
|
||||
{
|
||||
var series = context.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == s.SeriesId);
|
||||
if (series is null)
|
||||
{
|
||||
series = context.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
series.UpdateName(s.SeriesName);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
166
DtoImporterService/UNTESTED/BookImporter.cs
Normal file
166
DtoImporterService/UNTESTED/BookImporter.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class BookImporter : ItemsImporterBase
|
||||
{
|
||||
public BookImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new BookValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// pre-req.s
|
||||
new ContributorImporter(DbContext).Import(importItems);
|
||||
new SeriesImporter(DbContext).Import(importItems);
|
||||
new CategoryImporter(DbContext).Import(importItems);
|
||||
|
||||
// get distinct
|
||||
var productIds = importItems.Select(i => i.DtoItem.ProductId).ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_books(productIds);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertBooks(importItems);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_books(List<string> productIds)
|
||||
{
|
||||
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId);
|
||||
var remainingProductIds = productIds
|
||||
.Distinct()
|
||||
.Except(localProductIds)
|
||||
.ToList();
|
||||
|
||||
// GetBooks() eager loads Series, category, et al
|
||||
if (remainingProductIds.Any())
|
||||
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertBooks(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var item in importItems)
|
||||
{
|
||||
var book = DbContext.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
|
||||
if (book is null)
|
||||
{
|
||||
book = createNewBook(item);
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
updateBook(item, book);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private Book createNewBook(ImportItem importItem)
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
// absence of authors is very rare, but possible
|
||||
if (!item.Authors?.Any() ?? true)
|
||||
item.Authors = new[] { new Person { Name = "", Asin = null } };
|
||||
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
var authors = item
|
||||
.Authors
|
||||
.Select(a => DbContext.Contributors.Local.Single(c => a.Name == c.Name))
|
||||
.ToList();
|
||||
|
||||
var narrators
|
||||
= item.Narrators is null || !item.Narrators.Any()
|
||||
// if no narrators listed, author is the narrator
|
||||
? authors
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
: item
|
||||
.Narrators
|
||||
.Select(n => DbContext.Contributors.Local.Single(c => n.Name == c.Name))
|
||||
.ToList();
|
||||
|
||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||
// absence of categories is also possible
|
||||
|
||||
// CATEGORY HACK: only use the 1st 2 categories
|
||||
// (real impl: var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";)
|
||||
var lastCategory
|
||||
= item.Categories.Length == 0 ? ""
|
||||
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
|
||||
// 2+
|
||||
: item.Categories[1].CategoryId;
|
||||
|
||||
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
|
||||
|
||||
var book = DbContext.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.Title,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
authors,
|
||||
narrators,
|
||||
category,
|
||||
importItem.LocaleName)
|
||||
).Entity;
|
||||
|
||||
var publisherName = item.Publisher;
|
||||
if (!string.IsNullOrWhiteSpace(publisherName))
|
||||
{
|
||||
var publisher = DbContext.Contributors.Local.Single(c => publisherName == c.Name);
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
|
||||
book.AddSupplementDownloadUrl(item.SupplementUrl);
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
private void updateBook(ImportItem importItem, Book book)
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
book.PictureId = item.PictureId;
|
||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||
|
||||
// needed during v3 => v4 migration
|
||||
book.UpdateLocale(importItem.LocaleName);
|
||||
|
||||
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
||||
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
||||
|
||||
// update series even for existing books. these are occasionally updated
|
||||
// these will upsert over library-scraped series, but will not leave orphans
|
||||
if (item.Series != null)
|
||||
{
|
||||
foreach (var seriesEntry in item.Series)
|
||||
{
|
||||
var series = DbContext.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||
|
||||
var index = 0f;
|
||||
try
|
||||
{
|
||||
index = seriesEntry.Index;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Error parsing series index. Title: {item.Title}. ASIN: {item.Asin}. Series index: {seriesEntry.Sequence}");
|
||||
}
|
||||
|
||||
book.UpsertSeries(series, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
DtoImporterService/UNTESTED/CategoryImporter.cs
Normal file
85
DtoImporterService/UNTESTED/CategoryImporter.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class CategoryImporter : ItemsImporterBase
|
||||
{
|
||||
public CategoryImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new CategoryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var categoryIds = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetCategoriesDistinct()
|
||||
.Select(c => c.CategoryId).ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_categories(categoryIds);
|
||||
|
||||
// upsert
|
||||
var categoryPairs = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetCategoryPairsDistinct()
|
||||
.ToList();
|
||||
var qtyNew = upsertCategories(categoryPairs);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_categories(List<string> categoryIds)
|
||||
{
|
||||
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId);
|
||||
var remainingCategoryIds = categoryIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
|
||||
// load existing => local
|
||||
// remember to include default/empty/missing
|
||||
var emptyName = Contributor.GetEmpty().Name;
|
||||
if (remainingCategoryIds.Any())
|
||||
DbContext.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId) || c.Name == emptyName).ToList();
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertCategories(List<Ladder[]> categoryPairs)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var pair in categoryPairs)
|
||||
{
|
||||
for (var i = 0; i < pair.Length; i++)
|
||||
{
|
||||
// CATEGORY HACK: not yet supported: depth beyond 0 and 1
|
||||
if (i > 1)
|
||||
break;
|
||||
|
||||
var id = pair[i].CategoryId;
|
||||
var name = pair[i].CategoryName;
|
||||
|
||||
Category parentCategory = null;
|
||||
if (i == 1)
|
||||
parentCategory = DbContext.Categories.Local.Single(c => c.AudibleCategoryId == pair[0].CategoryId);
|
||||
|
||||
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == id);
|
||||
if (category is null)
|
||||
{
|
||||
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
category.UpdateParentCategory(parentCategory);
|
||||
}
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
DtoImporterService/UNTESTED/ContributorImporter.cs
Normal file
102
DtoImporterService/UNTESTED/ContributorImporter.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class ContributorImporter : ItemsImporterBase
|
||||
{
|
||||
public ContributorImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new ContributorValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var authors = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetAuthorsDistinct()
|
||||
.ToList();
|
||||
var narrators = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetNarratorsDistinct()
|
||||
.ToList();
|
||||
var publishers = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetPublishersDistinct()
|
||||
.ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
var allNames = publishers
|
||||
.Union(authors.Select(n => n.Name))
|
||||
.Union(narrators.Select(n => n.Name))
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.ToList();
|
||||
loadLocal_contributors(allNames);
|
||||
|
||||
// upsert
|
||||
var qtyNew = 0;
|
||||
qtyNew += upsertPeople(authors);
|
||||
qtyNew += upsertPeople(narrators);
|
||||
qtyNew += upsertPublishers(publishers);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_contributors(List<string> contributorNames)
|
||||
{
|
||||
//// BAD: very inefficient
|
||||
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
|
||||
|
||||
// GOOD: Except() is efficient. Due to hashing, it's close to O(n)
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name);
|
||||
var remainingContribNames = contributorNames
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
|
||||
// load existing => local
|
||||
// remember to include default/empty/missing
|
||||
var emptyName = Contributor.GetEmpty().Name;
|
||||
if (remainingContribNames.Any())
|
||||
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name) || c.Name == emptyName).ToList();
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPeople(List<Person> people)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var p in people)
|
||||
{
|
||||
var person = DbContext.Contributors.Local.SingleOrDefault(c => c.Name == p.Name);
|
||||
if (person == null)
|
||||
{
|
||||
person = DbContext.Contributors.Add(new Contributor(p.Name, p.Asin)).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPublishers(List<string> publishers)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var publisherName in publishers)
|
||||
{
|
||||
if (DbContext.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
|
||||
{
|
||||
DbContext.Contributors.Add(new Contributor(publisherName));
|
||||
qtyNew++;
|
||||
}
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
DtoImporterService/UNTESTED/ImporterBase.cs
Normal file
57
DtoImporterService/UNTESTED/ImporterBase.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public abstract class ImporterBase<T>
|
||||
{
|
||||
protected LibationContext DbContext { get; }
|
||||
|
||||
protected ImporterBase(LibationContext context)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
DbContext = context;
|
||||
}
|
||||
|
||||
/// <summary>LONG RUNNING. call with await Task.Run</summary>
|
||||
public int Import(T param) => Run(DoImport, param);
|
||||
|
||||
public TResult Run<TResult>(Func<T, TResult> func, T param)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exceptions = Validate(param);
|
||||
if (exceptions != null && exceptions.Any())
|
||||
throw new AggregateException($"Importer validation failed", exceptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Import error: validation");
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = func(param);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Import error: post-validation importing");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract int DoImport(T elements);
|
||||
public abstract IEnumerable<Exception> Validate(T param);
|
||||
}
|
||||
|
||||
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<ImportItem>>
|
||||
{
|
||||
protected ItemsImporterBase(LibationContext context) : base(context) { }
|
||||
}
|
||||
}
|
||||
62
DtoImporterService/UNTESTED/LibraryImporter.cs
Normal file
62
DtoImporterService/UNTESTED/LibraryImporter.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class LibraryImporter : ItemsImporterBase
|
||||
{
|
||||
public LibraryImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
new BookImporter(DbContext).Import(importItems);
|
||||
|
||||
var qtyNew = upsertLibraryBooks(importItems);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// technically, we should be able to have duplicate books from separate accounts.
|
||||
// this would violate the current pk and would be difficult to deal with elsewhere:
|
||||
// - what to show in the grid
|
||||
// - which to consider liberated
|
||||
//
|
||||
// sqlite cannot alter pk. the work around is an extensive headache. it'll be fixed in pre .net5/efcore5
|
||||
//
|
||||
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
|
||||
//
|
||||
// CURRENT SOLUTION: don't re-insert
|
||||
|
||||
var currentLibraryProductIds = DbContext.Library.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
|
||||
|
||||
foreach (var newItem in newItems)
|
||||
{
|
||||
var libraryBook = new LibraryBook(
|
||||
DbContext.Books.Local.Single(b => b.AudibleProductId == newItem.DtoItem.ProductId),
|
||||
newItem.DtoItem.DateAdded,
|
||||
newItem.AccountId);
|
||||
DbContext.Library.Add(libraryBook);
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
var toUpdate = DbContext.Library.Where(l => l.Account == null);
|
||||
foreach (var u in toUpdate)
|
||||
{
|
||||
var item = importItems.FirstOrDefault(ii => ii.DtoItem.ProductId == u.Book.AudibleProductId);
|
||||
if (item != null)
|
||||
u.UpdateAccount(item.AccountId);
|
||||
}
|
||||
|
||||
var qtyNew = newItems.Count;
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
DtoImporterService/UNTESTED/SeriesImporter.cs
Normal file
63
DtoImporterService/UNTESTED/SeriesImporter.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class SeriesImporter : ItemsImporterBase
|
||||
{
|
||||
public SeriesImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new SeriesValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var series = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetSeriesDistinct()
|
||||
.ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_series(series);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertSeries(series);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_series(List<AudibleApiDTOs.Series> series)
|
||||
{
|
||||
var seriesIds = series.Select(s => s.SeriesId).ToList();
|
||||
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
|
||||
var remainingSeriesIds = seriesIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
|
||||
if (remainingSeriesIds.Any())
|
||||
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var s in requestedSeries)
|
||||
{
|
||||
var series = DbContext.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == s.SeriesId);
|
||||
if (series is null)
|
||||
{
|
||||
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
||||
qtyNew++;
|
||||
}
|
||||
series.UpdateName(s.SeriesName);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
||||
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
|
||||
<ProjectReference Include="..\ApplicationService\ApplicationService.csproj" />
|
||||
<ProjectReference Include="..\AudibleDotComAutomation\AudibleDotComAutomation.csproj" />
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
65
FileLiberator/UNTESTED/BackupBook.cs
Normal file
65
FileLiberator/UNTESTED/BackupBook.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
/// <summary>
|
||||
/// Download DRM book and decrypt audiobook files
|
||||
///
|
||||
/// Processes:
|
||||
/// Download: download aax file: the DRM encrypted audiobook
|
||||
/// Decrypt: remove DRM encryption from audiobook. Store final book
|
||||
/// Backup: perform all steps (downloaded, decrypt) still needed to get final book
|
||||
/// </summary>
|
||||
public class BackupBook : IProcessable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public DownloadBook DownloadBook { get; } = new DownloadBook();
|
||||
public DecryptBook DecryptBook { get; } = new DecryptBook();
|
||||
public DownloadPdf DownloadPdf { get; } = new DownloadPdf();
|
||||
|
||||
public bool Validate(LibraryBook libraryBook)
|
||||
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
|
||||
|
||||
// do NOT use ConfigureAwait(false) on ProcessAsync()
|
||||
// often calls events which prints to forms in the UI context
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
{
|
||||
var statusHandler = await DownloadBook.TryProcessAsync(libraryBook);
|
||||
if (statusHandler.HasErrors)
|
||||
return statusHandler;
|
||||
}
|
||||
|
||||
{
|
||||
var statusHandler = await DecryptBook.TryProcessAsync(libraryBook);
|
||||
if (statusHandler.HasErrors)
|
||||
return statusHandler;
|
||||
}
|
||||
|
||||
{
|
||||
var statusHandler = await DownloadPdf.TryProcessAsync(libraryBook);
|
||||
if (statusHandler.HasErrors)
|
||||
return statusHandler;
|
||||
}
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
178
FileLiberator/UNTESTED/DecryptBook.cs
Normal file
178
FileLiberator/UNTESTED/DecryptBook.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AaxDecrypter;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
/// <summary>
|
||||
/// Decrypt audiobook files
|
||||
///
|
||||
/// Processes:
|
||||
/// Download: download aax file: the DRM encrypted audiobook
|
||||
/// Decrypt: remove DRM encryption from audiobook. Store final book
|
||||
/// Backup: perform all steps (downloaded, decrypt) still needed to get final book
|
||||
/// </summary>
|
||||
public class DecryptBook : IDecryptable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
public event EventHandler<string> DecryptBegin;
|
||||
|
||||
public event EventHandler<string> TitleDiscovered;
|
||||
public event EventHandler<string> AuthorsDiscovered;
|
||||
public event EventHandler<string> NarratorsDiscovered;
|
||||
public event EventHandler<byte[]> CoverImageFilepathDiscovered;
|
||||
public event EventHandler<int> UpdateProgress;
|
||||
|
||||
public event EventHandler<string> DecryptCompleted;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public bool Validate(LibraryBook libraryBook)
|
||||
=> AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId)
|
||||
&& !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
|
||||
|
||||
// do NOT use ConfigureAwait(false) on ProcessAsync()
|
||||
// often calls events which prints to forms in the UI context
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
var aaxFilename = AudibleFileStorage.AAX.GetPath(libraryBook.Book.AudibleProductId);
|
||||
|
||||
if (aaxFilename == null)
|
||||
return new StatusHandler { "aaxFilename parameter is null" };
|
||||
if (!File.Exists(aaxFilename))
|
||||
return new StatusHandler { $"Cannot find AAX file: {aaxFilename}" };
|
||||
if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId))
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
var outputAudioFilename = await aaxToM4bConverterDecrypt(aaxFilename, libraryBook);
|
||||
|
||||
// decrypt failed
|
||||
if (outputAudioFilename == null)
|
||||
return new StatusHandler { "Decrypt failed" };
|
||||
|
||||
moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
|
||||
|
||||
Dinah.Core.IO.FileExt.SafeDelete(aaxFilename);
|
||||
|
||||
var statusHandler = new StatusHandler();
|
||||
var finalAudioExists = AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
|
||||
if (!finalAudioExists)
|
||||
statusHandler.AddError("Cannot find final audio file after decryption");
|
||||
return statusHandler;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> aaxToM4bConverterDecrypt(string aaxFilename, LibraryBook libraryBook)
|
||||
{
|
||||
DecryptBegin?.Invoke(this, $"Begin decrypting {aaxFilename}");
|
||||
|
||||
try
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
var account = persister
|
||||
.AccountsSettings
|
||||
.GetAccount(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
var converter = await AaxToM4bConverter.CreateAsync(aaxFilename, account.DecryptKey);
|
||||
converter.AppName = "Libation";
|
||||
|
||||
TitleDiscovered?.Invoke(this, converter.tags.title);
|
||||
AuthorsDiscovered?.Invoke(this, converter.tags.author);
|
||||
NarratorsDiscovered?.Invoke(this, converter.tags.narrator);
|
||||
CoverImageFilepathDiscovered?.Invoke(this, converter.coverBytes);
|
||||
|
||||
// override default which was set in CreateAsync
|
||||
var proposedOutputFile = Path.Combine(AudibleFileStorage.DecryptInProgress, $"[{libraryBook.Book.AudibleProductId}].m4b");
|
||||
converter.SetOutputFilename(proposedOutputFile);
|
||||
converter.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress);
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
var success = await Task.Run(() => converter.Run());
|
||||
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
return null;
|
||||
|
||||
account.DecryptKey = converter.decryptKey;
|
||||
|
||||
return converter.outputFileName;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DecryptCompleted?.Invoke(this, $"Completed decrypting {aaxFilename}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void moveFilesToBooksDir(Book product, string outputAudioFilename)
|
||||
{
|
||||
// create final directory. move each file into it. MOVE AUDIO FILE LAST
|
||||
// new dir: safetitle_limit50char + " [" + productId + "]"
|
||||
|
||||
var destinationDir = getDestDir(product);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
var sortedFiles = getProductFilesSorted(product, outputAudioFilename);
|
||||
|
||||
var musicFileExt = Path.GetExtension(outputAudioFilename).Trim('.');
|
||||
|
||||
foreach (var f in sortedFiles)
|
||||
{
|
||||
var dest = AudibleFileStorage.Audio.IsFileTypeMatch(f)
|
||||
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext
|
||||
? FileUtility.GetValidFilename(destinationDir, product.Title, musicFileExt, product.AudibleProductId)
|
||||
// non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext
|
||||
: FileUtility.GetValidFilename(destinationDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt);
|
||||
|
||||
File.Move(f.FullName, dest);
|
||||
}
|
||||
}
|
||||
|
||||
private static string getDestDir(Book product)
|
||||
{
|
||||
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
|
||||
var underscoreIndex = product.Title.IndexOf(':');
|
||||
var titleDir
|
||||
= underscoreIndex < 4
|
||||
? product.Title
|
||||
: product.Title.Substring(0, underscoreIndex);
|
||||
var finalDir = FileUtility.GetValidFilename(AudibleFileStorage.BooksDirectory, titleDir, null, product.AudibleProductId);
|
||||
return finalDir;
|
||||
}
|
||||
|
||||
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
|
||||
{
|
||||
// files are: temp path\author\[asin].ext
|
||||
var m4bDir = new FileInfo(outputAudioFilename).Directory;
|
||||
var files = m4bDir
|
||||
.EnumerateFiles()
|
||||
.Where(f => f.Name.ContainsInsensitive(product.AudibleProductId))
|
||||
.ToList();
|
||||
|
||||
// move audio files to the end of the collection so these files are moved last
|
||||
var musicFiles = files.Where(f => AudibleFileStorage.Audio.IsFileTypeMatch(f));
|
||||
var sortedFiles = files
|
||||
.Except(musicFiles)
|
||||
.Concat(musicFiles)
|
||||
.ToList();
|
||||
|
||||
return sortedFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
122
FileLiberator/UNTESTED/DownloadBook.cs
Normal file
122
FileLiberator/UNTESTED/DownloadBook.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
/// <summary>
|
||||
/// Download DRM book
|
||||
///
|
||||
/// Processes:
|
||||
/// Download: download aax file: the DRM encrypted audiobook
|
||||
/// Decrypt: remove DRM encryption from audiobook. Store final book
|
||||
/// Backup: perform all steps (downloaded, decrypt) still needed to get final book
|
||||
/// </summary>
|
||||
public class DownloadBook : DownloadableBase
|
||||
{
|
||||
private const string SERVICE_UNAVAILABLE = "Content Delivery Companion Service is not available.";
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId)
|
||||
&& !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId);
|
||||
|
||||
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var tempAaxFilename = getDownloadPath(libraryBook);
|
||||
var actualFilePath = await downloadBookAsync(libraryBook, tempAaxFilename);
|
||||
moveBook(libraryBook, actualFilePath);
|
||||
return verifyDownload(libraryBook);
|
||||
}
|
||||
|
||||
private static string getDownloadPath(LibraryBook libraryBook)
|
||||
=> FileUtility.GetValidFilename(
|
||||
AudibleFileStorage.DownloadsInProgress,
|
||||
libraryBook.Book.Title,
|
||||
"aax",
|
||||
libraryBook.Book.AudibleProductId);
|
||||
|
||||
private async Task<string> downloadBookAsync(LibraryBook libraryBook, string tempAaxFilename)
|
||||
{
|
||||
validate(libraryBook);
|
||||
|
||||
var api = await AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
var actualFilePath = await PerformDownloadAsync(
|
||||
tempAaxFilename,
|
||||
(p) => api.DownloadAaxWorkaroundAsync(libraryBook.Book.AudibleProductId, tempAaxFilename, p));
|
||||
|
||||
System.Threading.Thread.Sleep(100);
|
||||
// if bad file download, a 0-33 byte file will be created
|
||||
// if service unavailable, a 52 byte string will be saved as file
|
||||
var length = new FileInfo(actualFilePath).Length;
|
||||
|
||||
if (length > 100)
|
||||
return actualFilePath;
|
||||
|
||||
var contents = File.ReadAllText(actualFilePath);
|
||||
File.Delete(actualFilePath);
|
||||
|
||||
var exMsg = contents.StartsWithInsensitive(SERVICE_UNAVAILABLE)
|
||||
? SERVICE_UNAVAILABLE
|
||||
: "Error downloading file";
|
||||
|
||||
var ex = new Exception(exMsg);
|
||||
Serilog.Log.Logger.Error(ex, "Download error {@DebugInfo}", new
|
||||
{
|
||||
libraryBook.Book.Title,
|
||||
libraryBook.Book.AudibleProductId,
|
||||
libraryBook.Book.Locale,
|
||||
libraryBook.Account,
|
||||
tempAaxFilename,
|
||||
actualFilePath,
|
||||
length,
|
||||
contents
|
||||
});
|
||||
throw ex;
|
||||
}
|
||||
|
||||
private static void validate(LibraryBook libraryBook)
|
||||
{
|
||||
string errorString(string field)
|
||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new Exception(errorString("Account"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new Exception(errorString("Locale"));
|
||||
}
|
||||
|
||||
private void moveBook(LibraryBook libraryBook, string actualFilePath)
|
||||
{
|
||||
var newAaxFilename = FileUtility.GetValidFilename(
|
||||
AudibleFileStorage.DownloadsFinal,
|
||||
libraryBook.Book.Title,
|
||||
"aax",
|
||||
libraryBook.Book.AudibleProductId);
|
||||
File.Move(actualFilePath, newAaxFilename);
|
||||
Invoke_StatusUpdate($"Successfully downloaded. Moved to: {newAaxFilename}");
|
||||
}
|
||||
|
||||
private static StatusHandler verifyDownload(LibraryBook libraryBook)
|
||||
=> !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId)
|
||||
? new StatusHandler { "Downloaded AAX file cannot be found" }
|
||||
: new StatusHandler();
|
||||
}
|
||||
}
|
||||
35
FileLiberator/UNTESTED/DownloadFile.cs
Normal file
35
FileLiberator/UNTESTED/DownloadFile.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
// frustratingly copy pasta from DownloadableBase and DownloadPdf
|
||||
public class DownloadFile : IDownloadable
|
||||
{
|
||||
public event EventHandler<string> DownloadBegin;
|
||||
public event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
public event EventHandler<string> DownloadCompleted;
|
||||
|
||||
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
|
||||
|
||||
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
FileLiberator/UNTESTED/DownloadPdf.cs
Normal file
55
FileLiberator/UNTESTED/DownloadPdf.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadPdf : DownloadableBase
|
||||
{
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
|
||||
&& !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId);
|
||||
|
||||
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
||||
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
return verifyDownload(libraryBook);
|
||||
}
|
||||
|
||||
private static StatusHandler verifyDownload(LibraryBook libraryBook)
|
||||
=> !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId)
|
||||
? new StatusHandler { "Downloaded PDF cannot be found" }
|
||||
: new StatusHandler();
|
||||
|
||||
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
||||
{
|
||||
// if audio file exists, get it's dir. else return base Book dir
|
||||
var destinationDir =
|
||||
// this is safe b/c GetDirectoryName(null) == null
|
||||
Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId))
|
||||
?? AudibleFileStorage.PDF.StorageDirectory;
|
||||
|
||||
return Path.Combine(destinationDir, Path.GetFileName(getdownloadUrl(libraryBook)));
|
||||
}
|
||||
|
||||
private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
{
|
||||
var downloadUrl = getdownloadUrl(libraryBook);
|
||||
|
||||
var client = new HttpClient();
|
||||
var actualDownloadedFilePath = await PerformDownloadAsync(
|
||||
proposedDownloadFilePath,
|
||||
(p) => client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, p));
|
||||
}
|
||||
|
||||
private static string getdownloadUrl(LibraryBook libraryBook)
|
||||
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
|
||||
}
|
||||
}
|
||||
61
FileLiberator/UNTESTED/DownloadableBase.cs
Normal file
61
FileLiberator/UNTESTED/DownloadableBase.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class DownloadableBase : IDownloadableProcessable
|
||||
{
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
public event EventHandler<string> DownloadBegin;
|
||||
public event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
public event EventHandler<string> DownloadCompleted;
|
||||
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
protected void Invoke_StatusUpdate(string message) => StatusUpdate?.Invoke(this, message);
|
||||
|
||||
public abstract bool Validate(LibraryBook libraryBook);
|
||||
|
||||
public abstract Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook);
|
||||
|
||||
// do NOT use ConfigureAwait(false) on ProcessAsync()
|
||||
// often calls events which prints to forms in the UI context
|
||||
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
Begin?.Invoke(this, libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
return await ProcessItemAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task<string> PerformDownloadAsync(string proposedDownloadFilePath, Func<Progress<DownloadProgress>, Task<string>> func)
|
||||
{
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
|
||||
|
||||
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await func(progress);
|
||||
StatusUpdate?.Invoke(this, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace ScrapingDomainServices
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IDecryptable : IProcessable
|
||||
{
|
||||
12
FileLiberator/UNTESTED/IDownloadable.cs
Normal file
12
FileLiberator/UNTESTED/IDownloadable.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IDownloadable
|
||||
{
|
||||
event EventHandler<string> DownloadBegin;
|
||||
event EventHandler<DownloadProgress> DownloadProgressChanged;
|
||||
event EventHandler<string> DownloadCompleted;
|
||||
}
|
||||
}
|
||||
4
FileLiberator/UNTESTED/IDownloadableProcessable.cs
Normal file
4
FileLiberator/UNTESTED/IDownloadableProcessable.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IDownloadableProcessable : IDownloadable, IProcessable { }
|
||||
}
|
||||
@@ -3,19 +3,19 @@ using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
|
||||
namespace ScrapingDomainServices
|
||||
namespace FileLiberator
|
||||
{
|
||||
public interface IProcessable
|
||||
{
|
||||
event EventHandler<string> Begin;
|
||||
event EventHandler<LibraryBook> Begin;
|
||||
|
||||
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
|
||||
event EventHandler<string> StatusUpdate;
|
||||
|
||||
event EventHandler<string> Completed;
|
||||
event EventHandler<LibraryBook> Completed;
|
||||
|
||||
/// <returns>True == Valid</returns>
|
||||
Task<bool> ValidateAsync(LibraryBook libraryBook);
|
||||
/// <returns>True == Valid</returns>
|
||||
bool Validate(LibraryBook libraryBook);
|
||||
|
||||
/// <returns>True == success</returns>
|
||||
Task<StatusHandler> ProcessAsync(LibraryBook libraryBook);
|
||||
81
FileLiberator/UNTESTED/IProcessableExt.cs
Normal file
81
FileLiberator/UNTESTED/IProcessableExt.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class IProcessableExt
|
||||
{
|
||||
//
|
||||
// DO NOT USE ConfigureAwait(false) WITH ProcessAsync() unless ensuring ProcessAsync() implementation is cross-thread compatible
|
||||
// ProcessAsync() often does a lot with forms in the UI context
|
||||
//
|
||||
|
||||
|
||||
/// <summary>Process the first valid product. Create default context</summary>
|
||||
/// <returns>Returns either the status handler from the process, or null if all books have been processed</returns>
|
||||
public static async Task<StatusHandler> ProcessFirstValidAsync(this IProcessable processable)
|
||||
{
|
||||
var libraryBook = processable.getNextValidBook();
|
||||
if (libraryBook == null)
|
||||
return null;
|
||||
|
||||
return await processBookAsync(processable, libraryBook);
|
||||
}
|
||||
|
||||
/// <summary>Process the first valid product. Create default context</summary>
|
||||
/// <returns>Returns either the status handler from the process, or null if all books have been processed</returns>
|
||||
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, string productId)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryBook = context
|
||||
.Library
|
||||
.GetLibrary()
|
||||
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
|
||||
|
||||
if (libraryBook == null)
|
||||
return null;
|
||||
if (!processable.Validate(libraryBook))
|
||||
return new StatusHandler { "Validation failed" };
|
||||
|
||||
return await processBookAsync(processable, libraryBook);
|
||||
}
|
||||
|
||||
private static async Task<StatusHandler> processBookAsync(IProcessable processable, LibraryBook libraryBook)
|
||||
{
|
||||
Serilog.Log.Information("Begin " + nameof(processBookAsync) + " {@DebugInfo}", new
|
||||
{
|
||||
libraryBook.Book.Title,
|
||||
libraryBook.Book.AudibleProductId,
|
||||
libraryBook.Book.Locale,
|
||||
libraryBook.Account
|
||||
});
|
||||
|
||||
// this should never happen. check anyway. ProcessFirstValidAsync returning null is the signal that we're done. we can't let another IProcessable accidentally send this command
|
||||
var status = await processable.ProcessAsync(libraryBook);
|
||||
if (status == null)
|
||||
throw new Exception("Processable should never return a null status");
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
private static LibraryBook getNextValidBook(this IProcessable processable)
|
||||
{
|
||||
var libraryBooks = DbContexts.GetContext().GetLibrary_Flat_NoTracking();
|
||||
|
||||
foreach (var libraryBook in libraryBooks)
|
||||
if (processable.Validate(libraryBook))
|
||||
return libraryBook;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async Task<StatusHandler> TryProcessAsync(this IProcessable processable, LibraryBook libraryBook)
|
||||
=> processable.Validate(libraryBook)
|
||||
? await processable.ProcessAsync(libraryBook)
|
||||
: new StatusHandler();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Polly" Version="7.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
using System.IO;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class AudibleApiStorage
|
||||
{
|
||||
public static string IdentityTokensFile => Path.Combine(Configuration.Instance.LibationFiles, "IdentityTokens.json");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
@@ -21,21 +21,6 @@ namespace FileManager
|
||||
public sealed class AudibleFileStorage : Enumeration<AudibleFileStorage>
|
||||
{
|
||||
#region static
|
||||
// centralize filetype mappings to ensure uniqueness
|
||||
private static Dictionary<string, FileType> extensionMap => new Dictionary<string, FileType>
|
||||
{
|
||||
[".m4b"] = FileType.Audio,
|
||||
[".mp3"] = FileType.Audio,
|
||||
[".aac"] = FileType.Audio,
|
||||
[".mp4"] = FileType.Audio,
|
||||
[".m4a"] = FileType.Audio,
|
||||
|
||||
[".aax"] = FileType.AAX,
|
||||
|
||||
[".pdf"] = FileType.PDF,
|
||||
[".zip"] = FileType.PDF,
|
||||
};
|
||||
|
||||
public static AudibleFileStorage Audio { get; }
|
||||
public static AudibleFileStorage AAX { get; }
|
||||
public static AudibleFileStorage PDF { get; }
|
||||
@@ -54,7 +39,7 @@ namespace FileManager
|
||||
Configuration.Instance.DecryptInProgressEnum = "WinTemp";
|
||||
var M4bRootDir
|
||||
= Configuration.Instance.DecryptInProgressEnum == "WinTemp" // else "LibationFiles"
|
||||
? Configuration.Instance.WinTemp
|
||||
? Configuration.WinTemp
|
||||
: Configuration.Instance.LibationFiles;
|
||||
DecryptInProgress = Path.Combine(M4bRootDir, "DecryptInProgress");
|
||||
Directory.CreateDirectory(DecryptInProgress);
|
||||
@@ -65,7 +50,7 @@ namespace FileManager
|
||||
Configuration.Instance.DownloadsInProgressEnum = "WinTemp";
|
||||
var AaxRootDir
|
||||
= Configuration.Instance.DownloadsInProgressEnum == "WinTemp" // else "LibationFiles"
|
||||
? Configuration.Instance.WinTemp
|
||||
? Configuration.WinTemp
|
||||
: Configuration.Instance.LibationFiles;
|
||||
DownloadsInProgress = Path.Combine(AaxRootDir, "DownloadsInProgress");
|
||||
Directory.CreateDirectory(DownloadsInProgress);
|
||||
@@ -79,9 +64,9 @@ namespace FileManager
|
||||
|
||||
// must do this in static ctor, not w/inline properties
|
||||
// static properties init before static ctor so these dir.s would still be null
|
||||
Audio = new AudibleFileStorage(FileType.Audio, BooksDirectory);
|
||||
AAX = new AudibleFileStorage(FileType.AAX, DownloadsFinal);
|
||||
PDF = new AudibleFileStorage(FileType.PDF, BooksDirectory);
|
||||
Audio = new AudibleFileStorage(FileType.Audio, BooksDirectory, "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac");
|
||||
AAX = new AudibleFileStorage(FileType.AAX, DownloadsFinal, "aax");
|
||||
PDF = new AudibleFileStorage(FileType.PDF, BooksDirectory, "pdf", "zip");
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -90,9 +75,14 @@ namespace FileManager
|
||||
|
||||
public string StorageDirectory => DisplayName;
|
||||
|
||||
public IEnumerable<string> Extensions => extensionMap.Where(kvp => kvp.Value == FileType).Select(kvp => kvp.Key);
|
||||
private IEnumerable<string> extensions_noDots { get; }
|
||||
private string extAggr { get; }
|
||||
|
||||
private AudibleFileStorage(FileType fileType, string storageDirectory) : base((int)fileType, storageDirectory) { }
|
||||
private AudibleFileStorage(FileType fileType, string storageDirectory, params string[] extensions) : base((int)fileType, storageDirectory)
|
||||
{
|
||||
extensions_noDots = extensions.Select(ext => ext.Trim('.')).ToList();
|
||||
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example for full books:
|
||||
@@ -100,78 +90,30 @@ namespace FileManager
|
||||
/// - a directory name has the product id and an audio file is immediately inside
|
||||
/// - any audio filename contains the product id
|
||||
/// </summary>
|
||||
public async Task<bool> ExistsAsync(string productId)
|
||||
=> (await GetAsync(productId).ConfigureAwait(false)) != null;
|
||||
public bool Exists(string productId)
|
||||
=> GetPath(productId) != null;
|
||||
|
||||
public async Task<string> GetAsync(string productId)
|
||||
=> await getAsync(productId).ConfigureAwait(false);
|
||||
|
||||
private async Task<string> getAsync(string productId)
|
||||
public string GetPath(string productId)
|
||||
{
|
||||
{
|
||||
{
|
||||
var cachedFile = FilePathCache.GetPath(productId, FileType);
|
||||
if (cachedFile != null)
|
||||
return cachedFile;
|
||||
}
|
||||
|
||||
// this is how files are saved by default. check this method first
|
||||
{
|
||||
var diskFile_byDirName = (await Task.Run(() => getFile_checkDirName(productId)).ConfigureAwait(false));
|
||||
if (diskFile_byDirName != null)
|
||||
{
|
||||
FilePathCache.Upsert(productId, FileType, diskFile_byDirName);
|
||||
return diskFile_byDirName;
|
||||
}
|
||||
}
|
||||
var firstOrNull =
|
||||
Directory
|
||||
.EnumerateFiles(StorageDirectory, "*.*", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(s => Regex.IsMatch(s, $@"{productId}.*?\.({extAggr})$", RegexOptions.IgnoreCase));
|
||||
|
||||
{
|
||||
var diskFile_byFileName = (await Task.Run(() => getFile_checkFileName(productId, StorageDirectory, SearchOption.AllDirectories)).ConfigureAwait(false));
|
||||
if (diskFile_byFileName != null)
|
||||
{
|
||||
FilePathCache.Upsert(productId, FileType, diskFile_byFileName);
|
||||
return diskFile_byFileName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
if (firstOrNull is null)
|
||||
return null;
|
||||
FilePathCache.Upsert(productId, FileType, firstOrNull);
|
||||
return firstOrNull;
|
||||
}
|
||||
|
||||
// returns audio file if there is a directory where both are true
|
||||
// - the directory name contains the productId
|
||||
// - the directory contains an audio file in it's top dir (not recursively)
|
||||
private string getFile_checkDirName(string productId)
|
||||
{
|
||||
foreach (var d in Directory.EnumerateDirectories(StorageDirectory, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (!fileHasId(d, productId))
|
||||
continue;
|
||||
|
||||
var firstAudio = Directory
|
||||
.EnumerateFiles(d, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.FirstOrDefault(f => IsFileTypeMatch(f));
|
||||
if (firstAudio != null)
|
||||
return firstAudio;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// returns audio file if there is an file where both are true
|
||||
// - the file name contains the productId
|
||||
// - the file is an audio type
|
||||
private string getFile_checkFileName(string productId, string dir, SearchOption searchOption)
|
||||
=> Directory
|
||||
.EnumerateFiles(dir, "*.*", searchOption)
|
||||
.FirstOrDefault(f => fileHasId(f, productId) && IsFileTypeMatch(f));
|
||||
|
||||
public bool IsFileTypeMatch(string filename)
|
||||
=> Extensions.ContainsInsensative(Path.GetExtension(filename));
|
||||
|
||||
public bool IsFileTypeMatch(FileInfo fileInfo)
|
||||
=> Extensions.ContainsInsensative(fileInfo.Extension);
|
||||
|
||||
// use GetFileName, NOT GetFileNameWithoutExtension. This tests files AND directories. if the dir has a dot in the final part of the path, it will be treated like the file extension
|
||||
private static bool fileHasId(string file, string productId)
|
||||
=> Path.GetFileName(file).ContainsInsensitive(productId);
|
||||
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
@@ -31,162 +33,151 @@ namespace FileManager
|
||||
*/
|
||||
#endregion
|
||||
|
||||
private const string configFilename = "LibationSettings.json";
|
||||
private PersistentDictionary persistentDictionary;
|
||||
|
||||
private PersistentDictionary persistentDictionary { get; }
|
||||
public bool FilesExist
|
||||
=> File.Exists(APPSETTINGS_JSON)
|
||||
&& File.Exists(SettingsFilePath)
|
||||
&& Directory.Exists(LibationFiles)
|
||||
&& Directory.Exists(Books);
|
||||
|
||||
[Description("Location of the configuration file where these settings are saved. Please do not edit this file directly while Libation is running.")]
|
||||
public string Filepath { get; }
|
||||
|
||||
[Description("Your user-specific key used to decrypt your audible files (*.aax) into audio files you can use anywhere (*.m4b)")]
|
||||
public string DecryptKey
|
||||
{
|
||||
get => persistentDictionary[nameof(DecryptKey)];
|
||||
set => persistentDictionary[nameof(DecryptKey)] = value;
|
||||
}
|
||||
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
|
||||
|
||||
[Description("Location for book storage. Includes destination of newly liberated books")]
|
||||
public string Books
|
||||
{
|
||||
get => persistentDictionary[nameof(Books)];
|
||||
set => persistentDictionary[nameof(Books)] = value;
|
||||
get => persistentDictionary.GetString(nameof(Books));
|
||||
set => persistentDictionary.Set(nameof(Books), value);
|
||||
}
|
||||
|
||||
public string WinTemp { get; } = Path.Combine(Path.GetTempPath(), "Libation");
|
||||
private const string APP_DIR = "AppDir";
|
||||
public static string AppDir { get; } = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), LIBATION_FILES));
|
||||
public static string MyDocs { get; } = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), LIBATION_FILES));
|
||||
public static string WinTemp { get; } = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
|
||||
|
||||
[Description("Location for storage of program-created files")]
|
||||
public string LibationFiles
|
||||
private Dictionary<string, string> wellKnownPaths { get; } = new Dictionary<string, string>
|
||||
{
|
||||
get => persistentDictionary[nameof(LibationFiles)];
|
||||
set => persistentDictionary[nameof(LibationFiles)] = value;
|
||||
}
|
||||
[APP_DIR] = AppDir,
|
||||
["MyDocs"] = MyDocs,
|
||||
["WinTemp"] = WinTemp
|
||||
};
|
||||
private string libationFilesPathCache;
|
||||
|
||||
// default setting and directory creation occur in class responsible for files.
|
||||
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
|
||||
// exceptions: appsettings.json, LibationFiles dir, Settings.json
|
||||
|
||||
// temp/working dir(s) should be outside of dropbox
|
||||
[Description("Temporary location of files while they're in process of being downloaded.\r\nWhen download is complete, the final file will be in [LibationFiles]\\DownloadsFinal")]
|
||||
public string DownloadsInProgressEnum
|
||||
{
|
||||
get => persistentDictionary[nameof(DownloadsInProgressEnum)];
|
||||
set => persistentDictionary[nameof(DownloadsInProgressEnum)] = value;
|
||||
get => persistentDictionary.GetString(nameof(DownloadsInProgressEnum));
|
||||
set => persistentDictionary.Set(nameof(DownloadsInProgressEnum), value);
|
||||
}
|
||||
|
||||
// temp/working dir(s) should be outside of dropbox
|
||||
[Description("Temporary location of files while they're in process of being decrypted.\r\nWhen decryption is complete, the final file will be in Books location")]
|
||||
public string DecryptInProgressEnum
|
||||
{
|
||||
get => persistentDictionary[nameof(DecryptInProgressEnum)];
|
||||
set => persistentDictionary[nameof(DecryptInProgressEnum)] = value;
|
||||
get => persistentDictionary.GetString(nameof(DecryptInProgressEnum));
|
||||
set => persistentDictionary.Set(nameof(DecryptInProgressEnum), value);
|
||||
}
|
||||
|
||||
public string LocaleCountryCode
|
||||
{
|
||||
get => persistentDictionary[nameof(LocaleCountryCode)];
|
||||
set => persistentDictionary[nameof(LocaleCountryCode)] = value;
|
||||
}
|
||||
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
|
||||
|
||||
// singleton stuff
|
||||
public static Configuration Instance { get; } = new Configuration();
|
||||
private Configuration()
|
||||
private Configuration() { }
|
||||
|
||||
private const string APPSETTINGS_JSON = "appsettings.json";
|
||||
private const string LIBATION_FILES = "LibationFiles";
|
||||
|
||||
[Description("Location for storage of program-created files")]
|
||||
public string LibationFiles => libationFilesPathCache ?? getLibationFiles();
|
||||
private string getLibationFiles()
|
||||
{
|
||||
Filepath = getPath();
|
||||
var value = getLiberationFilesSettingFromJson();
|
||||
|
||||
// load json values into memory
|
||||
persistentDictionary = new PersistentDictionary(Filepath);
|
||||
ensureDictionaryEntries();
|
||||
// this looks weird but is correct for translating wellKnownPaths
|
||||
if (wellKnownPaths.ContainsKey(value))
|
||||
value = wellKnownPaths[value];
|
||||
|
||||
// setUserFilesDirectoryDefault
|
||||
// don't create dir. dir creation is the responsibility of places that use the dir
|
||||
if (string.IsNullOrWhiteSpace(LibationFiles))
|
||||
LibationFiles = Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), "Libation");
|
||||
// must write here before SettingsFilePath in next step reads cache
|
||||
libationFilesPathCache = value;
|
||||
|
||||
// load json values into memory. create if not exists
|
||||
persistentDictionary = new PersistentDictionary(SettingsFilePath);
|
||||
|
||||
return libationFilesPathCache;
|
||||
}
|
||||
private string getLiberationFilesSettingFromJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(APPSETTINGS_JSON))
|
||||
{
|
||||
var appSettingsContents = File.ReadAllText(APPSETTINGS_JSON);
|
||||
var jObj = JObject.Parse(appSettingsContents);
|
||||
|
||||
if (jObj.ContainsKey(LIBATION_FILES))
|
||||
{
|
||||
var value = jObj[LIBATION_FILES].Value<string>();
|
||||
|
||||
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
File.WriteAllText(APPSETTINGS_JSON, new JObject { { LIBATION_FILES, APP_DIR } }.ToString(Formatting.Indented));
|
||||
return APP_DIR;
|
||||
}
|
||||
|
||||
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
|
||||
public void SetObject(string propertyName, object newValue) => persistentDictionary.Set(propertyName, newValue);
|
||||
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue) => persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue);
|
||||
|
||||
public static string GetDescription(string propertyName)
|
||||
{
|
||||
var attribute = typeof(Configuration)
|
||||
.GetProperty(propertyName)
|
||||
?.GetCustomAttributes(typeof(DescriptionAttribute), true)
|
||||
.SingleOrDefault()
|
||||
as DescriptionAttribute;
|
||||
var attribute = typeof(Configuration)
|
||||
.GetProperty(propertyName)
|
||||
?.GetCustomAttributes(typeof(DescriptionAttribute), true)
|
||||
.SingleOrDefault()
|
||||
as DescriptionAttribute;
|
||||
|
||||
return attribute?.Description;
|
||||
}
|
||||
return attribute?.Description;
|
||||
}
|
||||
|
||||
private static string getPath()
|
||||
public bool TrySetLibationFiles(string directory)
|
||||
{
|
||||
// search folders for config file. accept the first match
|
||||
var defaultdir = Path.GetDirectoryName(Exe.FileLocationOnDisk);
|
||||
if (!Directory.Exists(directory) && !wellKnownPaths.ContainsKey(directory))
|
||||
return false;
|
||||
|
||||
var baseDirs = new HashSet<string>
|
||||
// if moving from default, delete old settings file and dir (if empty)
|
||||
if (LibationFiles.EqualsInsensitive(AppDir))
|
||||
{
|
||||
defaultdir,
|
||||
getNonDevelopmentDir(defaultdir),
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.Personal)
|
||||
};
|
||||
|
||||
var subDirs = baseDirs.Select(dir => Path.Combine(dir, "Libation"));
|
||||
var dirs = baseDirs.Concat(subDirs).ToList();
|
||||
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
var f = Path.Combine(dir, configFilename);
|
||||
if (File.Exists(f))
|
||||
return f;
|
||||
File.Delete(SettingsFilePath);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
if (!Directory.EnumerateDirectories(AppDir).Any() && !Directory.EnumerateFiles(AppDir).Any())
|
||||
Directory.Delete(AppDir);
|
||||
}
|
||||
|
||||
return Path.Combine(defaultdir, configFilename);
|
||||
}
|
||||
|
||||
private static string getNonDevelopmentDir(string path)
|
||||
{
|
||||
// examples:
|
||||
// \Libation\Core2_0\bin\Debug\netcoreapp3.0
|
||||
// \Libation\StndLib\bin\Debug\netstandard2.1
|
||||
// \Libation\MyWnfrm\bin\Debug
|
||||
// \Libation\Core2_0\bin\Release\netcoreapp3.0
|
||||
// \Libation\StndLib\bin\Release\netstandard2.1
|
||||
// \Libation\MyWnfrm\bin\Release
|
||||
libationFilesPathCache = null;
|
||||
|
||||
var curr = new DirectoryInfo(path);
|
||||
|
||||
if (!curr.Name.EqualsInsensitive("debug") && !curr.Name.EqualsInsensitive("release") && !curr.Name.StartsWithInsensitive("netcoreapp") && !curr.Name.StartsWithInsensitive("netstandard"))
|
||||
return path;
|
||||
var contents = File.ReadAllText(APPSETTINGS_JSON);
|
||||
var jObj = JObject.Parse(contents);
|
||||
|
||||
// get out of netcore/standard dir => debug
|
||||
if (curr.Name.StartsWithInsensitive("netcoreapp") || curr.Name.StartsWithInsensitive("netstandard"))
|
||||
curr = curr.Parent;
|
||||
jObj[LIBATION_FILES] = directory;
|
||||
|
||||
if (!curr.Name.EqualsInsensitive("debug") && !curr.Name.EqualsInsensitive("release"))
|
||||
return path;
|
||||
var output = JsonConvert.SerializeObject(jObj, Formatting.Indented);
|
||||
File.WriteAllText(APPSETTINGS_JSON, output);
|
||||
|
||||
// get out of debug => bin
|
||||
curr = curr.Parent;
|
||||
if (!curr.Name.EqualsInsensitive("bin"))
|
||||
return path;
|
||||
|
||||
// get out of bin
|
||||
curr = curr.Parent;
|
||||
// get out of csproj-level dir
|
||||
curr = curr.Parent;
|
||||
|
||||
// curr should now be sln-level dir
|
||||
return curr.FullName;
|
||||
}
|
||||
|
||||
private void ensureDictionaryEntries()
|
||||
{
|
||||
var stringProperties = getDictionaryProperties().Select(p => p.Name).ToList();
|
||||
var missingKeys = stringProperties.Except(persistentDictionary.Keys).ToArray();
|
||||
persistentDictionary.AddKeys(missingKeys);
|
||||
}
|
||||
|
||||
private IEnumerable<System.Reflection.PropertyInfo> dicPropertiesCache;
|
||||
private IEnumerable<System.Reflection.PropertyInfo> getDictionaryProperties()
|
||||
{
|
||||
if (dicPropertiesCache == null)
|
||||
dicPropertiesCache = PersistentDictionary.GetPropertiesToPersist(this.GetType());
|
||||
return dicPropertiesCache;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Collections.Immutable;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class FilePathCache
|
||||
public static class FilePathCache
|
||||
{
|
||||
internal class CacheEntry
|
||||
{
|
||||
@@ -15,27 +16,30 @@ namespace FileManager
|
||||
public string Path { get; set; }
|
||||
}
|
||||
|
||||
static List<CacheEntry> inMemoryCache = new List<CacheEntry>();
|
||||
static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
|
||||
|
||||
public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json");
|
||||
|
||||
static FilePathCache()
|
||||
{
|
||||
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
|
||||
if (FileUtility.FileExists(JsonFile))
|
||||
inMemoryCache = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(JsonFile));
|
||||
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
|
||||
if (File.Exists(JsonFile))
|
||||
{
|
||||
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(JsonFile));
|
||||
cache = new Cache<CacheEntry>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Exists(string id, FileType type) => GetPath(id, type) != null;
|
||||
|
||||
public static string GetPath(string id, FileType type)
|
||||
{
|
||||
var entry = inMemoryCache.SingleOrDefault(i => i.Id == id && i.FileType == type);
|
||||
var entry = cache.SingleOrDefault(i => i.Id == id && i.FileType == type);
|
||||
|
||||
if (entry == null)
|
||||
return null;
|
||||
|
||||
if (!FileUtility.FileExists(entry.Path))
|
||||
if (!File.Exists(entry.Path))
|
||||
{
|
||||
remove(entry);
|
||||
return null;
|
||||
@@ -44,51 +48,47 @@ namespace FileManager
|
||||
return entry.Path;
|
||||
}
|
||||
|
||||
private static object locker { get; } = new object();
|
||||
|
||||
private static void remove(CacheEntry entry)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
inMemoryCache.Remove(entry);
|
||||
save();
|
||||
}
|
||||
}
|
||||
{
|
||||
cache.Remove(entry);
|
||||
save();
|
||||
}
|
||||
|
||||
public static void Upsert(string id, FileType type, string path)
|
||||
{
|
||||
if (!FileUtility.FileExists(path))
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException("Cannot add path to cache. File not found");
|
||||
|
||||
lock (locker)
|
||||
{
|
||||
var entry = inMemoryCache.SingleOrDefault(i => i.Id == id && i.FileType == type);
|
||||
if (entry != null)
|
||||
entry.Path = path;
|
||||
else
|
||||
{
|
||||
entry = new CacheEntry { Id = id, FileType = type, Path = path };
|
||||
inMemoryCache.Add(entry);
|
||||
}
|
||||
save();
|
||||
}
|
||||
}
|
||||
var entry = cache.SingleOrDefault(i => i.Id == id && i.FileType == type);
|
||||
|
||||
// ONLY call this within lock()
|
||||
private static void save()
|
||||
{
|
||||
// create json if not exists
|
||||
void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(inMemoryCache, Formatting.Indented));
|
||||
try { resave(); }
|
||||
catch (IOException)
|
||||
{
|
||||
try { resave(); }
|
||||
catch (IOException)
|
||||
{
|
||||
Console.WriteLine("...that's not good");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
if (entry is null)
|
||||
cache.Add(new CacheEntry { Id = id, FileType = type, Path = path });
|
||||
else
|
||||
entry.Path = path;
|
||||
|
||||
save();
|
||||
}
|
||||
|
||||
// cache is thread-safe and lock free. but file saving is not
|
||||
private static object locker { get; } = new object();
|
||||
private static void save()
|
||||
{
|
||||
// create json if not exists
|
||||
static void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
|
||||
|
||||
lock (locker)
|
||||
{
|
||||
try { resave(); }
|
||||
catch (IOException)
|
||||
{
|
||||
try { resave(); }
|
||||
catch (IOException ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error saving FilePaths.json");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class FileUtility
|
||||
{
|
||||
// a replacement for File.Exists() which allows long paths
|
||||
// not needed in .net-core
|
||||
public static bool FileExists(string path)
|
||||
{
|
||||
var basic = File.Exists(path);
|
||||
if (basic)
|
||||
return true;
|
||||
|
||||
// character cutoff is usually 269 but this isn't a hard number. there are edgecases which shorted the threshold
|
||||
if (path.Length < 260)
|
||||
return false;
|
||||
|
||||
// try long name prefix:
|
||||
// \\?\
|
||||
// https://blogs.msdn.microsoft.com/jeremykuhne/2016/06/21/more-on-new-net-path-handling/
|
||||
path = @"\\?\" + path;
|
||||
|
||||
return File.Exists(path);
|
||||
}
|
||||
|
||||
/// <param name="proposedPath">acceptable inputs:
|
||||
/// example.txt
|
||||
/// C:\Users\username\Desktop\example.txt</param>
|
||||
/// <returns>Returns full name and path of unused filename. including (#)</returns>
|
||||
public static string GetValidFilename(string proposedPath)
|
||||
=> GetValidFilename(Path.GetDirectoryName(proposedPath), Path.GetFileNameWithoutExtension(proposedPath), Path.GetExtension(proposedPath));
|
||||
|
||||
public static string GetValidFilename(string dirFullPath, string filename, string extension, params string[] metadataSuffixes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dirFullPath))
|
||||
@@ -58,7 +29,7 @@ namespace FileManager
|
||||
// ensure uniqueness
|
||||
var fullfilename = Path.Combine(dirFullPath, filename + extension);
|
||||
var i = 0;
|
||||
while (FileExists(fullfilename))
|
||||
while (File.Exists(fullfilename))
|
||||
fullfilename = Path.Combine(dirFullPath, filename + $" ({++i})" + extension);
|
||||
|
||||
return fullfilename;
|
||||
@@ -77,25 +48,5 @@ namespace FileManager
|
||||
property = property.Replace(ch.ToString(), "");
|
||||
return property;
|
||||
}
|
||||
|
||||
public static string Declaw(string str)
|
||||
=> str
|
||||
.Replace("<script", "<sxcript")
|
||||
.Replace(".net", ".nxet")
|
||||
.Replace(".com", ".cxom")
|
||||
.Replace("<link", "<lxink")
|
||||
.Replace("http", "hxttp");
|
||||
public static string RestoreDeclawed(string str)
|
||||
=> str
|
||||
?.Replace("<sxcript", "<script")
|
||||
.Replace(".nxet", ".net")
|
||||
.Replace(".cxom", ".com")
|
||||
.Replace("<lxink", "<link")
|
||||
.Replace("hxttp", "http");
|
||||
|
||||
public static string TitleCompressed(string title)
|
||||
=> new string(title
|
||||
.Where(c => (char.IsLetterOrDigit(c)))
|
||||
.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
@@ -10,70 +11,114 @@ namespace FileManager
|
||||
{
|
||||
public string Filepath { get; }
|
||||
|
||||
// forgiving -- doesn't drop settings. old entries will continue to be persisted even if not publicly visible
|
||||
private Dictionary<string, string> settingsDic { get; }
|
||||
|
||||
public string this[string key]
|
||||
{
|
||||
get => settingsDic[key];
|
||||
set
|
||||
{
|
||||
if (settingsDic.ContainsKey(key) && settingsDic[key] == value)
|
||||
return;
|
||||
|
||||
settingsDic[key] = value;
|
||||
|
||||
// auto-save to file
|
||||
save();
|
||||
}
|
||||
}
|
||||
// optimize for strings. expectation is most settings will be strings and a rare exception will be something else
|
||||
private Dictionary<string, string> stringCache { get; } = new Dictionary<string, string>();
|
||||
private Dictionary<string, object> objectCache { get; } = new Dictionary<string, object>();
|
||||
|
||||
public PersistentDictionary(string filepath)
|
||||
{
|
||||
Filepath = filepath;
|
||||
|
||||
// not found. create blank file
|
||||
if (!File.Exists(Filepath))
|
||||
{
|
||||
File.WriteAllText(Filepath, "{}");
|
||||
|
||||
// give system time to create file before first use
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
|
||||
settingsDic = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(Filepath));
|
||||
}
|
||||
|
||||
public IEnumerable<string> Keys => settingsDic.Keys.Cast<string>();
|
||||
|
||||
public void AddKeys(params string[] keys)
|
||||
{
|
||||
if (keys == null || keys.Length == 0)
|
||||
if (File.Exists(Filepath))
|
||||
return;
|
||||
|
||||
foreach (var key in keys)
|
||||
settingsDic.Add(key, null);
|
||||
save();
|
||||
// will create any missing directories, incl subdirectories. if all already exist: no action
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filepath));
|
||||
|
||||
File.WriteAllText(Filepath, "{}");
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
|
||||
public string GetString(string propertyName)
|
||||
{
|
||||
if (!stringCache.ContainsKey(propertyName))
|
||||
{
|
||||
var jObject = readFile();
|
||||
stringCache[propertyName] = jObject.ContainsKey(propertyName) ? jObject[propertyName].Value<string>() : null;
|
||||
}
|
||||
|
||||
return stringCache[propertyName];
|
||||
}
|
||||
|
||||
public T Get<T>(string propertyName) where T : class
|
||||
=> GetObject(propertyName) is T obj ? obj : default;
|
||||
|
||||
public object GetObject(string propertyName)
|
||||
{
|
||||
if (!objectCache.ContainsKey(propertyName))
|
||||
{
|
||||
var jObject = readFile();
|
||||
objectCache[propertyName] = jObject.ContainsKey(propertyName) ? jObject[propertyName].Value<object>() : null;
|
||||
}
|
||||
|
||||
return objectCache[propertyName];
|
||||
}
|
||||
|
||||
private object locker { get; } = new object();
|
||||
private void save()
|
||||
public void Set(string propertyName, string newValue)
|
||||
{
|
||||
lock (locker)
|
||||
File.WriteAllText(Filepath, JsonConvert.SerializeObject(settingsDic, Formatting.Indented));
|
||||
// only do this check in string cache, NOT object cache
|
||||
if (stringCache[propertyName] == newValue)
|
||||
return;
|
||||
|
||||
// set cache
|
||||
stringCache[propertyName] = newValue;
|
||||
|
||||
writeFile(propertyName, newValue);
|
||||
}
|
||||
|
||||
public static IEnumerable<System.Reflection.PropertyInfo> GetPropertiesToPersist(Type type)
|
||||
=> type
|
||||
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(p =>
|
||||
// string properties only
|
||||
p.PropertyType == typeof(string)
|
||||
// exclude indexer
|
||||
&& p.GetIndexParameters().Length == 0
|
||||
// exclude read-only, write-only
|
||||
&& p.GetGetMethod(false) != null
|
||||
&& p.GetSetMethod(false) != null
|
||||
).ToList();
|
||||
public void Set(string propertyName, object newValue)
|
||||
{
|
||||
// set cache
|
||||
objectCache[propertyName] = newValue;
|
||||
|
||||
var parsedNewValue = JToken.Parse(JsonConvert.SerializeObject(newValue));
|
||||
writeFile(propertyName, parsedNewValue);
|
||||
}
|
||||
|
||||
private void writeFile(string propertyName, JToken newValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var str = newValue?.ToString();
|
||||
var formattedValue
|
||||
= str is null ? "[null]"
|
||||
: string.IsNullOrEmpty(str) ? "[empty]"
|
||||
: string.IsNullOrWhiteSpace(str) ? $"[whitespace. Length={str.Length}]"
|
||||
: str.Length > 100 ? $"[Length={str.Length}] {str[0..50]}...{str[^50..^0]}"
|
||||
: str;
|
||||
Serilog.Log.Logger.Information($"Config changed. {propertyName}={formattedValue}");
|
||||
}
|
||||
catch { }
|
||||
|
||||
// write new setting to file
|
||||
lock (locker)
|
||||
{
|
||||
var jObject = readFile();
|
||||
jObject[propertyName] = newValue;
|
||||
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
|
||||
}
|
||||
}
|
||||
|
||||
// special case: no caching. no logging
|
||||
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
var jObject = readFile();
|
||||
var token = jObject.SelectToken(jsonPath);
|
||||
var debug_oldValue = (string)token[propertyName];
|
||||
|
||||
token[propertyName] = newValue;
|
||||
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
|
||||
}
|
||||
}
|
||||
|
||||
private JObject readFile()
|
||||
{
|
||||
var settingsJsonContents = File.ReadAllText(Filepath);
|
||||
var jObject = JsonConvert.DeserializeObject<JObject>(settingsJsonContents);
|
||||
return jObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,77 +2,112 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Files are small. Entire file is read from disk every time. No volitile storage. Paths are well known
|
||||
/// </summary>
|
||||
public enum PictureSize { _80x80, _300x300, _500x500 }
|
||||
public struct PictureDefinition
|
||||
{
|
||||
public string PictureId { get; }
|
||||
public PictureSize Size { get; }
|
||||
|
||||
public PictureDefinition(string pictureId, PictureSize pictureSize)
|
||||
{
|
||||
PictureId = pictureId;
|
||||
Size = pictureSize;
|
||||
}
|
||||
}
|
||||
public static class PictureStorage
|
||||
{
|
||||
public enum PictureSize { _80x80, _300x300, _500x500 }
|
||||
|
||||
// not customizable. don't move to config
|
||||
private static string ImagesDirectory { get; }
|
||||
= new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("Images").FullName;
|
||||
|
||||
private static string getPath(string pictureId, PictureSize size)
|
||||
=> Path.Combine(ImagesDirectory, $"{pictureId}{size}.jpg");
|
||||
private static string getPath(PictureDefinition def)
|
||||
=> Path.Combine(ImagesDirectory, $"{def.PictureId}{def.Size}.jpg");
|
||||
|
||||
public static byte[] GetImage(string pictureId, PictureSize size)
|
||||
{
|
||||
var path = getPath(pictureId, size);
|
||||
if (!FileUtility.FileExists(path))
|
||||
DownloadImages(pictureId);
|
||||
private static System.Timers.Timer timer { get; }
|
||||
static PictureStorage()
|
||||
{
|
||||
timer = new System.Timers.Timer(700)
|
||||
{
|
||||
AutoReset = true,
|
||||
Enabled = true
|
||||
};
|
||||
timer.Elapsed += (_, __) => timerDownload();
|
||||
}
|
||||
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
public static event EventHandler<string> PictureCached;
|
||||
|
||||
public static void DownloadImages(string pictureId)
|
||||
{
|
||||
var path80 = getPath(pictureId, PictureSize._80x80);
|
||||
var path300 = getPath(pictureId, PictureSize._300x300);
|
||||
var path500 = getPath(pictureId, PictureSize._500x500);
|
||||
private static Dictionary<PictureDefinition, byte[]> cache { get; } = new Dictionary<PictureDefinition, byte[]>();
|
||||
public static (bool isDefault, byte[] bytes) GetPicture(PictureDefinition def)
|
||||
{
|
||||
if (!cache.ContainsKey(def))
|
||||
{
|
||||
var path = getPath(def);
|
||||
cache[def]
|
||||
= File.Exists(path)
|
||||
? File.ReadAllBytes(path)
|
||||
: null;
|
||||
}
|
||||
return (cache[def] == null, cache[def] ?? getDefaultImage(def.Size));
|
||||
}
|
||||
|
||||
int retry = 0;
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
using var webClient = new System.Net.WebClient();
|
||||
// download any that don't exist
|
||||
{
|
||||
if (!FileUtility.FileExists(path80))
|
||||
{
|
||||
var bytes = webClient.DownloadData(
|
||||
"https://images-na.ssl-images-amazon.com/images/I/" + pictureId + "._SL80_.jpg");
|
||||
File.WriteAllBytes(path80, bytes);
|
||||
}
|
||||
}
|
||||
private static Dictionary<PictureSize, byte[]> defaultImages { get; } = new Dictionary<PictureSize, byte[]>();
|
||||
public static void SetDefaultImage(PictureSize pictureSize, byte[] bytes)
|
||||
=> defaultImages[pictureSize] = bytes;
|
||||
private static byte[] getDefaultImage(PictureSize size)
|
||||
=> defaultImages.ContainsKey(size)
|
||||
? defaultImages[size]
|
||||
: new byte[0];
|
||||
|
||||
{
|
||||
if (!FileUtility.FileExists(path300))
|
||||
{
|
||||
var bytes = webClient.DownloadData(
|
||||
"https://images-na.ssl-images-amazon.com/images/I/" + pictureId + "._SL300_.jpg");
|
||||
File.WriteAllBytes(path300, bytes);
|
||||
}
|
||||
}
|
||||
// necessary to avoid IO errors. ReadAllBytes and WriteAllBytes can conflict in some cases, esp when debugging
|
||||
private static bool isProcessing;
|
||||
private static void timerDownload()
|
||||
{
|
||||
// must live outside try-catch, else 'finally' can reset another thread's lock
|
||||
if (isProcessing)
|
||||
return;
|
||||
|
||||
{
|
||||
if (!FileUtility.FileExists(path500))
|
||||
{
|
||||
var bytes = webClient.DownloadData(
|
||||
"https://m.media-amazon.com/images/I/" + pictureId + "._SL500_.jpg");
|
||||
File.WriteAllBytes(path500, bytes);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
isProcessing = true;
|
||||
|
||||
break;
|
||||
}
|
||||
catch { retry++; }
|
||||
}
|
||||
while (retry < 3);
|
||||
}
|
||||
}
|
||||
var def = cache
|
||||
.Where(kvp => kvp.Value is null)
|
||||
.Select(kvp => kvp.Key)
|
||||
// 80x80 should be 1st since it's enum value == 0
|
||||
.OrderBy(d => d.PictureId)
|
||||
.FirstOrDefault();
|
||||
|
||||
// no more null entries. all requsted images are cached
|
||||
if (string.IsNullOrWhiteSpace(def.PictureId))
|
||||
return;
|
||||
|
||||
var bytes = downloadBytes(def);
|
||||
saveFile(def, bytes);
|
||||
cache[def] = bytes;
|
||||
|
||||
PictureCached?.Invoke(nameof(PictureStorage), def.PictureId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient imageDownloadClient { get; } = new HttpClient();
|
||||
private static byte[] downloadBytes(PictureDefinition def)
|
||||
{
|
||||
var sz = def.Size.ToString().Split('x')[1];
|
||||
return imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}._SL{sz}_.jpg").Result;
|
||||
}
|
||||
|
||||
private static void saveFile(PictureDefinition def, byte[] bytes)
|
||||
{
|
||||
var path = getPath(def);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace FileManager
|
||||
static QuickFilters()
|
||||
{
|
||||
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
|
||||
if (FileUtility.FileExists(JsonFile))
|
||||
if (File.Exists(JsonFile))
|
||||
inMemoryState = JsonConvert.DeserializeObject<FilterState>(File.ReadAllText(JsonFile));
|
||||
}
|
||||
|
||||
@@ -105,12 +105,12 @@ namespace FileManager
|
||||
catch (IOException)
|
||||
{
|
||||
try { resave(); }
|
||||
catch (IOException)
|
||||
{
|
||||
Console.WriteLine("...that's not good");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error saving QuickFilters.json");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
FileManager/UNTESTED/SqliteStorage.cs
Normal file
11
FileManager/UNTESTED/SqliteStorage.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.IO;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class SqliteStorage
|
||||
{
|
||||
// not customizable. don't move to config
|
||||
private static string databasePath => Path.Combine(Configuration.Instance.LibationFiles, "LibationContext.db");
|
||||
public static string ConnectionString => $"Data Source={databasePath};Foreign Keys=False;";
|
||||
}
|
||||
}
|
||||
@@ -3,64 +3,59 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Tags must also be stored in db for search performance. Stored in json file to survive a db reset.
|
||||
/// json is only read when a product is first loaded
|
||||
/// json is only read when a product is first loaded into the db
|
||||
/// json is only written to when tags are edited
|
||||
/// json access is infrequent and one-off
|
||||
/// all other reads happen against db. No volitile storage
|
||||
/// </summary>
|
||||
public static class TagsPersistence
|
||||
{
|
||||
public static string TagsFile => Path.Combine(Configuration.Instance.LibationFiles, "BookTags.json");
|
||||
private static string TagsFile => Path.Combine(Configuration.Instance.LibationFiles, "BookTags.json");
|
||||
|
||||
private static object locker { get; } = new object();
|
||||
|
||||
public static void Save(string productId, string tags)
|
||||
=> System.Threading.Tasks.Task.Run(() => save_fireAndForget(productId, tags));
|
||||
// if failed, retry only 1 time after a wait of 100 ms
|
||||
// 1st save attempt sometimes fails with
|
||||
// The requested operation cannot be performed on a file with a user-mapped section open.
|
||||
private static RetryPolicy policy { get; }
|
||||
= Policy.Handle<Exception>()
|
||||
.WaitAndRetry(new[] { TimeSpan.FromMilliseconds(100) });
|
||||
|
||||
private static void save_fireAndForget(string productId, string tags)
|
||||
public static void Save(IEnumerable<(string productId, string tags)> tagsCollection)
|
||||
{
|
||||
ensureCache();
|
||||
|
||||
// on initial reload, there's a huge benefit to adding to cache individually then updating the file only once
|
||||
foreach ((string productId, string tags) in tagsCollection)
|
||||
cache[productId] = tags;
|
||||
|
||||
lock (locker)
|
||||
{
|
||||
// get all
|
||||
var allDictionary = retrieve();
|
||||
|
||||
// update/upsert tag list
|
||||
allDictionary[productId] = tags;
|
||||
|
||||
// re-save:
|
||||
// this often fails the first time with
|
||||
// The requested operation cannot be performed on a file with a user-mapped section open.
|
||||
// 2nd immediate attempt failing was rare. So I added sleep. We'll see...
|
||||
void resave() => File.WriteAllText(TagsFile, JsonConvert.SerializeObject(allDictionary, Formatting.Indented));
|
||||
try { resave(); }
|
||||
catch (IOException debugEx)
|
||||
{
|
||||
// 1000 was always reliable but very slow. trying other values
|
||||
var waitMs = 100;
|
||||
|
||||
System.Threading.Thread.Sleep(waitMs);
|
||||
resave();
|
||||
}
|
||||
}
|
||||
policy.Execute(() => File.WriteAllText(TagsFile, JsonConvert.SerializeObject(cache, Formatting.Indented)));
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> cache;
|
||||
|
||||
public static string GetTags(string productId)
|
||||
{
|
||||
var dic = retrieve();
|
||||
return dic.ContainsKey(productId) ? dic[productId] : null;
|
||||
ensureCache();
|
||||
|
||||
cache.TryGetValue(productId, out string value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> retrieve()
|
||||
{
|
||||
if (!FileUtility.FileExists(TagsFile))
|
||||
return new Dictionary<string, string>();
|
||||
lock (locker)
|
||||
return JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
|
||||
}
|
||||
}
|
||||
private static void ensureCache()
|
||||
{
|
||||
if (cache is null)
|
||||
lock (locker)
|
||||
cache = !File.Exists(TagsFile)
|
||||
? new Dictionary<string, string>()
|
||||
: JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class WebpageStorage
|
||||
{
|
||||
// not customizable. don't move to config
|
||||
private static string PagesDirectory { get; }
|
||||
= new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("Pages").FullName;
|
||||
private static string BookDetailsDirectory { get; }
|
||||
= new DirectoryInfo(PagesDirectory).CreateSubdirectory("Book Details").FullName;
|
||||
|
||||
public static string GetLibraryBatchName() => "Library_" + DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||
public static string SavePageToBatch(string contents, string batchName, string extension)
|
||||
{
|
||||
var batch_dir = Path.Combine(PagesDirectory, batchName);
|
||||
|
||||
Directory.CreateDirectory(batch_dir);
|
||||
|
||||
var file = Path.Combine(batch_dir, batchName + '.' + extension.Trim('.'));
|
||||
var filename = FileUtility.GetValidFilename(file);
|
||||
File.WriteAllText(filename, contents);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
public static List<FileInfo> GetJsonFiles(DirectoryInfo libDir)
|
||||
=> libDir == null
|
||||
? new List<FileInfo>()
|
||||
: Directory
|
||||
.EnumerateFiles(libDir.FullName, "*.json")
|
||||
.Select(f => new FileInfo(f))
|
||||
.ToList();
|
||||
|
||||
public static DirectoryInfo GetMostRecentLibraryDir()
|
||||
{
|
||||
var dir = Directory
|
||||
.EnumerateDirectories(PagesDirectory, "Library_*")
|
||||
.OrderBy(a => a)
|
||||
.LastOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
return null;
|
||||
return new DirectoryInfo(dir);
|
||||
}
|
||||
|
||||
public static FileInfo GetBookDetailHtmFileInfo(string productId)
|
||||
{
|
||||
var path = Path.Combine(BookDetailsDirectory, $"BookDetail-{productId}.htm");
|
||||
return new FileInfo(path);
|
||||
}
|
||||
|
||||
public static FileInfo GetBookDetailJsonFileInfo(string productId)
|
||||
{
|
||||
var path = Path.Combine(BookDetailsDirectory, $"BookDetail-{productId}.json");
|
||||
return new FileInfo(path);
|
||||
}
|
||||
|
||||
public static FileInfo SaveBookDetailsToHtm(string productId, string contents)
|
||||
{
|
||||
var fi = GetBookDetailHtmFileInfo(productId);
|
||||
File.WriteAllText(fi.FullName, contents);
|
||||
return fi;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
InternalUtilities/Account.cs
Normal file
97
InternalUtilities/Account.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InternalUtilities
|
||||
{
|
||||
public class Account : IUpdatable
|
||||
{
|
||||
public event EventHandler Updated;
|
||||
private void update(object sender = null, EventArgs e = null)
|
||||
=> Updated?.Invoke(this, new EventArgs());
|
||||
|
||||
// canonical. immutable. email or phone number
|
||||
public string AccountId { get; }
|
||||
|
||||
// user-friendly, non-canonical name. mutable
|
||||
private string _accountName;
|
||||
public string AccountName
|
||||
{
|
||||
get => _accountName;
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
var v = value.Trim();
|
||||
if (v == _accountName)
|
||||
return;
|
||||
_accountName = v;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
// whether to include this account when scanning libraries.
|
||||
// technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here
|
||||
private bool _libraryScan = true;
|
||||
public bool LibraryScan
|
||||
{
|
||||
get => _libraryScan;
|
||||
set
|
||||
{
|
||||
if (value == _libraryScan)
|
||||
return;
|
||||
_libraryScan = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
private string _decryptKey = "";
|
||||
/// <summary>aka: activation bytes</summary>
|
||||
public string DecryptKey
|
||||
{
|
||||
get => _decryptKey;
|
||||
set
|
||||
{
|
||||
var v = (value ?? "").Trim();
|
||||
if (v == _decryptKey)
|
||||
return;
|
||||
_decryptKey = v;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
private Identity _identity;
|
||||
public Identity IdentityTokens
|
||||
{
|
||||
get => _identity;
|
||||
set
|
||||
{
|
||||
if (_identity is null && value is null)
|
||||
return;
|
||||
|
||||
if (_identity != null)
|
||||
_identity.Updated -= update;
|
||||
|
||||
if (value != null)
|
||||
value.Updated += update;
|
||||
|
||||
_identity = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public Locale Locale => IdentityTokens?.Locale;
|
||||
|
||||
public Account(string accountId)
|
||||
{
|
||||
AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim();
|
||||
}
|
||||
|
||||
public override string ToString() => $"{AccountId} - {Locale?.Name ?? "[empty]"}";
|
||||
}
|
||||
}
|
||||
142
InternalUtilities/AccountsSettings.cs
Normal file
142
InternalUtilities/AccountsSettings.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InternalUtilities
|
||||
{
|
||||
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
|
||||
// from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm):
|
||||
// .NET : IList, IEnumerable, IList<T>, Array
|
||||
// JSON : Array (properties on the collection will not be serialized)
|
||||
public class AccountsSettings : IUpdatable
|
||||
{
|
||||
public event EventHandler Updated;
|
||||
private void update(object sender = null, EventArgs e = null)
|
||||
{
|
||||
foreach (var account in Accounts)
|
||||
validate(account);
|
||||
update_no_validate();
|
||||
}
|
||||
private void update_no_validate() => Updated?.Invoke(this, new EventArgs());
|
||||
|
||||
public AccountsSettings() { }
|
||||
|
||||
// for some reason this will make the json instantiator use _accounts_json.set()
|
||||
[JsonConstructor]
|
||||
protected AccountsSettings(List<Account> accountsSettings) { }
|
||||
|
||||
#region Accounts
|
||||
private List<Account> _accounts_backing = new List<Account>();
|
||||
[JsonProperty(PropertyName = nameof(Accounts))]
|
||||
private List<Account> _accounts_json
|
||||
{
|
||||
get => _accounts_backing;
|
||||
// 'set' is only used by json deser
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
|
||||
foreach (var account in value)
|
||||
_add(account);
|
||||
|
||||
update_no_validate();
|
||||
}
|
||||
}
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
||||
#endregion
|
||||
|
||||
#region de/serialize
|
||||
public static AccountsSettings FromJson(string json)
|
||||
=> JsonConvert.DeserializeObject<AccountsSettings>(json, Identity.GetJsonSerializerSettings());
|
||||
|
||||
public string ToJson(Formatting formatting = Formatting.Indented)
|
||||
=> JsonConvert.SerializeObject(this, formatting, Identity.GetJsonSerializerSettings());
|
||||
#endregion
|
||||
|
||||
// more common naming convention alias for internal collection
|
||||
public IReadOnlyList<Account> GetAll() => Accounts;
|
||||
|
||||
public Account Upsert(string accountId, string locale)
|
||||
{
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
if (acct != null)
|
||||
return acct;
|
||||
|
||||
var l = Localization.Get(locale);
|
||||
var id = new Identity(l);
|
||||
|
||||
var account = new Account(accountId) { IdentityTokens = id };
|
||||
Add(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
public void Add(Account account)
|
||||
{
|
||||
_add(account);
|
||||
update_no_validate();
|
||||
}
|
||||
|
||||
public void _add(Account account)
|
||||
{
|
||||
validate(account);
|
||||
|
||||
_accounts_backing.Add(account);
|
||||
account.Updated += update;
|
||||
}
|
||||
|
||||
public Account GetAccount(string accountId, string locale)
|
||||
{
|
||||
if (locale is null)
|
||||
return null;
|
||||
|
||||
return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.IdentityTokens.Locale.Name == locale);
|
||||
}
|
||||
|
||||
public bool Delete(string accountId, string locale)
|
||||
{
|
||||
var acct = GetAccount(accountId, locale);
|
||||
if (acct is null)
|
||||
return false;
|
||||
return Delete(acct);
|
||||
}
|
||||
|
||||
public bool Delete(Account account)
|
||||
{
|
||||
if (!_accounts_backing.Contains(account))
|
||||
return false;
|
||||
|
||||
account.Updated -= update;
|
||||
var result = _accounts_backing.Remove(account);
|
||||
update_no_validate();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validate(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
var accountId = account.AccountId;
|
||||
var locale = account?.IdentityTokens?.Locale?.Name;
|
||||
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
// new: ok
|
||||
if (acct is null)
|
||||
return;
|
||||
|
||||
// same account instance: ok
|
||||
if (acct == account)
|
||||
return;
|
||||
|
||||
// same account id + locale, different instance: bad
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
}
|
||||
}
|
||||
}
|
||||
24
InternalUtilities/AccountsSettingsPersister.cs
Normal file
24
InternalUtilities/AccountsSettingsPersister.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core.IO;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InternalUtilities
|
||||
{
|
||||
public class AccountsSettingsPersister : JsonFilePersister<AccountsSettings>
|
||||
{
|
||||
/// <summary>Alias for Target </summary>
|
||||
public AccountsSettings AccountsSettings => Target;
|
||||
|
||||
/// <summary>uses path. create file if doesn't yet exist</summary>
|
||||
public AccountsSettingsPersister(AccountsSettings target, string path, string jsonPath = null)
|
||||
: base(target, path, jsonPath) { }
|
||||
|
||||
/// <summary>load from existing file</summary>
|
||||
public AccountsSettingsPersister(string path, string jsonPath = null)
|
||||
: base(path, jsonPath) { }
|
||||
|
||||
protected override JsonSerializerSettings GetSerializerSettings()
|
||||
=> Identity.GetJsonSerializerSettings();
|
||||
}
|
||||
}
|
||||
12
InternalUtilities/ImportItem.cs
Normal file
12
InternalUtilities/ImportItem.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using AudibleApiDTOs;
|
||||
|
||||
namespace InternalUtilities
|
||||
{
|
||||
public class ImportItem
|
||||
{
|
||||
public Item DtoItem { get; set; }
|
||||
public string AccountId { get; set; }
|
||||
public string LocaleName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
|
||||
<ProjectReference Include="..\Scraping\Scraping.csproj" />
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -4,36 +4,49 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApiDTOs;
|
||||
using FileManager;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace InternalUtilities
|
||||
{
|
||||
public class AudibleApiActions
|
||||
public static class AudibleApiActions
|
||||
{
|
||||
public async Task<List<Item>> GetAllLibraryItemsAsync(ILoginCallback callback)
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public static Task<Api> GetApiAsync(string username, string localeName, ILoginCallback loginCallback = null)
|
||||
=> EzApiCreator.GetApiAsync(
|
||||
Localization.Get(localeName),
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName),
|
||||
loginCallback);
|
||||
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public static Task<Api> GetApiAsync(ILoginCallback loginCallback, Account account)
|
||||
=> EzApiCreator.GetApiAsync(
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
loginCallback);
|
||||
|
||||
private static AsyncRetryPolicy policy { get; }
|
||||
= Policy.Handle<Exception>()
|
||||
// 2 retries == 3 total
|
||||
.RetryAsync(2);
|
||||
|
||||
public static Task<List<Item>> GetLibraryValidatedAsync(Api api)
|
||||
{
|
||||
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or tokens are refreshed
|
||||
// worse, this 1st dummy call doesn't seem to help:
|
||||
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
|
||||
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
|
||||
|
||||
try
|
||||
{
|
||||
return await getItemsAsync(callback);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return await getItemsAsync(callback);
|
||||
}
|
||||
return policy.ExecuteAsync(() => getItemsAsync(api));
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(ILoginCallback callback)
|
||||
private static async Task<List<Item>> getItemsAsync(Api api)
|
||||
{
|
||||
var api = await EzApiCreator.GetApiAsync(AudibleApiStorage.IdentityTokensFile, callback, Configuration.Instance.LocaleCountryCode);
|
||||
var items = await AudibleApiExtensions.GetAllLibraryItemsAsync(api);
|
||||
var items = await api.GetAllLibraryItemsAsync();
|
||||
|
||||
// remove episode parents
|
||||
items.RemoveAll(i => i.Episodes);
|
||||
// remove episode parents and 'audible plus' check-outs
|
||||
items.RemoveAll(i => i.IsEpisodes || i.IsNonLibraryAudiblePlus);
|
||||
|
||||
#region // episode handling. doesn't quite work
|
||||
// // add individual/children episodes
|
||||
@@ -46,7 +59,7 @@ namespace InternalUtilities
|
||||
// foreach (var childId in childIds)
|
||||
// {
|
||||
// var bookResult = await api.GetLibraryBookAsync(childId, AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
// var bookItem = AudibleApiDTOs.LibraryApiV10.FromJson(bookResult.ToString()).Item;
|
||||
// var bookItem = AudibleApiDTOs.LibraryDtoV10.FromJson(bookResult.ToString()).Item;
|
||||
// items.Add(bookItem);
|
||||
// }
|
||||
#endregion
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApiDTOs;
|
||||
|
||||
//
|
||||
// probably not the best place for this
|
||||
// but good enough for now
|
||||
//
|
||||
namespace InternalUtilities
|
||||
{
|
||||
public static class AudibleApiExtensions
|
||||
{
|
||||
public static async Task<List<Item>> GetAllLibraryItemsAsync(this Api api)
|
||||
{
|
||||
var allItems = new List<Item>();
|
||||
|
||||
for (var i = 1; ; i++)
|
||||
{
|
||||
var page = await api.GetLibraryAsync(new LibraryOptions
|
||||
{
|
||||
NumberOfResultPerPage = 1000,
|
||||
PageNumber = i,
|
||||
PurchasedAfter = new DateTime(2000, 1, 1),
|
||||
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS
|
||||
});
|
||||
|
||||
// important! use this convert method
|
||||
var libResult = LibraryApiV10.FromJson(page.ToString());
|
||||
|
||||
if (!libResult.Items.Any())
|
||||
break;
|
||||
|
||||
allItems.AddRange(libResult.Items);
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user