mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-24 06:28:02 -05:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff20d777a6 | ||
|
|
270e2531e2 | ||
|
|
959a1aebe9 | ||
|
|
2217fe6948 | ||
|
|
96abf56a87 | ||
|
|
5731a8f693 | ||
|
|
ff722b6a52 | ||
|
|
9271114408 | ||
|
|
ebfdd44142 | ||
|
|
6ed4eb34bd | ||
|
|
9372571370 | ||
|
|
215c539920 | ||
|
|
7c7da2024e | ||
|
|
f55a3ca008 | ||
|
|
726b36de4d | ||
|
|
abd00ff1df | ||
|
|
7b966f6962 | ||
|
|
c0e955d5ef | ||
|
|
bc6f53c8ea | ||
|
|
cefab86ce1 | ||
|
|
249a2f3b59 | ||
|
|
0e9f2c7681 | ||
|
|
d25c32ff45 | ||
|
|
642a500f87 | ||
|
|
0e2469db64 | ||
|
|
9aa4ef70af | ||
|
|
1812fc2c7c | ||
|
|
f9849abb7b | ||
|
|
9cfe8ee6ca | ||
|
|
44e2cef18c | ||
|
|
4dc29affc3 | ||
|
|
2df38706f7 | ||
|
|
f30e9dae6f | ||
|
|
50843e5102 | ||
|
|
a13b00d520 | ||
|
|
b5ebe3db23 | ||
|
|
40f3e4503b | ||
|
|
0d93243b66 | ||
|
|
59c3845d21 | ||
|
|
a3ee3c2881 | ||
|
|
e971d34948 | ||
|
|
2b3f67fb99 | ||
|
|
4509b8c8eb | ||
|
|
2e40bebd7d | ||
|
|
dfc4121ab0 | ||
|
|
3648607d4d | ||
|
|
b22c35f841 | ||
|
|
2795690199 | ||
|
|
b1f92343cf | ||
|
|
9e1d657f60 | ||
|
|
389761355d | ||
|
|
69054afaa0 | ||
|
|
aacdcea1e1 | ||
|
|
0beb3bf437 | ||
|
|
e925b57f7f | ||
|
|
5deaa06d78 | ||
|
|
eda62975ba | ||
|
|
d91e02db29 | ||
|
|
cd604d03b1 | ||
|
|
d5cd569319 | ||
|
|
a58f51a8ce | ||
|
|
d24c10ddf5 | ||
|
|
a12391f0ab | ||
|
|
60f1d8117d | ||
|
|
20b6f28cb5 | ||
|
|
9a1fa89f6f | ||
|
|
2a294f4f85 | ||
|
|
0938c84929 | ||
|
|
99cc6a6425 | ||
|
|
0025825d5c | ||
|
|
81b6833118 | ||
|
|
a51e76d44d | ||
|
|
755a7338e9 | ||
|
|
56732a5365 | ||
|
|
dd3b032b21 | ||
|
|
dd25792864 | ||
|
|
6979ab4450 | ||
|
|
4b31207f91 | ||
|
|
84a847a838 | ||
|
|
6900a68b9d | ||
|
|
743644c4e9 | ||
|
|
e9e380dbe6 | ||
|
|
515dfceb73 | ||
|
|
3941906d72 | ||
|
|
6407d15fe0 | ||
|
|
be84fb317e | ||
|
|
3af010c1f5 | ||
|
|
714bb2ba50 | ||
|
|
2e5360f0ba | ||
|
|
258775ff3f | ||
|
|
82318ffab7 | ||
|
|
901572e7bb | ||
|
|
cfa938360a | ||
|
|
80017ce9fd | ||
|
|
c67972a327 | ||
|
|
57ee150d3c | ||
|
|
57302e1b5c | ||
|
|
09dbc67914 | ||
|
|
b768362eae | ||
|
|
04a32533cb | ||
|
|
1ad2135a3f | ||
|
|
643ae09b2b | ||
|
|
8391e43b03 | ||
|
|
8a54eda4a0 | ||
|
|
e0406378cb | ||
|
|
e1299331cc | ||
|
|
248b336867 | ||
|
|
b7d96ae447 | ||
|
|
8ab2af1c5d | ||
|
|
2d459bb2cf | ||
|
|
aeb0d2a82b | ||
|
|
f50dab94a4 | ||
|
|
efa5cefa23 | ||
|
|
2e4a97fde7 | ||
|
|
2f241806fa | ||
|
|
e417f60a36 | ||
|
|
b00f2bd908 | ||
|
|
220cda42e7 | ||
|
|
f992a7ec64 | ||
|
|
c54c45df33 |
40
AaxDecrypter/AAXChapters.cs
Normal file
40
AaxDecrypter/AAXChapters.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Diagnostics;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AAXChapters : Chapters
|
||||
{
|
||||
public AAXChapters(string file)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = DecryptSupportLibraries.ffprobePath,
|
||||
Arguments = "-loglevel panic -show_chapters -print_format xml \"" + file + "\""
|
||||
};
|
||||
var xml = info.RunHidden().Output;
|
||||
|
||||
var xmlDocument = new System.Xml.XmlDocument();
|
||||
xmlDocument.LoadXml(xml);
|
||||
var chaptersXml = xmlDocument.SelectNodes("/ffprobe/chapters/chapter")
|
||||
.Cast<System.Xml.XmlNode>()
|
||||
.Where(n => n.Name == "chapter");
|
||||
|
||||
foreach (var cnode in chaptersXml)
|
||||
{
|
||||
double startTime = double.Parse(cnode.Attributes["start_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture);
|
||||
double endTime = double.Parse(cnode.Attributes["end_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture);
|
||||
|
||||
string chapterTitle = cnode.ChildNodes
|
||||
.Cast<System.Xml.XmlNode>()
|
||||
.Where(childnode => childnode.Attributes["key"].Value == "title")
|
||||
.Select(childnode => childnode.Attributes["value"].Value)
|
||||
.FirstOrDefault();
|
||||
|
||||
AddChapter(new Chapter(startTime, endTime, chapterTitle));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -10,57 +10,11 @@
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="..\..\..\..\..\..\Dinah%2527s folder\coding\_NET\Visual Studio 2019\Libation\AaxDecrypter\UNTESTED\BytesCrackerLib\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="BytesCrackerLib\alglib1.dll">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_0_10000x789935_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_1_10000x791425_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_2_10000x790991_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_3_10000x792120_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_4_10000x790743_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_5_10000x790568_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_6_10000x791458_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_7_10000x791707_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_8_10000x790202_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\audible_byte#4-4_9_10000x791022_0.rtc">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\ffmpeg.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\ffprobe.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="BytesCrackerLib\rcrack.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="DecryptLib\AtomicParsley.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
||||
@@ -62,15 +62,18 @@ namespace AaxDecrypter
|
||||
public Tags tags { get; private set; }
|
||||
public EncodingInfo encodingInfo { get; private set; }
|
||||
|
||||
public static async Task<AaxToM4bConverter> CreateAsync(string inputFile, string decryptKey)
|
||||
private Func<Task<string>> getKeyFuncAsync { get; }
|
||||
|
||||
public static async Task<AaxToM4bConverter> CreateAsync(string inputFile, string decryptKey, Func<Task<string>> getKeyFunc, Chapters chapters = null)
|
||||
{
|
||||
var converter = new AaxToM4bConverter(inputFile, decryptKey);
|
||||
var converter = new AaxToM4bConverter(inputFile, decryptKey, getKeyFunc);
|
||||
converter.chapters = chapters ?? new AAXChapters(inputFile);
|
||||
await converter.prelimProcessing();
|
||||
converter.printPrelim();
|
||||
|
||||
return converter;
|
||||
}
|
||||
private AaxToM4bConverter(string inputFile, string decryptKey)
|
||||
private AaxToM4bConverter(string inputFile, string decryptKey, Func<Task<string>> getKeyFunc)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(inputFile, nameof(inputFile));
|
||||
if (!File.Exists(inputFile))
|
||||
@@ -92,18 +95,18 @@ namespace AaxDecrypter
|
||||
|
||||
inputFileName = inputFile;
|
||||
this.decryptKey = decryptKey;
|
||||
this.getKeyFuncAsync = getKeyFunc;
|
||||
}
|
||||
|
||||
private async Task prelimProcessing()
|
||||
{
|
||||
tags = new Tags(inputFileName);
|
||||
encodingInfo = new EncodingInfo(inputFileName);
|
||||
chapters = new Chapters(inputFileName, tags.duration.TotalSeconds);
|
||||
|
||||
var defaultFilename = Path.Combine(
|
||||
Path.GetDirectoryName(inputFileName),
|
||||
getASCIITag(tags.author),
|
||||
getASCIITag(tags.title) + ".m4b"
|
||||
PathLib.ToPathSafeString(tags.author),
|
||||
PathLib.ToPathSafeString(tags.title) + ".m4b"
|
||||
);
|
||||
|
||||
// set default name
|
||||
@@ -111,12 +114,6 @@ namespace AaxDecrypter
|
||||
|
||||
await Task.Run(() => saveCover(inputFileName));
|
||||
}
|
||||
private string getASCIITag(string property)
|
||||
{
|
||||
foreach (char ch in new string(Path.GetInvalidFileNameChars()) + new string(Path.GetInvalidPathChars()))
|
||||
property = property.Replace(ch.ToString(), "");
|
||||
return property;
|
||||
}
|
||||
|
||||
private void saveCover(string aaxFile)
|
||||
{
|
||||
@@ -126,19 +123,14 @@ namespace AaxDecrypter
|
||||
|
||||
private void printPrelim()
|
||||
{
|
||||
Console.WriteLine("Audible Book ID = " + tags.id);
|
||||
Console.WriteLine($"Audible Book ID = {tags.id}");
|
||||
|
||||
Console.WriteLine("Book: " + tags.title);
|
||||
Console.WriteLine("Author: " + tags.author);
|
||||
Console.WriteLine("Narrator: " + tags.narrator);
|
||||
Console.WriteLine("Year: " + tags.year);
|
||||
Console.WriteLine("Total Time: "
|
||||
+ tags.duration.GetTotalTimeFormatted()
|
||||
+ " in " + chapters.Count() + " chapters");
|
||||
Console.WriteLine("WARNING-Source is "
|
||||
+ encodingInfo.originalBitrate + " kbits @ "
|
||||
+ encodingInfo.sampleRate + "Hz, "
|
||||
+ encodingInfo.channels + " channels");
|
||||
Console.WriteLine($"Book: {tags.title}");
|
||||
Console.WriteLine($"Author: {tags.author}");
|
||||
Console.WriteLine($"Narrator: {tags.narrator}");
|
||||
Console.WriteLine($"Year: {tags.year}");
|
||||
Console.WriteLine($"Total Time: {tags.duration.GetTotalTimeFormatted()} in {chapters.Count} chapters");
|
||||
Console.WriteLine($"WARNING-Source is {encodingInfo.originalBitrate} kbits @ {encodingInfo.sampleRate}Hz, {encodingInfo.channels} channels");
|
||||
}
|
||||
|
||||
public bool Run()
|
||||
@@ -159,19 +151,14 @@ namespace AaxDecrypter
|
||||
|
||||
public void SetOutputFilename(string outFileName)
|
||||
{
|
||||
outputFileName = outFileName;
|
||||
|
||||
if (Path.GetExtension(outputFileName) != ".m4b")
|
||||
outputFileName = outputFileWithNewExt(".m4b");
|
||||
|
||||
if (File.Exists(outputFileName))
|
||||
File.Delete(outputFileName);
|
||||
|
||||
outputFileName = PathLib.ReplaceExtension(outFileName, ".m4b");
|
||||
outDir = Path.GetDirectoryName(outputFileName);
|
||||
|
||||
if (File.Exists(outputFileName))
|
||||
File.Delete(outputFileName);
|
||||
}
|
||||
|
||||
private string outputFileWithNewExt(string extension)
|
||||
=> Path.Combine(outDir, Path.GetFileNameWithoutExtension(outputFileName) + '.' + extension.Trim('.'));
|
||||
private string outputFileWithNewExt(string extension) => PathLib.ReplaceExtension(outputFileName, extension);
|
||||
|
||||
public bool Step1_CreateDir()
|
||||
{
|
||||
@@ -225,22 +212,12 @@ namespace AaxDecrypter
|
||||
|
||||
private int getKey_decrypt(string tempRipFile)
|
||||
{
|
||||
getKey();
|
||||
decryptKey = getKey();
|
||||
return decrypt(tempRipFile);
|
||||
}
|
||||
private void getKey()
|
||||
{
|
||||
Console.WriteLine("Discovering decrypt key");
|
||||
|
||||
Console.WriteLine("Getting file hash");
|
||||
var checksum = BytesCracker.GetChecksum(inputFileName);
|
||||
Console.WriteLine("File hash calculated: " + checksum);
|
||||
|
||||
Console.WriteLine("Cracking activation bytes");
|
||||
var activation_bytes = BytesCracker.GetActivationBytes(checksum);
|
||||
decryptKey = activation_bytes;
|
||||
Console.WriteLine("Activation bytes cracked. Decrypt key: " + activation_bytes);
|
||||
}
|
||||
// I am NOT happy about doing async this way. Async needs to be added to Step framework
|
||||
string getKey() => getKeyFuncAsync().GetAwaiter().GetResult();
|
||||
|
||||
private int decrypt(string tempRipFile)
|
||||
{
|
||||
@@ -276,13 +253,13 @@ namespace AaxDecrypter
|
||||
};
|
||||
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
|
||||
@@ -294,9 +271,9 @@ namespace AaxDecrypter
|
||||
public bool Step3_Chapterize()
|
||||
{
|
||||
var str1 = "";
|
||||
if (chapters.FirstChapterStart != 0.0)
|
||||
if (chapters.FirstChapter.StartTime != 0.0)
|
||||
{
|
||||
str1 = " -ss " + chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " ";
|
||||
str1 = " -ss " + chapters.FirstChapter.StartTime.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + chapters.LastChapter.EndTime.ToString("0.000", CultureInfo.InvariantCulture) + " ";
|
||||
}
|
||||
|
||||
var ffmpegTags = tags.GenerateFfmpegTags();
|
||||
@@ -349,13 +326,13 @@ namespace AaxDecrypter
|
||||
|
||||
public bool End_CreateCue()
|
||||
{
|
||||
File.WriteAllText(outputFileWithNewExt(".cue"), chapters.GetCuefromChapters(Path.GetFileName(outputFileName)));
|
||||
File.WriteAllText(outputFileWithNewExt(".cue"), Cue.CreateContents(Path.GetFileName(outputFileName), chapters));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool End_CreateNfo()
|
||||
{
|
||||
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, tags, encodingInfo, chapters));
|
||||
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateContents(AppName, tags, encodingInfo, chapters));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
27
AaxDecrypter/Chapter.cs
Normal file
27
AaxDecrypter/Chapter.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class Chapter
|
||||
{
|
||||
public Chapter(double startTime, double endTime, string title)
|
||||
{
|
||||
StartTime = startTime;
|
||||
EndTime = endTime;
|
||||
Title = title;
|
||||
}
|
||||
/// <summary>
|
||||
/// Chapter start time, in seconds.
|
||||
/// </summary>
|
||||
public double StartTime { get; private set; }
|
||||
/// <summary>
|
||||
/// Chapter end time, in seconds.
|
||||
/// </summary>
|
||||
public double EndTime { get; private set; }
|
||||
public string Title { get; private set; }
|
||||
}
|
||||
}
|
||||
40
AaxDecrypter/Chapters.cs
Normal file
40
AaxDecrypter/Chapters.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public abstract class Chapters
|
||||
{
|
||||
private List<Chapter> _chapterList = new();
|
||||
public int Count => _chapterList.Count;
|
||||
public Chapter FirstChapter => _chapterList[0];
|
||||
public Chapter LastChapter => _chapterList[Count - 1];
|
||||
public IEnumerable<Chapter> ChapterList => _chapterList.AsEnumerable();
|
||||
public IEnumerable<TimeSpan> GetBeginningTimes() => ChapterList.Select(c => TimeSpan.FromSeconds(c.StartTime));
|
||||
protected void AddChapter(Chapter chapter)
|
||||
{
|
||||
_chapterList.Add(chapter);
|
||||
}
|
||||
protected void AddChapters(IEnumerable<Chapter> chapters)
|
||||
{
|
||||
_chapterList.AddRange(chapters);
|
||||
}
|
||||
public string GenerateFfmpegChapters()
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
foreach (Chapter c in ChapterList)
|
||||
{
|
||||
stringBuilder.Append("[CHAPTER]\n");
|
||||
stringBuilder.Append("TIMEBASE=1/1000\n");
|
||||
stringBuilder.Append("START=" + c.StartTime * 1000 + "\n");
|
||||
stringBuilder.Append("END=" + c.EndTime * 1000 + "\n");
|
||||
stringBuilder.Append("title=" + c.Title + "\n");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
60
AaxDecrypter/Cue.cs
Normal file
60
AaxDecrypter/Cue.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class Cue
|
||||
{
|
||||
public static string CreateContents(string filePath, Chapters chapters)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
stringBuilder.AppendLine(GetFileLine(filePath, "MP3"));
|
||||
|
||||
var trackCount = 0;
|
||||
foreach (Chapter c in chapters.ChapterList)
|
||||
{
|
||||
trackCount++;
|
||||
var startTime = TimeSpan.FromSeconds(c.StartTime);
|
||||
|
||||
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
|
||||
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
||||
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss\\:ff}");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath)
|
||||
=> UpdateFileName(cueFileInfo.FullName, audioFilePath);
|
||||
|
||||
public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo)
|
||||
=> UpdateFileName(cueFilePath, audioFileInfo.FullName);
|
||||
|
||||
public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo)
|
||||
=> UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName);
|
||||
|
||||
public static void UpdateFileName(string cueFilePath, string audioFilePath)
|
||||
{
|
||||
var cueContents = File.ReadAllLines(cueFilePath);
|
||||
|
||||
for (var i = 0; i < cueContents.Length; i++)
|
||||
{
|
||||
var line = cueContents[i];
|
||||
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
|
||||
continue;
|
||||
|
||||
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
||||
cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]);
|
||||
break;
|
||||
}
|
||||
|
||||
File.WriteAllLines(cueFilePath, cueContents);
|
||||
}
|
||||
|
||||
private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}";
|
||||
}
|
||||
}
|
||||
54
AaxDecrypter/NFO.cs
Normal file
54
AaxDecrypter/NFO.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class NFO
|
||||
{
|
||||
public static string CreateContents(string ripper, Tags tags, EncodingInfo encodingInfo, Chapters chapters)
|
||||
{
|
||||
var _hours = (int)tags.duration.TotalHours;
|
||||
var myDuration
|
||||
= (_hours > 0 ? _hours + " hours, " : "")
|
||||
+ tags.duration.Minutes + " minutes, "
|
||||
+ tags.duration.Seconds + " seconds";
|
||||
|
||||
var header
|
||||
= "General Information\r\n"
|
||||
+ "===================\r\n"
|
||||
+ $" Title: {tags.title}\r\n"
|
||||
+ $" Author: {tags.author}\r\n"
|
||||
+ $" Read By: {tags.narrator}\r\n"
|
||||
+ $" Copyright: {tags.year}\r\n"
|
||||
+ $" Audiobook Copyright: {tags.year}\r\n";
|
||||
if (tags.genre != "")
|
||||
header += $" Genre: {tags.genre}\r\n";
|
||||
|
||||
var s
|
||||
= header
|
||||
+ $" Publisher: {tags.publisher}\r\n"
|
||||
+ $" Duration: {myDuration}\r\n"
|
||||
+ $" Chapters: {chapters.Count}\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Media Information\r\n"
|
||||
+ "=================\r\n"
|
||||
+ " Source Format: Audible AAX\r\n"
|
||||
+ $" Source Sample Rate: {encodingInfo.sampleRate} Hz\r\n"
|
||||
+ $" Source Channels: {encodingInfo.channels}\r\n"
|
||||
+ $" Source Bitrate: {encodingInfo.originalBitrate} kbits\r\n"
|
||||
+ "\r\n"
|
||||
+ " Lossless Encode: Yes\r\n"
|
||||
+ " Encoded Codec: AAC / M4B\r\n"
|
||||
+ $" Encoded Sample Rate: {encodingInfo.sampleRate} Hz\r\n"
|
||||
+ $" Encoded Channels: {encodingInfo.channels}\r\n"
|
||||
+ $" Encoded Bitrate: {encodingInfo.originalBitrate} kbits\r\n"
|
||||
+ "\r\n"
|
||||
+ $" Ripper: {ripper}\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Book Description\r\n"
|
||||
+ "================\r\n"
|
||||
+ tags.comments;
|
||||
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Diagnostics;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class BytesCracker
|
||||
{
|
||||
public static string GetChecksum(string aaxPath)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = BytesCrackerSupportLibraries.ffprobePath,
|
||||
Arguments = aaxPath.SurroundWithQuotes(),
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
// checksum is in the debug info. ffprobe's debug info is written to stderr, not stdout
|
||||
var readErrorOutput = true;
|
||||
var ffprobeStderr = info.RunHidden(readErrorOutput).Output;
|
||||
|
||||
// example checksum line:
|
||||
// ... [aax] file checksum == 0c527840c4f18517157eb0b4f9d6f9317ce60cd1
|
||||
var checksum = ffprobeStderr.ExtractString("file checksum == ", 40);
|
||||
|
||||
return checksum;
|
||||
}
|
||||
|
||||
/// <summary>use checksum to get activation bytes. activation bytes are unique per audible customer. only have to do this 1x/customer</summary>
|
||||
public static string GetActivationBytes(string checksum)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = BytesCrackerSupportLibraries.rcrackPath,
|
||||
Arguments = @". -h " + checksum,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
var rcrackStdout = info.RunHidden().Output;
|
||||
|
||||
// example result
|
||||
// 0c527840c4f18517157eb0b4f9d6f9317ce60cd1 \xbd\x89X\x09 hex:bd895809
|
||||
var activation_bytes = rcrackStdout.ExtractString("hex:", 8);
|
||||
|
||||
return activation_bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.IO;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class BytesCrackerSupportLibraries
|
||||
{
|
||||
// GetActivationBytes dependencies
|
||||
// rcrack.exe
|
||||
// alglib1.dll
|
||||
// RainbowCrack files to recover your own Audible activation data (activation_bytes) in an offline manner
|
||||
// audible_byte#4-4_0_10000x789935_0.rtc
|
||||
// audible_byte#4-4_1_10000x791425_0.rtc
|
||||
// audible_byte#4-4_2_10000x790991_0.rtc
|
||||
// audible_byte#4-4_3_10000x792120_0.rtc
|
||||
// audible_byte#4-4_4_10000x790743_0.rtc
|
||||
// audible_byte#4-4_5_10000x790568_0.rtc
|
||||
// audible_byte#4-4_6_10000x791458_0.rtc
|
||||
// audible_byte#4-4_7_10000x791707_0.rtc
|
||||
// audible_byte#4-4_8_10000x790202_0.rtc
|
||||
// audible_byte#4-4_9_10000x791022_0.rtc
|
||||
|
||||
private static string appPath_ { get; } = Path.GetDirectoryName(Dinah.Core.Exe.FileLocationOnDisk);
|
||||
private static string bytesCrackerLib_ { get; } = Path.Combine(appPath_, "BytesCrackerLib");
|
||||
|
||||
public static string ffprobePath { get; } = Path.Combine(bytesCrackerLib_, "ffprobe.exe");
|
||||
public static string rcrackPath { get; } = Path.Combine(bytesCrackerLib_, "rcrack.exe");
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Dinah.Core.Diagnostics;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class Chapters
|
||||
{
|
||||
private List<double> markers { get; }
|
||||
|
||||
public double FirstChapterStart => markers[0];
|
||||
public double LastChapterStart => markers[markers.Count - 1];
|
||||
|
||||
public Chapters(string file, double totalTime)
|
||||
{
|
||||
markers = getAAXChapters(file);
|
||||
|
||||
// add end time
|
||||
markers.Add(totalTime);
|
||||
}
|
||||
|
||||
private static List<double> getAAXChapters(string file)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = DecryptSupportLibraries.ffprobePath,
|
||||
Arguments = "-loglevel panic -show_chapters -print_format xml \"" + file + "\""
|
||||
};
|
||||
var xml = info.RunHidden().Output;
|
||||
|
||||
var xmlDocument = new System.Xml.XmlDocument();
|
||||
xmlDocument.LoadXml(xml);
|
||||
var chapters = xmlDocument.SelectNodes("/ffprobe/chapters/chapter")
|
||||
.Cast<System.Xml.XmlNode>()
|
||||
.Select(xmlNode => double.Parse(xmlNode.Attributes["start_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
return chapters;
|
||||
}
|
||||
|
||||
// subtract 1 b/c end time marker is a real entry but isn't a real chapter
|
||||
public int Count() => markers.Count - 1;
|
||||
|
||||
public string GetCuefromChapters(string fileName)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
if (fileName != "")
|
||||
{
|
||||
stringBuilder.Append("FILE \"" + fileName + "\" MP4\n");
|
||||
}
|
||||
|
||||
for (var i = 0; i < Count(); i++)
|
||||
{
|
||||
var chapter = i + 1;
|
||||
|
||||
var timeSpan = TimeSpan.FromSeconds(markers[i]);
|
||||
var minutes = Math.Floor(timeSpan.TotalMinutes).ToString();
|
||||
var seconds = timeSpan.Seconds.ToString("D2");
|
||||
var milliseconds = (timeSpan.Milliseconds / 10).ToString("D2");
|
||||
string str = minutes + ":" + seconds + ":" + milliseconds;
|
||||
|
||||
stringBuilder.Append("TRACK " + chapter + " AUDIO\n");
|
||||
stringBuilder.Append(" TITLE \"Chapter " + chapter.ToString("D2") + "\"\n");
|
||||
stringBuilder.Append(" INDEX 01 " + str + "\n");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
public string GenerateFfmpegChapters()
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
for (var i = 0; i < Count(); i++)
|
||||
{
|
||||
var chapter = i + 1;
|
||||
|
||||
var start = markers[i] * 1000.0;
|
||||
var end = markers[i + 1] * 1000.0;
|
||||
var chapterName = chapter.ToString("D3");
|
||||
|
||||
stringBuilder.Append("[CHAPTER]\n");
|
||||
stringBuilder.Append("TIMEBASE=1/1000\n");
|
||||
stringBuilder.Append("START=" + start + "\n");
|
||||
stringBuilder.Append("END=" + end + "\n");
|
||||
stringBuilder.Append("title=" + chapterName + "\n");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class NFO
|
||||
{
|
||||
public static string CreateNfoContents(string ripper, Tags tags, EncodingInfo encodingInfo, Chapters chapters)
|
||||
{
|
||||
int _hours = (int)tags.duration.TotalHours;
|
||||
string myDuration
|
||||
= (_hours > 0 ? _hours + " hours, " : "")
|
||||
+ tags.duration.Minutes + " minutes, "
|
||||
+ tags.duration.Seconds + " seconds";
|
||||
|
||||
string str4
|
||||
= "General Information\r\n"
|
||||
+ "===================\r\n"
|
||||
+ " Title: " + tags.title + "\r\n"
|
||||
+ " Author: " + tags.author + "\r\n"
|
||||
+ " Read By: " + tags.narrator + "\r\n"
|
||||
+ " Copyright: " + tags.year + "\r\n"
|
||||
+ " Audiobook Copyright: " + tags.year + "\r\n";
|
||||
if (tags.genre != "")
|
||||
{
|
||||
str4 = str4 + " Genre: " + tags.genre + "\r\n";
|
||||
}
|
||||
|
||||
string s
|
||||
= str4
|
||||
+ " Publisher: " + tags.publisher + "\r\n"
|
||||
+ " Duration: " + myDuration + "\r\n"
|
||||
+ " Chapters: " + chapters.Count() + "\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Media Information\r\n"
|
||||
+ "=================\r\n"
|
||||
+ " Source Format: Audible AAX\r\n"
|
||||
+ " Source Sample Rate: " + encodingInfo.sampleRate + " Hz\r\n"
|
||||
+ " Source Channels: " + encodingInfo.channels + "\r\n"
|
||||
+ " Source Bitrate: " + encodingInfo.originalBitrate + " kbits\r\n"
|
||||
+ "\r\n"
|
||||
+ " Lossless Encode: Yes\r\n"
|
||||
+ " Encoded Codec: AAC / M4B\r\n"
|
||||
+ " Encoded Sample Rate: " + encodingInfo.sampleRate + " Hz\r\n"
|
||||
+ " Encoded Channels: " + encodingInfo.channels + "\r\n"
|
||||
+ " Encoded Bitrate: " + encodingInfo.originalBitrate + " kbits\r\n"
|
||||
+ "\r\n"
|
||||
+ " Ripper: " + ripper + "\r\n"
|
||||
+ "\r\n"
|
||||
+ "\r\n"
|
||||
+ "Book Description\r\n"
|
||||
+ "================\r\n"
|
||||
+ tags.comments;
|
||||
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="27.0.2" />
|
||||
<PackageReference Include="NPOI" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
|
||||
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
|
||||
|
||||
123
ApplicationServices/LibraryCommands.cs
Normal file
123
ApplicationServices/LibraryCommands.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using DtoImporterService;
|
||||
using InternalUtilities;
|
||||
using Serilog;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class LibraryCommands
|
||||
{
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts)
|
||||
{
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return (0, 0);
|
||||
|
||||
try
|
||||
{
|
||||
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
var newCount = await importIntoDbAsync(importItems);
|
||||
Log.Logger.Information($"Import: New count {newCount}");
|
||||
|
||||
await Task.Run(() => SearchEngineCommands.FullReIndex());
|
||||
Log.Logger.Information("FullReIndex: success");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.MoveResponseBodyFile(FileManager.Configuration.Instance.LibationFiles);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new {
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePath
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, Account[] accounts)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
var callback = loginCallbackFactoryFunc(account);
|
||||
|
||||
// get APIs in serial, esp b/c of logins
|
||||
var api = await AudibleApiActions.GetApiAsync(callback, account);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(api, account));
|
||||
}
|
||||
|
||||
// import library in parallel
|
||||
var arrayOfLists = await Task.WhenAll(tasks);
|
||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||
return importItems;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(Api api, Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||
{
|
||||
Account = account?.MaskedLogEntry ?? "[null]"
|
||||
});
|
||||
|
||||
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api);
|
||||
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
||||
}
|
||||
|
||||
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryImporter = new LibraryImporter(context);
|
||||
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
|
||||
context.SaveChanges();
|
||||
|
||||
return newCount;
|
||||
}
|
||||
|
||||
public static int UpdateTags(this LibationContext context, Book book, string newTags)
|
||||
{
|
||||
try
|
||||
{
|
||||
book.UserDefinedItem.Tags = newTags;
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
|
||||
if (qtyChanges > 0)
|
||||
SearchEngineCommands.UpdateBookTags(book);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error updating tags");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
268
ApplicationServices/LibraryExporter.cs
Normal file
268
ApplicationServices/LibraryExporter.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using NPOI.XSSF.UserModel;
|
||||
using Serilog;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public class ExportDto
|
||||
{
|
||||
public static string GetName(string fieldName)
|
||||
{
|
||||
var property = typeof(ExportDto).GetProperty(fieldName);
|
||||
var attribute = property.GetCustomAttributes(typeof(NameAttribute), true)[0];
|
||||
var description = (NameAttribute)attribute;
|
||||
var text = description.Names;
|
||||
return text[0];
|
||||
}
|
||||
|
||||
[Name("Account")]
|
||||
public string Account { get; set; }
|
||||
|
||||
[Name("Date Added to library")]
|
||||
public DateTime DateAdded { get; set; }
|
||||
|
||||
[Name("Audible Product Id")]
|
||||
public string AudibleProductId { get; set; }
|
||||
|
||||
[Name("Locale")]
|
||||
public string Locale { get; set; }
|
||||
|
||||
[Name("Title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[Name("Authors")]
|
||||
public string AuthorNames { get; set; }
|
||||
|
||||
[Name("Narrators")]
|
||||
public string NarratorNames { get; set; }
|
||||
|
||||
[Name("Length In Minutes")]
|
||||
public int LengthInMinutes { get; set; }
|
||||
|
||||
[Name("Publisher")]
|
||||
public string Publisher { get; set; }
|
||||
|
||||
[Name("Pdf url")]
|
||||
public string PdfUrl { get; set; }
|
||||
|
||||
[Name("Series Names")]
|
||||
public string SeriesNames { get; set; }
|
||||
|
||||
[Name("Series Order")]
|
||||
public string SeriesOrder { get; set; }
|
||||
|
||||
[Name("Community Rating: Overall")]
|
||||
public float? CommunityRatingOverall { get; set; }
|
||||
|
||||
[Name("Community Rating: Performance")]
|
||||
public float? CommunityRatingPerformance { get; set; }
|
||||
|
||||
[Name("Community Rating: Story")]
|
||||
public float? CommunityRatingStory { get; set; }
|
||||
|
||||
[Name("Cover Id")]
|
||||
public string PictureId { get; set; }
|
||||
|
||||
[Name("Is Abridged?")]
|
||||
public bool IsAbridged { get; set; }
|
||||
|
||||
[Name("Date Published")]
|
||||
public DateTime? DatePublished { get; set; }
|
||||
|
||||
[Name("Categories")]
|
||||
public string CategoriesNames { get; set; }
|
||||
|
||||
[Name("My Rating: Overall")]
|
||||
public float? MyRatingOverall { get; set; }
|
||||
|
||||
[Name("My Rating: Performance")]
|
||||
public float? MyRatingPerformance { get; set; }
|
||||
|
||||
[Name("My Rating: Story")]
|
||||
public float? MyRatingStory { get; set; }
|
||||
|
||||
[Name("My Libation Tags")]
|
||||
public string MyLibationTags { get; set; }
|
||||
}
|
||||
public static class LibToDtos
|
||||
{
|
||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||
=> library.Select(a => new ExportDto
|
||||
{
|
||||
Account = a.Account,
|
||||
DateAdded = a.DateAdded,
|
||||
AudibleProductId = a.Book.AudibleProductId,
|
||||
Locale = a.Book.Locale,
|
||||
Title = a.Book.Title,
|
||||
AuthorNames = a.Book.AuthorNames,
|
||||
NarratorNames = a.Book.NarratorNames,
|
||||
LengthInMinutes = a.Book.LengthInMinutes,
|
||||
Publisher = a.Book.Publisher,
|
||||
PdfUrl = a.Book.Supplements?.FirstOrDefault()?.Url,
|
||||
SeriesNames = a.Book.SeriesNames,
|
||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Index} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
CommunityRatingOverall = a.Book.Rating?.OverallRating,
|
||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
||||
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
||||
PictureId = a.Book.PictureId,
|
||||
IsAbridged = a.Book.IsAbridged,
|
||||
DatePublished = a.Book.DatePublished,
|
||||
CategoriesNames = a.Book.CategoriesNames.Any() ? a.Book.CategoriesNames.Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||
MyLibationTags = a.Book.UserDefinedItem.Tags
|
||||
}).ToList();
|
||||
}
|
||||
public static class LibraryExporter
|
||||
{
|
||||
public static void ToCsv(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
if (!dtos.Any())
|
||||
return;
|
||||
|
||||
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||
|
||||
csv.WriteHeader(typeof(ExportDto));
|
||||
csv.NextRecord();
|
||||
csv.WriteRecords(dtos);
|
||||
}
|
||||
|
||||
public static void ToJson(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
|
||||
System.IO.File.WriteAllText(saveFilePath, json);
|
||||
}
|
||||
|
||||
public static void ToXlsx(string saveFilePath)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var workbook = new XSSFWorkbook();
|
||||
var sheet = workbook.CreateSheet("Library");
|
||||
|
||||
var detailSubtotalFont = workbook.CreateFont();
|
||||
detailSubtotalFont.IsBold = true;
|
||||
|
||||
var detailSubtotalCellStyle = workbook.CreateCellStyle();
|
||||
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
|
||||
|
||||
// headers
|
||||
var rowIndex = 0;
|
||||
var row = sheet.CreateRow(rowIndex);
|
||||
|
||||
var columns = new[] {
|
||||
nameof (ExportDto.Account),
|
||||
nameof (ExportDto.DateAdded),
|
||||
nameof (ExportDto.AudibleProductId),
|
||||
nameof (ExportDto.Locale),
|
||||
nameof (ExportDto.Title),
|
||||
nameof (ExportDto.AuthorNames),
|
||||
nameof (ExportDto.NarratorNames),
|
||||
nameof (ExportDto.LengthInMinutes),
|
||||
nameof (ExportDto.Publisher),
|
||||
nameof (ExportDto.PdfUrl),
|
||||
nameof (ExportDto.SeriesNames),
|
||||
nameof (ExportDto.SeriesOrder),
|
||||
nameof (ExportDto.CommunityRatingOverall),
|
||||
nameof (ExportDto.CommunityRatingPerformance),
|
||||
nameof (ExportDto.CommunityRatingStory),
|
||||
nameof (ExportDto.PictureId),
|
||||
nameof (ExportDto.IsAbridged),
|
||||
nameof (ExportDto.DatePublished),
|
||||
nameof (ExportDto.CategoriesNames),
|
||||
nameof (ExportDto.MyRatingOverall),
|
||||
nameof (ExportDto.MyRatingPerformance),
|
||||
nameof (ExportDto.MyRatingStory),
|
||||
nameof (ExportDto.MyLibationTags)
|
||||
};
|
||||
var col = 0;
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var cell = row.CreateCell(col++);
|
||||
var name = ExportDto.GetName(c);
|
||||
cell.SetCellValue(name);
|
||||
cell.CellStyle = detailSubtotalCellStyle;
|
||||
}
|
||||
|
||||
var dateFormat = workbook.CreateDataFormat();
|
||||
var dateStyle = workbook.CreateCellStyle();
|
||||
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
|
||||
|
||||
rowIndex++;
|
||||
|
||||
// Add data rows
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
col = 0;
|
||||
|
||||
row = sheet.CreateRow(rowIndex);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||
|
||||
var dateAddedCell = row.CreateCell(col++);
|
||||
dateAddedCell.CellStyle = dateStyle;
|
||||
dateAddedCell.SetCellValue(dto.DateAdded);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||
row.CreateCell(col++).SetCellValue(dto.Publisher);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfUrl);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||
|
||||
col = createCell(row, col, dto.CommunityRatingOverall);
|
||||
col = createCell(row, col, dto.CommunityRatingPerformance);
|
||||
col = createCell(row, col, dto.CommunityRatingStory);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
||||
|
||||
var datePubCell = row.CreateCell(col++);
|
||||
datePubCell.CellStyle = dateStyle;
|
||||
if (dto.DatePublished.HasValue)
|
||||
datePubCell.SetCellValue(dto.DatePublished.Value);
|
||||
else
|
||||
datePubCell.SetCellValue("");
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
|
||||
|
||||
col = createCell(row, col, dto.MyRatingOverall);
|
||||
col = createCell(row, col, dto.MyRatingPerformance);
|
||||
col = createCell(row, col, dto.MyRatingStory);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||
workbook.Write(fileData);
|
||||
}
|
||||
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
|
||||
{
|
||||
if (nullableFloat.HasValue)
|
||||
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
|
||||
else
|
||||
row.CreateCell(col++).SetCellValue("");
|
||||
return col;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
ApplicationServices/SearchEngineCommands.cs
Normal file
56
ApplicationServices/SearchEngineCommands.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class SearchEngineCommands
|
||||
{
|
||||
public static void FullReIndex()
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
engine.CreateNewIndex();
|
||||
}
|
||||
|
||||
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
|
||||
e.Search(searchString)
|
||||
);
|
||||
|
||||
public static void UpdateBookTags(Book book) => performSearchEngineAction_safe(e =>
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
||||
);
|
||||
|
||||
public static void UpdateIsLiberated(Book book) => performSearchEngineAction_safe(e =>
|
||||
e.UpdateIsLiberated(book.AudibleProductId)
|
||||
);
|
||||
|
||||
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
try
|
||||
{
|
||||
action(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex();
|
||||
action(engine);
|
||||
}
|
||||
}
|
||||
|
||||
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> action)
|
||||
{
|
||||
var engine = new SearchEngine(DbContexts.GetContext());
|
||||
try
|
||||
{
|
||||
return action(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex();
|
||||
return action(engine);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using DtoImporterService;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class LibraryCommands
|
||||
{
|
||||
public static async Task<(int totalCount, int newCount)> ImportLibraryAsync(ILoginCallback callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
var audibleApiActions = new AudibleApiActions();
|
||||
var items = await audibleApiActions.GetAllLibraryItemsAsync(callback);
|
||||
var totalCount = items.Count;
|
||||
Serilog.Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
var libImporter = new LibraryImporter(context);
|
||||
var newCount = await Task.Run(() => libImporter.Import(items));
|
||||
context.SaveChanges();
|
||||
Serilog.Log.Logger.Information($"Import: New count {newCount}");
|
||||
|
||||
await Task.Run(() => SearchEngineCommands.FullReIndex());
|
||||
Serilog.Log.Logger.Information("FullReIndex: success");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error updating tags");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netcoreapp3.1;netstandard2.1</TargetFrameworks>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -12,13 +12,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.0">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.0">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -26,6 +26,9 @@ 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; }
|
||||
|
||||
@@ -63,7 +66,7 @@ namespace DataLayer
|
||||
int lengthInMinutes,
|
||||
IEnumerable<Contributor> authors,
|
||||
IEnumerable<Contributor> narrators,
|
||||
Category category)
|
||||
Category category, string localeName)
|
||||
{
|
||||
// validate
|
||||
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
|
||||
@@ -72,6 +75,7 @@ namespace DataLayer
|
||||
|
||||
// assign as soon as possible. stuff below relies on this
|
||||
AudibleProductId = productId;
|
||||
Locale = localeName;
|
||||
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title));
|
||||
|
||||
@@ -240,6 +244,10 @@ namespace DataLayer
|
||||
Category = category;
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
||||
// needed for v3 => v4 upgrade
|
||||
public void UpdateLocale(string localeName)
|
||||
=> Locale ??= localeName;
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
||||
}
|
||||
}
|
||||
33
DataLayer/EfClasses/LibraryBook.cs
Normal file
33
DataLayer/EfClasses/LibraryBook.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public class LibraryBook
|
||||
{
|
||||
internal int BookId { get; private set; }
|
||||
public Book Book { get; private set; }
|
||||
|
||||
public DateTime DateAdded { get; private set; }
|
||||
|
||||
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
|
||||
public string Account { get; private set; }
|
||||
|
||||
private LibraryBook() { }
|
||||
public LibraryBook(Book book, DateTime dateAdded, string account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
Book = book;
|
||||
DateAdded = dateAdded;
|
||||
Account = account;
|
||||
}
|
||||
|
||||
// needed for v3 => v4 upgrade
|
||||
public void UpdateAccount(string account)
|
||||
=> Account ??= account;
|
||||
|
||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,10 @@ namespace DataLayer
|
||||
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||
|
||||
// seeds go here. examples in scratch pad
|
||||
modelBuilder
|
||||
.Entity<Category>()
|
||||
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
||||
|
||||
modelBuilder
|
||||
.Entity<Category>()
|
||||
.HasData(Category.GetEmpty());
|
||||
modelBuilder
|
||||
.Entity<Contributor>()
|
||||
@@ -6,9 +6,6 @@ namespace DataLayer
|
||||
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
|
||||
{
|
||||
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder
|
||||
//.UseSqlServer
|
||||
.UseSqlite
|
||||
(connectionString);
|
||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
|
||||
}
|
||||
}
|
||||
338
DataLayer/Migrations/20200812152646_AddLocaleAndAccount.Designer.cs
generated
Normal file
338
DataLayer/Migrations/20200812152646_AddLocaleAndAccount.Designer.cs
generated
Normal file
@@ -0,0 +1,338 @@
|
||||
// <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("20200812152646_AddLocaleAndAccount")]
|
||||
partial class AddLocaleAndAccount
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "3.1.7");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("Library");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("Index")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace DataLayer.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "3.0.0");
|
||||
.HasAnnotation("ProductVersion", "3.1.7");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
@@ -40,6 +40,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -141,6 +144,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
public class LibraryBook
|
||||
{
|
||||
internal int BookId { get; private set; }
|
||||
public Book Book { get; private set; }
|
||||
|
||||
public DateTime DateAdded { get; private set; }
|
||||
|
||||
private LibraryBook() { }
|
||||
public LibraryBook(Book book, DateTime dateAdded)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
Book = book;
|
||||
DateAdded = dateAdded;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using System;
|
||||
using Dinah.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace _scratch_pad
|
||||
{
|
||||
////// to use this as a console, open properties and change from class library => console
|
||||
//// DON'T FORGET TO REVERT IT
|
||||
//public class Program
|
||||
//{
|
||||
// public static void Main(string[] args)
|
||||
// {
|
||||
// var user = new Student() { Name = "Dinah Cheshire" };
|
||||
// var udi = new UserDef { UserDefId = 1, TagsRaw = "my,tags" };
|
||||
|
||||
// using var context = new MyTestContextDesignTimeDbContextFactory().Create();
|
||||
// context.Add(user);
|
||||
// //context.Add(udi);
|
||||
// context.Update(udi);
|
||||
// context.SaveChanges();
|
||||
|
||||
// Console.WriteLine($"Student was saved in the database with id: {user.Id}");
|
||||
// }
|
||||
//}
|
||||
|
||||
public class MyTestContextDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase<MyTestContext>
|
||||
{
|
||||
protected override MyTestContext CreateNewInstance(DbContextOptions<MyTestContext> options) => new MyTestContext(options);
|
||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
|
||||
}
|
||||
|
||||
public class MyTestContext : DbContext
|
||||
{
|
||||
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
|
||||
public MyTestContext(DbContextOptions<MyTestContext> options) : base(options) { }
|
||||
|
||||
#region classes for OnModelCreating() seed example
|
||||
class Blog
|
||||
{
|
||||
public int BlogId { get; set; }
|
||||
public string Url { get; set; }
|
||||
public System.Collections.Generic.ICollection<Post> Posts { get; set; }
|
||||
}
|
||||
class Post
|
||||
{
|
||||
public int PostId { get; set; }
|
||||
public string Content { get; set; }
|
||||
public string Title { get; set; }
|
||||
public int BlogId { get; set; }
|
||||
public Blog Blog { get; set; }
|
||||
public Name AuthorName { get; set; }
|
||||
}
|
||||
class Name
|
||||
{
|
||||
public string First { get; set; }
|
||||
public string Last { get; set; }
|
||||
}
|
||||
#endregion
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// config
|
||||
modelBuilder.Entity<Blog>(entity => entity.Property(e => e.Url).IsRequired());
|
||||
modelBuilder.Entity<Order>().OwnsOne(p => p.OrderDetails, cb =>
|
||||
{
|
||||
cb.OwnsOne(c => c.BillingAddress);
|
||||
cb.OwnsOne(c => c.ShippingAddress);
|
||||
});
|
||||
modelBuilder.Entity<Post>(entity =>
|
||||
entity
|
||||
.HasOne(d => d.Blog)
|
||||
.WithMany(p => p.Posts)
|
||||
.HasForeignKey("BlogId"));
|
||||
|
||||
// BlogSeed
|
||||
modelBuilder.Entity<Blog>().HasData(new Blog { BlogId = 1, Url = "http://sample.com" });
|
||||
|
||||
// PostSeed
|
||||
modelBuilder.Entity<Post>().HasData(new Post() { BlogId = 1, PostId = 1, Title = "First post", Content = "Test 1" });
|
||||
|
||||
// AnonymousPostSeed
|
||||
modelBuilder.Entity<Post>().HasData(new { BlogId = 1, PostId = 2, Title = "Second post", Content = "Test 2" });
|
||||
|
||||
// OwnedTypeSeed
|
||||
modelBuilder.Entity<Post>().OwnsOne(p => p.AuthorName).HasData(
|
||||
new { PostId = 1, First = "Andriy", Last = "Svyryd" },
|
||||
new { PostId = 2, First = "Diego", Last = "Vega" });
|
||||
}
|
||||
|
||||
public DbSet<Student> Students { get; set; }
|
||||
public DbSet<UserDef> UserDefs { get; set; }
|
||||
public DbSet<Order> Orders { get; set; }
|
||||
}
|
||||
|
||||
public class Student
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
public class UserDef
|
||||
{
|
||||
public int UserDefId { get; set; }
|
||||
public string TagsRaw { get; set; }
|
||||
}
|
||||
|
||||
public class Order
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public OrderDetails OrderDetails { get; set; }
|
||||
}
|
||||
public class OrderDetails
|
||||
{
|
||||
public StreetAddress BillingAddress { get; set; }
|
||||
public StreetAddress ShippingAddress { get; set; }
|
||||
}
|
||||
public class StreetAddress
|
||||
{
|
||||
public string Street { get; set; }
|
||||
public string City { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
HOW TO CREATE: EF CORE PROJECT
|
||||
FOR QUICK MIGRATION INSTRUCTIONS:
|
||||
_DB_NOTES.txt
|
||||
|
||||
|
||||
HOW TO CREATE: EF CORE PROJECT
|
||||
==============================
|
||||
example is for sqlite but the same works with MsSql
|
||||
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"LibationContext_sqlserver": "Server=(LocalDb)\\MSSQLLocalDB;Database=DataLayer.LibationContext;Integrated Security=true;",
|
||||
|
||||
"// 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"
|
||||
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;"
|
||||
}
|
||||
}
|
||||
@@ -11,23 +11,23 @@ namespace DtoImporterService
|
||||
{
|
||||
public BookImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new BookValidator().Validate(items);
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new BookValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items)
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// pre-req.s
|
||||
new ContributorImporter(DbContext).Import(items);
|
||||
new SeriesImporter(DbContext).Import(items);
|
||||
new CategoryImporter(DbContext).Import(items);
|
||||
new ContributorImporter(DbContext).Import(importItems);
|
||||
new SeriesImporter(DbContext).Import(importItems);
|
||||
new CategoryImporter(DbContext).Import(importItems);
|
||||
|
||||
// get distinct
|
||||
var productIds = items.Select(i => i.ProductId).ToList();
|
||||
var productIds = importItems.Select(i => i.DtoItem.ProductId).ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_books(productIds);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertBooks(items);
|
||||
var qtyNew = upsertBooks(importItems);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ namespace DtoImporterService
|
||||
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
}
|
||||
|
||||
private int upsertBooks(IEnumerable<Item> items)
|
||||
private int upsertBooks(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
var qtyNew = 0;
|
||||
|
||||
foreach (var item in items)
|
||||
foreach (var item in importItems)
|
||||
{
|
||||
var book = DbContext.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.ProductId);
|
||||
var book = DbContext.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
|
||||
if (book is null)
|
||||
{
|
||||
book = createNewBook(item);
|
||||
@@ -63,8 +63,10 @@ namespace DtoImporterService
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private Book createNewBook(Item item)
|
||||
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 } };
|
||||
@@ -86,8 +88,16 @@ namespace DtoImporterService
|
||||
.ToList();
|
||||
|
||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||
// absence of categories is very rare, but possible
|
||||
var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
|
||||
// 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(
|
||||
@@ -97,7 +107,8 @@ namespace DtoImporterService
|
||||
item.LengthInMinutes,
|
||||
authors,
|
||||
narrators,
|
||||
category)
|
||||
category,
|
||||
importItem.LocaleName)
|
||||
).Entity;
|
||||
|
||||
var publisherName = item.Publisher;
|
||||
@@ -115,12 +126,17 @@ namespace DtoImporterService
|
||||
return book;
|
||||
}
|
||||
|
||||
private void updateBook(Item item, Book 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);
|
||||
|
||||
@@ -11,18 +11,24 @@ namespace DtoImporterService
|
||||
{
|
||||
public CategoryImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new CategoryValidator().Validate(items);
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new CategoryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items)
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var categoryIds = items.GetCategoriesDistinct().Select(c => c.CategoryId).ToList();
|
||||
var categoryIds = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetCategoriesDistinct()
|
||||
.Select(c => c.CategoryId).ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_categories(categoryIds);
|
||||
|
||||
// upsert
|
||||
var categoryPairs = items.GetCategoryPairsDistinct().ToList();
|
||||
var categoryPairs = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetCategoryPairsDistinct()
|
||||
.ToList();
|
||||
var qtyNew = upsertCategories(categoryPairs);
|
||||
return qtyNew;
|
||||
}
|
||||
@@ -51,7 +57,7 @@ namespace DtoImporterService
|
||||
{
|
||||
for (var i = 0; i < pair.Length; i++)
|
||||
{
|
||||
// not yet supported: depth beyond 0 and 1
|
||||
// CATEGORY HACK: not yet supported: depth beyond 0 and 1
|
||||
if (i > 1)
|
||||
break;
|
||||
|
||||
@@ -11,14 +11,23 @@ namespace DtoImporterService
|
||||
{
|
||||
public ContributorImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new ContributorValidator().Validate(items);
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new ContributorValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items)
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var authors = items.GetAuthorsDistinct().ToList();
|
||||
var narrators = items.GetNarratorsDistinct().ToList();
|
||||
var publishers = items.GetPublishersDistinct().ToList();
|
||||
var authors = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetAuthorsDistinct()
|
||||
.ToList();
|
||||
var narrators = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetNarratorsDistinct()
|
||||
.ToList();
|
||||
var publishers = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetPublishersDistinct()
|
||||
.ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
var allNames = publishers
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
@@ -11,7 +11,7 @@ namespace DtoImporterService
|
||||
{
|
||||
protected LibationContext DbContext { get; }
|
||||
|
||||
public ImporterBase(LibationContext context)
|
||||
protected ImporterBase(LibationContext context)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||
DbContext = context;
|
||||
@@ -50,8 +50,8 @@ namespace DtoImporterService
|
||||
public abstract IEnumerable<Exception> Validate(T param);
|
||||
}
|
||||
|
||||
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<Item>>
|
||||
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<ImportItem>>
|
||||
{
|
||||
public ItemsImporterBase(LibationContext context) : base(context) { }
|
||||
protected ItemsImporterBase(LibationContext context) : base(context) { }
|
||||
}
|
||||
}
|
||||
62
DtoImporterService/LibraryImporter.cs
Normal file
62
DtoImporterService/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,15 @@ namespace DtoImporterService
|
||||
{
|
||||
public SeriesImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new SeriesValidator().Validate(items);
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new SeriesValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items)
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// get distinct
|
||||
var series = items.GetSeriesDistinct().ToList();
|
||||
var series = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetSeriesDistinct()
|
||||
.ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_series(series);
|
||||
@@ -1,41 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleApiDTOs;
|
||||
using DataLayer;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class LibraryImporter : ItemsImporterBase
|
||||
{
|
||||
public LibraryImporter(LibationContext context) : base(context) { }
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new LibraryValidator().Validate(items);
|
||||
|
||||
protected override int DoImport(IEnumerable<Item> items)
|
||||
{
|
||||
new BookImporter(DbContext).Import(items);
|
||||
|
||||
var qtyNew = upsertLibraryBooks(items);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private int upsertLibraryBooks(IEnumerable<Item> items)
|
||||
{
|
||||
var currentLibraryProductIds = DbContext.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(
|
||||
DbContext.Books.Local.Single(b => b.AudibleProductId == newItem.ProductId),
|
||||
newItem.DateAdded);
|
||||
DbContext.Library.Add(libraryBook);
|
||||
}
|
||||
|
||||
var qtyNew = newItems.Count;
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AaxDecrypter;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
using InternalUtilities;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
@@ -55,22 +57,37 @@ namespace FileLiberator
|
||||
if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId))
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
var proposedOutputFile = Path.Combine(AudibleFileStorage.DecryptInProgress, $"[{libraryBook.Book.AudibleProductId}].m4b");
|
||||
var outputAudioFilename = await aaxToM4bConverterDecrypt(proposedOutputFile, aaxFilename);
|
||||
var api = await AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
var chapters = await downloadChapterNames(libraryBook, api);
|
||||
|
||||
var outputAudioFilename = await aaxToM4bConverterDecrypt(aaxFilename, libraryBook, chapters, api);
|
||||
|
||||
// decrypt failed
|
||||
if (outputAudioFilename == null)
|
||||
return new StatusHandler { "Decrypt failed" };
|
||||
|
||||
moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
|
||||
var destinationDir = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
|
||||
|
||||
Dinah.Core.IO.FileExt.SafeDelete(aaxFilename);
|
||||
var config = Configuration.Instance;
|
||||
if (config.RetainAaxFiles)
|
||||
{
|
||||
var newAaxFilename = FileUtility.GetValidFilename(
|
||||
destinationDir,
|
||||
Path.GetFileNameWithoutExtension(aaxFilename),
|
||||
"aax");
|
||||
File.Move(aaxFilename, newAaxFilename);
|
||||
}
|
||||
else
|
||||
{
|
||||
Dinah.Core.IO.FileExt.SafeDelete(aaxFilename);
|
||||
}
|
||||
|
||||
var statusHandler = new StatusHandler();
|
||||
var finalAudioExists = AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
|
||||
if (!finalAudioExists)
|
||||
statusHandler.AddError("Cannot find final audio file after decryption");
|
||||
return statusHandler;
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -78,13 +95,35 @@ namespace FileLiberator
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> aaxToM4bConverterDecrypt(string proposedOutputFile, string aaxFilename)
|
||||
private static async Task<Chapters> downloadChapterNames(LibraryBook libraryBook, Api api)
|
||||
{
|
||||
try
|
||||
{
|
||||
var contentMetadata = await api.GetLibraryBookMetadataAsync(libraryBook.Book.AudibleProductId);
|
||||
if (contentMetadata?.ChapterInfo is null)
|
||||
return null;
|
||||
|
||||
return new DownloadedChapters(contentMetadata.ChapterInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> aaxToM4bConverterDecrypt(string aaxFilename, LibraryBook libraryBook, Chapters chapters, Api api)
|
||||
{
|
||||
DecryptBegin?.Invoke(this, $"Begin decrypting {aaxFilename}");
|
||||
|
||||
try
|
||||
{
|
||||
var converter = await AaxToM4bConverter.CreateAsync(aaxFilename, Configuration.Instance.DecryptKey);
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
var account = persister
|
||||
.AccountsSettings
|
||||
.GetAccount(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
var converter = await AaxToM4bConverter.CreateAsync(aaxFilename, account.DecryptKey, api.GetActivationBytesAsync, chapters);
|
||||
converter.AppName = "Libation";
|
||||
|
||||
TitleDiscovered?.Invoke(this, converter.tags.title);
|
||||
@@ -92,7 +131,8 @@ namespace FileLiberator
|
||||
NarratorsDiscovered?.Invoke(this, converter.tags.narrator);
|
||||
CoverImageFilepathDiscovered?.Invoke(this, converter.coverBytes);
|
||||
|
||||
// override default which was set in CreateAsync
|
||||
// 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);
|
||||
|
||||
@@ -103,7 +143,7 @@ namespace FileLiberator
|
||||
if (!success)
|
||||
return null;
|
||||
|
||||
Configuration.Instance.DecryptKey = converter.decryptKey;
|
||||
account.DecryptKey = converter.decryptKey;
|
||||
|
||||
return converter.outputFileName;
|
||||
}
|
||||
@@ -113,41 +153,37 @@ namespace FileLiberator
|
||||
}
|
||||
}
|
||||
|
||||
private static void moveFilesToBooksDir(Book product, string outputAudioFilename)
|
||||
private static string moveFilesToBooksDir(Book product, string outputAudioFilename)
|
||||
{
|
||||
// create final directory. move each file into it. MOVE AUDIO FILE LAST
|
||||
// new dir: safetitle_limit50char + " [" + productId + "]"
|
||||
|
||||
var destinationDir = getDestDir(product);
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestDir(product.Title, product.AudibleProductId);
|
||||
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);
|
||||
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext
|
||||
var audioFileName = FileUtility.GetValidFilename(destinationDir, product.Title, musicFileExt, product.AudibleProductId);
|
||||
|
||||
File.Move(f.FullName, dest);
|
||||
foreach (var f in sortedFiles)
|
||||
{
|
||||
var dest
|
||||
= AudibleFileStorage.Audio.IsFileTypeMatch(f)
|
||||
? audioFileName
|
||||
// non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext
|
||||
: FileUtility.GetValidFilename(destinationDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt);
|
||||
|
||||
if (Path.GetExtension(dest).Trim('.').ToLower() == "cue")
|
||||
Cue.UpdateFileName(f, audioFileName);
|
||||
|
||||
File.Move(f.FullName, dest);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return destinationDir;
|
||||
}
|
||||
|
||||
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
|
||||
{
|
||||
@@ -1,10 +1,12 @@
|
||||
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
|
||||
{
|
||||
@@ -41,7 +43,9 @@ namespace FileLiberator
|
||||
|
||||
private async Task<string> downloadBookAsync(LibraryBook libraryBook, string tempAaxFilename)
|
||||
{
|
||||
var api = await AudibleApi.EzApiCreator.GetApiAsync(AudibleApiStorage.IdentityTokensFile);
|
||||
validate(libraryBook);
|
||||
|
||||
var api = await GetApiAsync(libraryBook);
|
||||
|
||||
var actualFilePath = await PerformDownloadAsync(
|
||||
tempAaxFilename,
|
||||
@@ -63,10 +67,12 @@ namespace FileLiberator
|
||||
: "Error downloading file";
|
||||
|
||||
var ex = new Exception(exMsg);
|
||||
Serilog.Log.Error(ex, "Download error {@DebugInfo}", new
|
||||
Serilog.Log.Logger.Error(ex, "Download error {@DebugInfo}", new
|
||||
{
|
||||
libraryBook.Book.Title,
|
||||
libraryBook.Book.AudibleProductId,
|
||||
libraryBook.Book.Locale,
|
||||
Account = libraryBook.Account?.ToMask() ?? "[empty]",
|
||||
tempAaxFilename,
|
||||
actualFilePath,
|
||||
length,
|
||||
@@ -75,6 +81,28 @@ namespace FileLiberator
|
||||
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(
|
||||
@@ -31,17 +31,24 @@ namespace FileLiberator
|
||||
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;
|
||||
var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId));
|
||||
var file = getdownloadUrl(libraryBook);
|
||||
|
||||
return Path.Combine(destinationDir, Path.GetFileName(getdownloadUrl(libraryBook)));
|
||||
if (existingPath != null)
|
||||
return Path.Combine(existingPath, Path.GetFileName(file));
|
||||
|
||||
var full = FileUtility.GetValidFilename(
|
||||
AudibleFileStorage.PDF.StorageDirectory,
|
||||
libraryBook.Book.Title,
|
||||
Path.GetExtension(file),
|
||||
libraryBook.Book.AudibleProductId);
|
||||
return full;
|
||||
}
|
||||
|
||||
private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
{
|
||||
var downloadUrl = getdownloadUrl(libraryBook);
|
||||
var api = await GetApiAsync(libraryBook);
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
var client = new HttpClient();
|
||||
var actualDownloadedFilePath = await PerformDownloadAsync(
|
||||
@@ -38,6 +38,9 @@ namespace FileLiberator
|
||||
}
|
||||
}
|
||||
|
||||
protected static Task<AudibleApi.Api> GetApiAsync(LibraryBook libraryBook)
|
||||
=> InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
protected async Task<string> PerformDownloadAsync(string proposedDownloadFilePath, Func<Progress<DownloadProgress>, Task<string>> func)
|
||||
{
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
22
FileLiberator/DownloadedChapters.cs
Normal file
22
FileLiberator/DownloadedChapters.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AaxDecrypter;
|
||||
using AudibleApiDTOs;
|
||||
using Dinah.Core.Diagnostics;
|
||||
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadedChapters : Chapters
|
||||
{
|
||||
public DownloadedChapters(ChapterInfo chapterInfo)
|
||||
{
|
||||
AddChapters(chapterInfo.Chapters
|
||||
.Select(c => new AaxDecrypter.Chapter(c.StartOffsetMs / 1000d, (c.StartOffsetMs + c.LengthMs) / 1000d, c.Title)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
66
FileLiberator/IProcessableExt.cs
Normal file
66
FileLiberator/IProcessableExt.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class IProcessableExt
|
||||
{
|
||||
//
|
||||
// DO NOT USE ConfigureAwait(false) WITH ProcessAsync() unless ensuring ProcessAsync() implementation is cross-thread compatible
|
||||
// ProcessAsync() often does a lot with forms in the UI context
|
||||
//
|
||||
|
||||
|
||||
// when used in foreach: stateful. deferred execution
|
||||
public static IEnumerable<LibraryBook> GetValidLibraryBooks(this IProcessable processable)
|
||||
=> DbContexts.GetContext()
|
||||
.GetLibrary_Flat_NoTracking()
|
||||
.Where(libraryBook => processable.Validate(libraryBook));
|
||||
|
||||
public static LibraryBook GetSingleLibraryBook(string productId)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryBook = context
|
||||
.Library
|
||||
.GetLibrary()
|
||||
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
|
||||
return libraryBook;
|
||||
}
|
||||
|
||||
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, LibraryBook libraryBook)
|
||||
{
|
||||
if (!processable.Validate(libraryBook))
|
||||
return new StatusHandler { "Validation failed" };
|
||||
|
||||
return await processable.ProcessBookAsync_NoValidation(libraryBook);
|
||||
}
|
||||
|
||||
public static async Task<StatusHandler> ProcessBookAsync_NoValidation(this IProcessable processable, LibraryBook libraryBook)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin " + nameof(ProcessBookAsync_NoValidation) + " {@DebugInfo}", new
|
||||
{
|
||||
libraryBook.Book.Title,
|
||||
libraryBook.Book.AudibleProductId,
|
||||
libraryBook.Book.Locale,
|
||||
Account = libraryBook.Account?.ToMask() ?? "[empty]"
|
||||
});
|
||||
|
||||
var status
|
||||
= (await processable.ProcessAsync(libraryBook))
|
||||
?? new StatusHandler { "Processable should never return a null status" };
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public static async Task<StatusHandler> TryProcessAsync(this IProcessable processable, LibraryBook libraryBook)
|
||||
=> processable.Validate(libraryBook)
|
||||
? await processable.ProcessAsync(libraryBook)
|
||||
: new StatusHandler();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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)
|
||||
{
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
179
FileManager/AudibleFileStorage.cs
Normal file
179
FileManager/AudibleFileStorage.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
// could add images here, but for now images are stored in a well-known location
|
||||
public enum FileType { Unknown, Audio, AAX, PDF }
|
||||
|
||||
/// <summary>
|
||||
/// Files are large. File contents are never read by app.
|
||||
/// Paths are varied.
|
||||
/// Files are written during download/decrypt/backup/liberate.
|
||||
/// Paths are read at app launch and during download/decrypt/backup/liberate.
|
||||
/// Many files are often looked up at once
|
||||
/// </summary>
|
||||
public abstract class AudibleFileStorage : Enumeration<AudibleFileStorage>
|
||||
{
|
||||
public abstract string[] Extensions { get; }
|
||||
public abstract string StorageDirectory { get; }
|
||||
|
||||
#region static
|
||||
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
|
||||
public static AudibleFileStorage AAX { get; } = new AaxFileStorage();
|
||||
public static AudibleFileStorage PDF { get; } = new PdfFileStorage();
|
||||
|
||||
public static string DownloadsInProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!Configuration.Instance.DownloadsInProgressEnum.In("WinTemp", "LibationFiles"))
|
||||
Configuration.Instance.DownloadsInProgressEnum = "WinTemp";
|
||||
var AaxRootDir
|
||||
= Configuration.Instance.DownloadsInProgressEnum == "WinTemp"
|
||||
? Configuration.WinTemp
|
||||
: Configuration.Instance.LibationFiles;
|
||||
|
||||
return Directory.CreateDirectory(Path.Combine(AaxRootDir, "DownloadsInProgress")).FullName;
|
||||
}
|
||||
}
|
||||
|
||||
public static string DecryptInProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!Configuration.Instance.DecryptInProgressEnum.In("WinTemp", "LibationFiles"))
|
||||
Configuration.Instance.DecryptInProgressEnum = "WinTemp";
|
||||
|
||||
var M4bRootDir
|
||||
= Configuration.Instance.DecryptInProgressEnum == "WinTemp"
|
||||
? Configuration.WinTemp
|
||||
: Configuration.Instance.LibationFiles;
|
||||
|
||||
return Directory.CreateDirectory(Path.Combine(M4bRootDir, "DecryptInProgress")).FullName;
|
||||
}
|
||||
}
|
||||
|
||||
// not customizable. don't move to config
|
||||
public static string DownloadsFinal => new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("DownloadsFinal").FullName;
|
||||
|
||||
public static string BooksDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
|
||||
Configuration.Instance.Books = Path.Combine(Configuration.Instance.LibationFiles, "Books");
|
||||
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region instance
|
||||
public FileType FileType => (FileType)Value;
|
||||
|
||||
private IEnumerable<string> extensions_noDots { get; }
|
||||
private string extAggr { get; }
|
||||
|
||||
protected AudibleFileStorage(FileType fileType) : base((int)fileType, fileType.ToString())
|
||||
{
|
||||
extensions_noDots = Extensions.Select(ext => ext.Trim('.')).ToList();
|
||||
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Example for full books:
|
||||
/// Search recursively in _books directory. Full book exists if either are true
|
||||
/// - a directory name has the product id and an audio file is immediately inside
|
||||
/// - any audio filename contains the product id
|
||||
/// </summary>
|
||||
public bool Exists(string productId) => GetPath(productId) != null;
|
||||
|
||||
public string GetPath(string productId)
|
||||
{
|
||||
var cachedFile = FilePathCache.GetPath(productId, FileType);
|
||||
if (cachedFile != null)
|
||||
return cachedFile;
|
||||
|
||||
var firstOrNull =
|
||||
Directory
|
||||
.EnumerateFiles(StorageDirectory, "*.*", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(s => Regex.IsMatch(s, $@"{productId}.*?\.({extAggr})$", RegexOptions.IgnoreCase));
|
||||
|
||||
if (firstOrNull is null)
|
||||
return null;
|
||||
|
||||
FilePathCache.Upsert(productId, FileType, firstOrNull);
|
||||
return firstOrNull;
|
||||
}
|
||||
|
||||
public string GetDestDir(string title, string asin)
|
||||
{
|
||||
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
|
||||
var underscoreIndex = title.IndexOf(':');
|
||||
var titleDir
|
||||
= underscoreIndex < 4
|
||||
? title
|
||||
: title.Substring(0, underscoreIndex);
|
||||
var finalDir = FileUtility.GetValidFilename(StorageDirectory, titleDir, null, asin);
|
||||
return finalDir;
|
||||
}
|
||||
|
||||
public bool IsFileTypeMatch(FileInfo fileInfo)
|
||||
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class AudioFileStorage : AudibleFileStorage
|
||||
{
|
||||
public const string SKIP_FILE_EXT = "libhack";
|
||||
|
||||
public override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac", SKIP_FILE_EXT };
|
||||
|
||||
// we always want to use the latest config value, therefore
|
||||
// - DO use 'get' arrow "=>"
|
||||
// - do NOT use assign "="
|
||||
public override string StorageDirectory => BooksDirectory;
|
||||
|
||||
public AudioFileStorage() : base(FileType.Audio) { }
|
||||
|
||||
public string CreateSkipFile(string title, string asin, string contents = null)
|
||||
{
|
||||
var destinationDir = GetDestDir(title, asin);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
var path = FileUtility.GetValidFilename(destinationDir, title, SKIP_FILE_EXT, asin);
|
||||
File.WriteAllText(path, contents ?? string.Empty);
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public class AaxFileStorage : AudibleFileStorage
|
||||
{
|
||||
public override string[] Extensions { get; } = new[] { "aax" };
|
||||
|
||||
// we always want to use the latest config value, therefore
|
||||
// - DO use 'get' arrow "=>"
|
||||
// - do NOT use assign "="
|
||||
public override string StorageDirectory => DownloadsFinal;
|
||||
|
||||
public AaxFileStorage() : base(FileType.AAX) { }
|
||||
}
|
||||
|
||||
public class PdfFileStorage : AudibleFileStorage
|
||||
{
|
||||
public override string[] Extensions { get; } = new[] { "pdf", "zip" };
|
||||
|
||||
// we always want to use the latest config value, therefore
|
||||
// - DO use 'get' arrow "=>"
|
||||
// - do NOT use assign "="
|
||||
public override string StorageDirectory => BooksDirectory;
|
||||
|
||||
public PdfFileStorage() : base(FileType.PDF) { }
|
||||
}
|
||||
}
|
||||
@@ -37,18 +37,11 @@ namespace FileManager
|
||||
|
||||
public bool FilesExist
|
||||
=> File.Exists(APPSETTINGS_JSON)
|
||||
&& File.Exists(SettingsJsonPath)
|
||||
&& File.Exists(SettingsFilePath)
|
||||
&& Directory.Exists(LibationFiles)
|
||||
&& Directory.Exists(Books);
|
||||
|
||||
public string SettingsJsonPath => Path.Combine(LibationFiles, "Settings.json");
|
||||
|
||||
[Description("Your user-specific key used to decrypt your audible files (*.aax) into audio files you can use anywhere (*.m4b). Leave alone in most cases")]
|
||||
public string DecryptKey
|
||||
{
|
||||
get => persistentDictionary.GetString(nameof(DecryptKey));
|
||||
set => persistentDictionary.Set(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
|
||||
@@ -90,10 +83,11 @@ namespace FileManager
|
||||
set => persistentDictionary.Set(nameof(DecryptInProgressEnum), value);
|
||||
}
|
||||
|
||||
public string LocaleCountryCode
|
||||
[Description("Retain .aax files after decrypting?")]
|
||||
public bool RetainAaxFiles
|
||||
{
|
||||
get => persistentDictionary.GetString(nameof(LocaleCountryCode));
|
||||
set => persistentDictionary.Set(nameof(LocaleCountryCode), value);
|
||||
get => persistentDictionary.Get<bool>(nameof(RetainAaxFiles));
|
||||
set => persistentDictionary.Set(nameof(RetainAaxFiles), 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
|
||||
@@ -115,11 +109,11 @@ namespace FileManager
|
||||
if (wellKnownPaths.ContainsKey(value))
|
||||
value = wellKnownPaths[value];
|
||||
|
||||
// must write here before SettingsJsonPath in next step reads cache
|
||||
// must write here before SettingsFilePath in next step reads cache
|
||||
libationFilesPathCache = value;
|
||||
|
||||
// load json values into memory. create if not exists
|
||||
persistentDictionary = new PersistentDictionary(SettingsJsonPath);
|
||||
persistentDictionary = new PersistentDictionary(SettingsFilePath);
|
||||
|
||||
return libationFilesPathCache;
|
||||
}
|
||||
@@ -171,7 +165,7 @@ namespace FileManager
|
||||
// if moving from default, delete old settings file and dir (if empty)
|
||||
if (LibationFiles.EqualsInsensitive(AppDir))
|
||||
{
|
||||
File.Delete(SettingsJsonPath);
|
||||
File.Delete(SettingsFilePath);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
if (!Directory.EnumerateDirectories(AppDir).Any() && !Directory.EnumerateFiles(AppDir).Any())
|
||||
Directory.Delete(AppDir);
|
||||
@@ -1,11 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Polly" Version="7.2.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.33" />
|
||||
<PackageReference Include="Octokit" Version="0.50.0" />
|
||||
<PackageReference Include="Polly" Version="7.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -40,8 +40,13 @@ namespace FileManager
|
||||
return stringCache[propertyName];
|
||||
}
|
||||
|
||||
public T Get<T>(string propertyName) where T : class
|
||||
=> GetObject(propertyName) is T obj ? obj : default;
|
||||
public T Get<T>(string propertyName)
|
||||
{
|
||||
var o = GetObject(propertyName);
|
||||
if (o is null) return default;
|
||||
if (o is JToken jt) return jt.Value<T>();
|
||||
return (T)o;
|
||||
}
|
||||
|
||||
public object GetObject(string propertyName)
|
||||
{
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user