Compare commits

...

40 Commits

Author SHA1 Message Date
Robert McRackan
ff20d777a6 Remove local retrieval of activation bytes. Use audible api. Dramatically reduces program's size 2021-06-17 17:02:37 -04:00
Robert McRackan
270e2531e2 update version 2021-06-17 14:49:05 -04:00
Robert McRackan
959a1aebe9 Enough already. I'm obviously not incentivized/shamed into writing unit tests for things in UNTESTED dir.s. It's just making a mess of the file tree 2021-06-17 14:21:15 -04:00
rmcrackan
2217fe6948 Merge pull request #26 from Mbucari/master
Added support for downloaded chapter titles.
2021-06-17 13:37:15 -04:00
Robert McRackan
96abf56a87 remove unused PublishSingleFile directive 2021-06-17 11:45:44 -04:00
Michael Bucari-Tovo
5731a8f693 Added support for downloaded chapters. 2021-06-16 17:04:42 -06:00
Michael Bucari-Tovo
ff722b6a52 Added support for chapter titles and refactored. 2021-06-16 16:58:01 -06:00
Michael Bucari-Tovo
9271114408 Allow caller to specify alternate chapters source. 2021-06-16 16:27:23 -06:00
Michael Bucari-Tovo
ebfdd44142 Abstracted Chapters class, adding chapter titles and end times. Updated references. 2021-06-16 16:05:06 -06:00
Robert McRackan
6ed4eb34bd update references. move db scratch pad into test 2021-05-06 11:53:40 -04:00
Robert McRackan
9372571370 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-04-12 14:52:43 -04:00
Robert McRackan
215c539920 Bug fix: first line in cue file was incorrectly formatted 2021-04-12 14:52:19 -04:00
rmcrackan
7c7da2024e Update README.md
Add paypal link
2021-04-08 13:56:39 -04:00
Robert McRackan
f55a3ca008 Search engine bug fix and unit tests 2021-04-02 11:27:16 -04:00
Robert McRackan
726b36de4d * bug fix: when user creates a tag which is also a reserved bool word (eg: israted), searching for this tag breaks the search
* add unit tests for search engine
2021-04-01 15:45:19 -04:00
Robert McRackan
abd00ff1df * search engine: refactoring and improved logging
* bug fix: after book is liberated, filter should immediately honor new "is liberated" status
2021-04-01 12:44:16 -04:00
Robert McRackan
7b966f6962 Add IsLiberated option to search engine. Reindex search after download and decrypt 2021-03-31 21:50:32 -04:00
Robert McRackan
c0e955d5ef Better logging and error handling during login 2021-03-15 13:30:33 -04:00
Robert McRackan
bc6f53c8ea bug fix around latest skip-bad-book feature 2020-12-23 16:07:47 -05:00
Robert McRackan
cefab86ce1 bug fixes around new skip-bad-book feature 2020-12-23 14:05:18 -05:00
Robert McRackan
249a2f3b59 bug fix: libhack files: directory not found 2020-12-22 15:54:46 -05:00
Robert McRackan
0e9f2c7681 Truncate too-long error message 2020-12-22 11:38:03 -05:00
Robert McRackan
d25c32ff45 When there's a problem downloading a book, you get the option to skip the file temporarily or permanently. This can be useful with extremely old audible titles where the modern download may no longer be supported 2020-12-21 16:25:42 -05:00
Robert McRackan
642a500f87 "Locale" typo. Make user msg more clear 2020-12-16 12:57:26 -05:00
Robert McRackan
0e2469db64 If no codec during download, retry with all library flags enabled 2020-12-16 11:19:12 -05:00
Robert McRackan
9aa4ef70af increase version 2020-12-14 15:56:30 -05:00
Robert McRackan
1812fc2c7c - Increase account privacy in logs
- Improve book download retry
2020-12-14 15:42:27 -05:00
Robert McRackan
f9849abb7b Better error logging when codecs are not known 2020-12-08 16:16:16 -05:00
Robert McRackan
9cfe8ee6ca bug fix: null ref exception 2020-12-03 10:58:45 -05:00
Robert McRackan
44e2cef18c New in v4.1.0
- upgrade all to .NET5
- bug fix: when codec doesn't appear in prioritized list, just get the 1st available
- add more account privacy in logs
2020-12-02 15:23:08 -05:00
Robert McRackan
4dc29affc3 Incl. audible api bug fix. Also add more account privacy in logs 2020-12-02 15:16:48 -05:00
Robert McRackan
2df38706f7 upgrade to .NET5 2020-11-18 09:32:15 -05:00
Robert McRackan
f30e9dae6f bug fix: include new ApprovalNeeded in enumeration singletons 2020-10-08 22:20:26 -04:00
Robert McRackan
50843e5102 add ApprovalNeeded page in login 2020-10-08 16:56:14 -04:00
Robert McRackan
a13b00d520 - better logging for LoginFailedException
- upgrade nuget pkg.s
2020-10-08 11:48:48 -04:00
Robert McRackan
b5ebe3db23 increm version 2020-10-07 17:48:30 -04:00
Robert McRackan
40f3e4503b Version # 2020-10-02 16:22:42 -04:00
Robert McRackan
0d93243b66 Obscure account names in logs 2020-10-02 16:10:35 -04:00
Robert McRackan
59c3845d21 Standardize logging 2020-10-02 09:35:58 -04:00
Robert McRackan
a3ee3c2881 v4.0.9 2020-10-01 12:35:16 -04:00
181 changed files with 1580 additions and 1465 deletions

View 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));
}
}
}
}

View File

@@ -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>

View File

@@ -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)
{
@@ -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;
}
}

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

27
AaxDecrypter/Chapter.cs Normal file
View 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
View 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
View 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
View 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;
}
}
}

View File

@@ -1,52 +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 ffprobeStderr = info.RunHidden().Error;
// 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;
}
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="NPOI" Version="2.5.1" />
<PackageReference Include="CsvHelper" Version="27.0.2" />
<PackageReference Include="NPOI" Version="2.5.3" />
</ItemGroup>
<ItemGroup>

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using DataLayer;
using Dinah.Core;
using DtoImporterService;
using InternalUtilities;
using Serilog;
@@ -32,6 +33,23 @@ namespace ApplicationServices
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");
@@ -61,18 +79,15 @@ namespace ApplicationServices
private static async Task<List<ImportItem>> scanAccountAsync(Api api, Account account)
{
Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(account, nameof(account));
var localeName = account.Locale?.Name;
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
account.AccountName,
account.AccountId,
LocaleName = localeName,
Account = account?.MaskedLogEntry ?? "[null]"
});
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api);
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = localeName }).ToList();
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
}
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)

View 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);
}
}
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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.7">
<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.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.7">
<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>

View File

@@ -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>()

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -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;"
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AaxDecrypter;
using AudibleApi;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
@@ -56,7 +57,11 @@ namespace FileLiberator
if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId))
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
var outputAudioFilename = await aaxToM4bConverterDecrypt(aaxFilename, libraryBook);
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)
@@ -78,11 +83,11 @@ namespace FileLiberator
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
{
@@ -90,7 +95,23 @@ namespace FileLiberator
}
}
private async Task<string> aaxToM4bConverterDecrypt(string aaxFilename, LibraryBook libraryBook)
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}");
@@ -102,7 +123,7 @@ namespace FileLiberator
.AccountsSettings
.GetAccount(libraryBook.Account, libraryBook.Book.Locale);
var converter = await AaxToM4bConverter.CreateAsync(aaxFilename, account.DecryptKey);
var converter = await AaxToM4bConverter.CreateAsync(aaxFilename, account.DecryptKey, api.GetActivationBytesAsync, chapters);
converter.AppName = "Libation";
TitleDiscovered?.Invoke(this, converter.tags.title);
@@ -137,39 +158,33 @@ namespace FileLiberator
// 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);
}
return destinationDir;
}
private static string getDestDir(Book product)
{
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
var underscoreIndex = product.Title.IndexOf(':');
var titleDir
= underscoreIndex < 4
? product.Title
: product.Title.Substring(0, underscoreIndex);
var finalDir = FileUtility.GetValidFilename(AudibleFileStorage.BooksDirectory, titleDir, null, product.AudibleProductId);
return finalDir;
}
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
{
// files are: temp path\author\[asin].ext

View File

@@ -72,7 +72,7 @@ namespace FileLiberator
libraryBook.Book.Title,
libraryBook.Book.AudibleProductId,
libraryBook.Book.Locale,
libraryBook.Account,
Account = libraryBook.Account?.ToMask() ?? "[empty]",
tempAaxFilename,
actualFilePath,
length,

View 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)));
}
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>

View 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();
}
}

View File

@@ -1,81 +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)
{
Serilog.Log.Information("Begin " + nameof(processBookAsync) + " {@DebugInfo}", new
{
libraryBook.Book.Title,
libraryBook.Book.AudibleProductId,
libraryBook.Book.Locale,
libraryBook.Account
});
// this should never happen. check anyway. ProcessFirstValidAsync returning null is the signal that we're done. we can't let another IProcessable accidentally send this command
var status = await processable.ProcessAsync(libraryBook);
if (status == null)
throw new Exception("Processable should never return a null status");
return status;
}
private static LibraryBook getNextValidBook(this IProcessable processable)
{
var libraryBooks = DbContexts.GetContext().GetLibrary_Flat_NoTracking();
foreach (var libraryBook in libraryBooks)
if (processable.Validate(libraryBook))
return libraryBook;
return null;
}
public static async Task<StatusHandler> TryProcessAsync(this IProcessable processable, LibraryBook libraryBook)
=> processable.Validate(libraryBook)
? await processable.ProcessAsync(libraryBook)
: new StatusHandler();
}
}

View 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) { }
}
}

View File

@@ -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.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.33" />
<PackageReference Include="Octokit" Version="0.50.0" />
<PackageReference Include="Polly" Version="7.2.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,119 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
namespace FileManager
{
// 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 sealed class AudibleFileStorage : Enumeration<AudibleFileStorage>
{
#region static
public static AudibleFileStorage Audio { get; }
public static AudibleFileStorage AAX { get; }
public static AudibleFileStorage PDF { get; }
public static string DownloadsInProgress { get; }
public static string DecryptInProgress { get; }
public static string BooksDirectory => Configuration.Instance.Books;
// not customizable. don't move to config
public static string DownloadsFinal { get; }
= new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("DownloadsFinal").FullName;
static AudibleFileStorage()
{
#region init DecryptInProgress
if (!Configuration.Instance.DecryptInProgressEnum.In("WinTemp", "LibationFiles"))
Configuration.Instance.DecryptInProgressEnum = "WinTemp";
var M4bRootDir
= Configuration.Instance.DecryptInProgressEnum == "WinTemp" // else "LibationFiles"
? Configuration.WinTemp
: Configuration.Instance.LibationFiles;
DecryptInProgress = Path.Combine(M4bRootDir, "DecryptInProgress");
Directory.CreateDirectory(DecryptInProgress);
#endregion
#region init DownloadsInProgress
if (!Configuration.Instance.DownloadsInProgressEnum.In("WinTemp", "LibationFiles"))
Configuration.Instance.DownloadsInProgressEnum = "WinTemp";
var AaxRootDir
= Configuration.Instance.DownloadsInProgressEnum == "WinTemp" // else "LibationFiles"
? Configuration.WinTemp
: Configuration.Instance.LibationFiles;
DownloadsInProgress = Path.Combine(AaxRootDir, "DownloadsInProgress");
Directory.CreateDirectory(DownloadsInProgress);
#endregion
#region init BooksDirectory
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Path.Combine(Configuration.Instance.LibationFiles, "Books");
Directory.CreateDirectory(Configuration.Instance.Books);
#endregion
// must do this in static ctor, not w/inline properties
// static properties init before static ctor so these dir.s would still be null
Audio = new AudibleFileStorage(FileType.Audio, BooksDirectory, "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac");
AAX = new AudibleFileStorage(FileType.AAX, DownloadsFinal, "aax");
PDF = new AudibleFileStorage(FileType.PDF, BooksDirectory, "pdf", "zip");
}
#endregion
#region instance
public FileType FileType => (FileType)Value;
public string StorageDirectory => DisplayName;
private IEnumerable<string> extensions_noDots { get; }
private string extAggr { get; }
private AudibleFileStorage(FileType fileType, string storageDirectory, params string[] extensions) : base((int)fileType, storageDirectory)
{
extensions_noDots = extensions.Select(ext => ext.Trim('.')).ToList();
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
}
/// <summary>
/// Example for full books:
/// 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 bool IsFileTypeMatch(FileInfo fileInfo)
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
#endregion
}
}

View File

@@ -93,5 +93,11 @@ namespace InternalUtilities
}
public override string ToString() => $"{AccountId} - {Locale?.Name ?? "[empty]"}";
public string MaskedLogEntry => @$"AccountId={mask(AccountId)}|AccountName={mask(AccountName)}|Locale={Locale?.Name ?? "[empty]"}";
private static string mask(string str)
=> str is null ? "[null]"
: str == string.Empty ? "[empty]"
: str.ToMask();
}
}

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApiDTOs;
using Dinah.Core;
using Polly;
using Polly.Retry;
@@ -16,7 +17,7 @@ namespace InternalUtilities
{
Serilog.Log.Logger.Information("GetApiAsync. {@DebugInfo}", new
{
username,
Username = username.ToMask(),
LocaleName = localeName,
});
return EzApiCreator.GetApiAsync(
@@ -31,7 +32,7 @@ namespace InternalUtilities
{
Serilog.Log.Logger.Information("GetApiAsync. {@DebugInfo}", new
{
AccountId = account?.AccountId ?? "[empty]",
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
return EzApiCreator.GetApiAsync(

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>

View File

@@ -86,6 +86,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Libation Tests", "0 Libat
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities.Tests", "_Tests\InternalUtilities.Tests\InternalUtilities.Tests.csproj", "{8447C956-B03E-4F59-9DD4-877793B849D9}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.EntityFrameworkCore.Tests", "..\Dinah.Core\_Tests\Dinah.EntityFrameworkCore.Tests\Dinah.EntityFrameworkCore.Tests.csproj", "{6F5131A0-09AE-4707-B82B-5E53CB74688E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -208,6 +212,14 @@ Global
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.Build.0 = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -242,6 +254,8 @@ Global
{E7EFD64D-6630-4426-B09C-B6862A92E3FD} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{F3B04A3A-20C8-4582-A54A-715AF6A5D859} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{8447C956-B03E-4F59-9DD4-877793B849D9} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{6F5131A0-09AE-4707-B82B-5E53CB74688E} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}

Some files were not shown because too many files have changed in this diff Show More