mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-24 06:28:02 -05:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ad627159 | ||
|
|
878a5dd36c | ||
|
|
7c144b8277 | ||
|
|
bca8c3865b | ||
|
|
58102acd35 | ||
|
|
5e577843f7 | ||
|
|
e1d549cead | ||
|
|
323b8f2fb9 | ||
|
|
3dcbcf42ed | ||
|
|
825078abc6 | ||
|
|
6be44966ad | ||
|
|
66da138556 | ||
|
|
e5dd4b856e | ||
|
|
5caa9c5687 | ||
|
|
c8c0ffeb0d | ||
|
|
bfceb58d6b | ||
|
|
2e4c4cf5f7 | ||
|
|
23966c9b00 | ||
|
|
ef73d2243d | ||
|
|
c95feebd39 | ||
|
|
d6601fed83 | ||
|
|
962e379642 | ||
|
|
cbc61f5a2d | ||
|
|
2eaac6acc2 | ||
|
|
03b458765c | ||
|
|
c8b4bc6361 | ||
|
|
d9b5725ff1 | ||
|
|
0a0f60192b | ||
|
|
424d939c15 | ||
|
|
87f13ff8ed | ||
|
|
1e24df626a | ||
|
|
0312786721 | ||
|
|
1f8a5b256e | ||
|
|
426391f01c | ||
|
|
c296bff47f | ||
|
|
6b649cf4ca | ||
|
|
5103240a76 | ||
|
|
c2418b10f6 | ||
|
|
d705c23472 | ||
|
|
de45d008c7 | ||
|
|
c267332027 | ||
|
|
4829e85faf | ||
|
|
2acb9ca7e5 | ||
|
|
b260554a2a | ||
|
|
41a4055cd9 | ||
|
|
c6e9ba9bf9 | ||
|
|
5059333b38 | ||
|
|
b4015030cf | ||
|
|
7f5cf8f018 | ||
|
|
2c9ccd9c78 | ||
|
|
cebf218db4 | ||
|
|
530b44a0e6 | ||
|
|
b3dc5a7054 | ||
|
|
2567ccb44c | ||
|
|
e67eac92fd | ||
|
|
6e84fd97f1 | ||
|
|
9a458bf3dc | ||
|
|
283a46e1e2 | ||
|
|
6ff2859c39 | ||
|
|
e8df4952fc | ||
|
|
b19e1e8a30 | ||
|
|
a3cf6ac40d | ||
|
|
ab450c37c4 | ||
|
|
c837fefbdd |
@@ -1,12 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean" Version="0.1.10" />
|
||||
<PackageReference Include="Dinah.Core" Version="2.0.2.1" />
|
||||
<PackageReference Include="AAXClean" Version="0.3.2" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.1.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace AaxDecrypter
|
||||
{
|
||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||
{
|
||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||
|
||||
protected OutputFormat OutputFormat { get; }
|
||||
|
||||
protected AaxFile AaxFile;
|
||||
@@ -20,8 +22,8 @@ namespace AaxDecrypter
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
base.SetCoverArt(coverArt);
|
||||
if (coverArt is not null)
|
||||
AaxFile?.AppleTags.SetCoverArt(coverArt);
|
||||
if (coverArt is not null && AaxFile?.AppleTags is not null)
|
||||
AaxFile.AppleTags.Cover = coverArt;
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
@@ -33,6 +35,8 @@ namespace AaxDecrypter
|
||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||
|
||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
@@ -12,23 +13,13 @@ namespace AaxDecrypter
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
private Func<string, int, int, NewSplitCallback, string> multipartFileNameCallback { get; }
|
||||
private static string DefaultMultipartFilename(string outputFileName, int partsPosition, int partsTotal, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var template = Path.ChangeExtension(outputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(outputFileName);
|
||||
|
||||
var fileTemplate = new FileTemplate(template) { IllegalCharacterReplacements = " " };
|
||||
fileTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(partsPosition, partsTotal));
|
||||
fileTemplate.AddParameterReplacement("title", newSplitCallback?.Chapter?.Title ?? "");
|
||||
|
||||
return fileTemplate.GetFilePath();
|
||||
}
|
||||
private Func<MultiConvertFileProperties, string> multipartFileNameCallback { get; }
|
||||
|
||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
||||
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat,
|
||||
Func<string, int, int, NewSplitCallback, string> multipartFileNameCallback = null)
|
||||
Func<MultiConvertFileProperties, string> multipartFileNameCallback = null)
|
||||
: base(outFileName, cacheDirectory, dlLic, outputFormat)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
@@ -39,7 +30,7 @@ namespace AaxDecrypter
|
||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
|
||||
["Step 3: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
this.multipartFileNameCallback = multipartFileNameCallback ?? DefaultMultipartFilename;
|
||||
this.multipartFileNameCallback = multipartFileNameCallback ?? MultiConvertFileProperties.DefaultMultipartFilename;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -127,13 +118,19 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
||||
AaxFile.ConvertToMultiMp3(splitChapters, newSplitCallback =>
|
||||
{
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback);
|
||||
newSplitCallback.LameConfig.ID3.Track = chapterCount.ToString();
|
||||
((NAudio.Lame.LameConfig)newSplitCallback.UserState).ID3.Track = chapterCount.ToString();
|
||||
});
|
||||
}
|
||||
|
||||
private void createOutputFileStream(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var fileName = multipartFileNameCallback(OutputFileName, currentChapter, splitChapters.Count, newSplitCallback);
|
||||
var fileName = multipartFileNameCallback(new()
|
||||
{
|
||||
OutputFileName = OutputFileName,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback?.Chapter?.Title
|
||||
});
|
||||
fileName = FileUtility.GetValidFilename(fileName);
|
||||
|
||||
multiPartFilePaths.Add(fileName);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
|
||||
@@ -67,16 +67,18 @@ namespace AaxDecrypter
|
||||
Serilog.Log.Logger.Error("Conversion failed");
|
||||
|
||||
return IsSuccess;
|
||||
}
|
||||
}
|
||||
|
||||
protected void OnRetrievedTitle(string title)
|
||||
protected void OnRetrievedTitle(string title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
protected void OnRetrievedAuthors(string authors)
|
||||
protected void OnRetrievedAuthors(string authors)
|
||||
=> RetrievedAuthors?.Invoke(this, authors);
|
||||
protected void OnRetrievedNarrators(string narrators)
|
||||
protected void OnRetrievedNarrators(string narrators)
|
||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
||||
|
||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
|
||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||
|
||||
25
AaxDecrypter/MultiConvertFileProperties.cs
Normal file
25
AaxDecrypter/MultiConvertFileProperties.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class MultiConvertFileProperties
|
||||
{
|
||||
public string OutputFileName { get; set; }
|
||||
public int PartsPosition { get; set; }
|
||||
public int PartsTotal { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
||||
public static string DefaultMultipartFilename(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
var template = Path.ChangeExtension(multiConvertFileProperties.OutputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(multiConvertFileProperties.OutputFileName);
|
||||
|
||||
var fileNamingTemplate = new FileNamingTemplate(template) { IllegalCharacterReplacements = " " };
|
||||
fileNamingTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(multiConvertFileProperties.PartsPosition, multiConvertFileProperties.PartsTotal));
|
||||
fileNamingTemplate.AddParameterReplacement("title", multiConvertFileProperties.Title ?? "");
|
||||
|
||||
return fileNamingTemplate.GetFilePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,9 +193,6 @@ namespace AaxDecrypter
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||
|
||||
if (response.Headers.GetValues("Accept-Ranges").FirstOrDefault(r => r.EqualsInsensitive("bytes")) is null)
|
||||
throw new WebException($"Server at {Uri.Host} does not support Http ranges");
|
||||
|
||||
//Content length is the length of the range request, and it is only equal
|
||||
//to the complete file length if requesting Range: bytes=0-
|
||||
if (WritePosition == 0)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<Version>6.3.4.1</Version>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<Version>7.0.0.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -11,7 +11,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Octokit" Version="0.50.0" />
|
||||
<PackageReference Include="Octokit" Version="0.51.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -50,16 +50,48 @@ namespace AppScaffolding
|
||||
public static void RunPostConfigMigrations(Configuration config)
|
||||
{
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
PopulateMissingConfigValues(config);
|
||||
|
||||
//
|
||||
// migrations go below here
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v5_2_0__post_config(config);
|
||||
Migrations.migrate_to_v5_7_1(config);
|
||||
Migrations.migrate_to_v6_1_2(config);
|
||||
Migrations.migrate_to_v6_2_0(config);
|
||||
Migrations.migrate_to_v6_2_9(config);
|
||||
Migrations.migrate_to_v6_6_9(config);
|
||||
}
|
||||
|
||||
public static void PopulateMissingConfigValues(Configuration config)
|
||||
{
|
||||
config.InProgress ??= Configuration.WinTemp;
|
||||
|
||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||
config.AllowLibationFixup = true;
|
||||
|
||||
if (!config.Exists(nameof(config.DecryptToLossy)))
|
||||
config.DecryptToLossy = false;
|
||||
|
||||
if (!config.Exists(nameof(config.BadBook)))
|
||||
config.BadBook = Configuration.BadBookAction.Ask;
|
||||
|
||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
||||
config.DownloadEpisodes = true;
|
||||
|
||||
if (!config.Exists(nameof(config.ImportEpisodes)))
|
||||
config.ImportEpisodes = true;
|
||||
|
||||
if (!config.Exists(nameof(config.SplitFilesByChapter)))
|
||||
config.SplitFilesByChapter = false;
|
||||
|
||||
if (!config.Exists(nameof(config.FolderTemplate)))
|
||||
config.FolderTemplate = Templates.Folder.DefaultTemplate;
|
||||
|
||||
if (!config.Exists(nameof(config.FileTemplate)))
|
||||
config.FileTemplate = Templates.File.DefaultTemplate;
|
||||
|
||||
if (!config.Exists(nameof(config.ChapterFileTemplate)))
|
||||
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
|
||||
|
||||
if (!config.Exists(nameof(config.ShowImportedStats)))
|
||||
config.ShowImportedStats = true;
|
||||
}
|
||||
|
||||
/// <summary>Initialize logging. Run after migration</summary>
|
||||
@@ -75,29 +107,12 @@ namespace AppScaffolding
|
||||
if (config.GetObject("Serilog") != null)
|
||||
return;
|
||||
|
||||
// "Serilog": {
|
||||
// "MinimumLevel": "Information"
|
||||
// "WriteTo": [
|
||||
// {
|
||||
// "Name": "Console"
|
||||
// },
|
||||
// {
|
||||
// "Name": "File",
|
||||
// "Args": {
|
||||
// "rollingInterval": "Day",
|
||||
// "outputTemplate": ...
|
||||
// }
|
||||
// }
|
||||
// ],
|
||||
// "Using": [ "Dinah.Core" ],
|
||||
// "Enrich": [ "WithCaller" ]
|
||||
// }
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
new JObject { {"Name", "Console" } },
|
||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "File" },
|
||||
@@ -112,14 +127,16 @@ namespace AppScaffolding
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}" }
|
||||
// {Properties:j} needed for expanded exception logging
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller" } },
|
||||
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
|
||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||
};
|
||||
config.SetObject("Serilog", serilogObj);
|
||||
}
|
||||
@@ -134,9 +151,9 @@ namespace AppScaffolding
|
||||
// capture most Console.WriteLine() and write to serilog. See below tests for details.
|
||||
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
|
||||
//
|
||||
// Serilog also writes to Console so this might be asking for trouble. ie: infinite loops.
|
||||
// SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
|
||||
// Empirical testing so far has shown no issues.
|
||||
// If Serilog also writes to Console, this might be asking for trouble. ie: infinite loops.
|
||||
// To use that way, SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
|
||||
// However, empirical testing so far has shown no issues.
|
||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||
|
||||
#region Console => Serilog tests
|
||||
@@ -213,10 +230,10 @@ namespace AppScaffolding
|
||||
config.InProgress,
|
||||
|
||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||
DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
|
||||
DownloadsInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
|
||||
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,52 +329,53 @@ namespace AppScaffolding
|
||||
"WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")),
|
||||
_ => path
|
||||
};
|
||||
|
||||
public static void migrate_to_v5_2_0__post_config(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||
config.AllowLibationFixup = true;
|
||||
|
||||
if (!config.Exists(nameof(config.DecryptToLossy)))
|
||||
config.DecryptToLossy = false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
// add config.BadBook
|
||||
public static void migrate_to_v5_7_1(Configuration config)
|
||||
public static void migrate_to_v6_6_9(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.BadBook)))
|
||||
config.BadBook = Configuration.BadBookAction.Ask;
|
||||
}
|
||||
var writeToPath = $"Serilog.WriteTo";
|
||||
|
||||
// add config.DownloadEpisodes , config.ImportEpisodes
|
||||
public static void migrate_to_v6_1_2(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
||||
config.DownloadEpisodes = true;
|
||||
// remove WriteTo[].Name == Console
|
||||
{
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
|
||||
{
|
||||
for (var i = length1 - 1; i >= 0; i--)
|
||||
{
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
|
||||
|
||||
if (!config.Exists(nameof(config.ImportEpisodes)))
|
||||
config.ImportEpisodes = true;
|
||||
}
|
||||
if (exists && value == "Console")
|
||||
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add config.SplitFilesByChapter
|
||||
public static void migrate_to_v6_2_0(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.SplitFilesByChapter)))
|
||||
config.SplitFilesByChapter = false;
|
||||
}
|
||||
// add Serilog.Exceptions -- WithExceptionDetails
|
||||
{
|
||||
// outputTemplate should contain "{Properties:j}"
|
||||
{
|
||||
// re-calculate. previous loop may have changed the length
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
|
||||
{
|
||||
var propertyName = "outputTemplate";
|
||||
for (var i = 0; i < length2; i++)
|
||||
{
|
||||
var jsonPath = $"{writeToPath}[{i}].Args";
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
|
||||
|
||||
// add file naming templates
|
||||
public static void migrate_to_v6_2_9(Configuration config)
|
||||
{
|
||||
if (!config.Exists(nameof(config.FolderTemplate)))
|
||||
config.FolderTemplate = Templates.Folder.DefaultTemplate;
|
||||
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
|
||||
|
||||
if (!config.Exists(nameof(config.FileTemplate)))
|
||||
config.FileTemplate = Templates.File.DefaultTemplate;
|
||||
if (exists && value != newValue)
|
||||
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.Exists(nameof(config.ChapterFileTemplate)))
|
||||
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
|
||||
// Serilog.Using must include "Serilog.Exceptions"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
|
||||
|
||||
// Serilog.Enrich must include "WithExceptionDetails"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,108 @@ namespace AppScaffolding
|
||||
return success;
|
||||
}
|
||||
|
||||
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
|
||||
{
|
||||
JToken val = null;
|
||||
|
||||
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||
|
||||
return val?.Type == jTokenType;
|
||||
}
|
||||
|
||||
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
|
||||
{
|
||||
JToken val = null;
|
||||
|
||||
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||
|
||||
if (val?.Type == JTokenType.String)
|
||||
{
|
||||
value = val.Value<string>();
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Settings_SetWithJsonPath(string jsonPath, string propertyName, string newValue)
|
||||
{
|
||||
if (!Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out _))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var token = jObj.SelectToken(jsonPath);
|
||||
if (token is null
|
||||
|| token is not JObject o
|
||||
|| o[propertyName] is null)
|
||||
return;
|
||||
|
||||
var oldValue = token.Value<string>(propertyName);
|
||||
if (oldValue != newValue)
|
||||
token[propertyName] = newValue;
|
||||
});
|
||||
}
|
||||
|
||||
public static bool Settings_TryGetArrayLength(string jsonPath, out int length)
|
||||
{
|
||||
length = 0;
|
||||
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return false;
|
||||
|
||||
JArray array = null;
|
||||
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
|
||||
|
||||
length = array.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Settings_AddToArray(string jsonPath, string newValue)
|
||||
{
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var array = (JArray)jObj.SelectToken(jsonPath);
|
||||
array.Add(newValue);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Do not add if already exists</summary>
|
||||
public static void Settings_AddUniqueToArray(string arrayPath, string newValue)
|
||||
{
|
||||
if (!Settings_TryGetArrayLength(arrayPath, out var qty))
|
||||
return;
|
||||
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
var exists = Settings_TryGetFromJsonPath($"{arrayPath}[{i}]", out var value);
|
||||
if (exists && value == newValue)
|
||||
return;
|
||||
}
|
||||
|
||||
Settings_AddToArray(arrayPath, newValue);
|
||||
}
|
||||
|
||||
/// <summary>only remove if not exists</summary>
|
||||
public static void Settings_RemoveFromArray(string jsonPath, int position)
|
||||
{
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return;
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var array = (JArray)jObj.SelectToken(jsonPath);
|
||||
if (position < array.Count)
|
||||
array.RemoveAt(position);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>only insert if not exists</summary>
|
||||
public static void Settings_Insert(string key, string value)
|
||||
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="27.1.1" />
|
||||
<PackageReference Include="NPOI" Version="2.5.5" />
|
||||
<PackageReference Include="CsvHelper" Version="27.2.1" />
|
||||
<PackageReference Include="NPOI" Version="2.5.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -89,6 +89,9 @@ namespace ApplicationServices
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||
var newCount = await importIntoDbAsync(importItems);
|
||||
@@ -164,25 +167,46 @@ namespace ApplicationServices
|
||||
}
|
||||
|
||||
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
||||
{
|
||||
logTime("importIntoDbAsync -- pre db");
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryBookImporter = new LibraryBookImporter(context);
|
||||
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
int qtyChanges = saveChanges(context);
|
||||
logTime("importIntoDbAsync -- post SaveChanges");
|
||||
|
||||
if (qtyChanges > 0)
|
||||
await Task.Run(() => finalizeLibrarySizeChange());
|
||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||
|
||||
return newCount;
|
||||
}
|
||||
|
||||
private static int saveChanges(LibationContext context)
|
||||
{
|
||||
logTime("importIntoDbAsync -- pre db");
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryImporter = new LibraryBookImporter(context);
|
||||
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
var qtyChanges = context.SaveChanges();
|
||||
logTime("importIntoDbAsync -- post SaveChanges");
|
||||
try
|
||||
{
|
||||
return context.SaveChanges();
|
||||
}
|
||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
|
||||
{
|
||||
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culpret is the "WithExceptionDetails" serilog extension
|
||||
|
||||
if (qtyChanges > 0)
|
||||
await Task.Run(() => finalizeLibrarySizeChange());
|
||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
|
||||
|
||||
return newCount;
|
||||
}
|
||||
#endregion
|
||||
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
|
||||
if (ex.InnerException is null)
|
||||
throw new Exception($"{msg}{format(ex)}");
|
||||
throw new Exception(
|
||||
$"{msg}{format(ex)}",
|
||||
new Exception($"Inner Exception{format(ex.InnerException)}"));
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region remove books
|
||||
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
#region remove books
|
||||
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
@@ -44,6 +44,9 @@ namespace ApplicationServices
|
||||
[Name("Length In Minutes")]
|
||||
public int LengthInMinutes { get; set; }
|
||||
|
||||
[Name("Description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[Name("Publisher")]
|
||||
public string Publisher { get; set; }
|
||||
|
||||
@@ -111,6 +114,7 @@ namespace ApplicationServices
|
||||
AuthorNames = a.Book.AuthorNames,
|
||||
NarratorNames = a.Book.NarratorNames,
|
||||
LengthInMinutes = a.Book.LengthInMinutes,
|
||||
Description = a.Book.Description,
|
||||
Publisher = a.Book.Publisher,
|
||||
HasPdf = a.Book.HasPdf,
|
||||
SeriesNames = a.Book.SeriesNames,
|
||||
@@ -180,6 +184,7 @@ namespace ApplicationServices
|
||||
nameof (ExportDto.AuthorNames),
|
||||
nameof (ExportDto.NarratorNames),
|
||||
nameof (ExportDto.LengthInMinutes),
|
||||
nameof (ExportDto.Description),
|
||||
nameof (ExportDto.Publisher),
|
||||
nameof (ExportDto.HasPdf),
|
||||
nameof (ExportDto.SeriesNames),
|
||||
@@ -233,6 +238,7 @@ namespace ApplicationServices
|
||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||
row.CreateCell(col++).SetCellValue(dto.Description);
|
||||
row.CreateCell(col++).SetCellValue(dto.Publisher);
|
||||
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||
|
||||
@@ -127,11 +127,18 @@ namespace AudibleUtilities
|
||||
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
|
||||
//}
|
||||
#endif
|
||||
|
||||
Serilog.Log.Logger.Debug("Begin initial library scan");
|
||||
|
||||
if (!items.Any())
|
||||
items = await Api.GetAllLibraryItemsAsync(responseGroups);
|
||||
|
||||
Serilog.Log.Logger.Debug("Initial library scan complete. Begin episode scan");
|
||||
|
||||
await manageEpisodesAsync(items, importEpisodes);
|
||||
|
||||
Serilog.Log.Logger.Debug("Episode scan complete");
|
||||
|
||||
#if DEBUG
|
||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
||||
#endif
|
||||
@@ -166,8 +173,7 @@ namespace AudibleUtilities
|
||||
|
||||
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
|
||||
|
||||
// remove episode parents. even if the following stuff fails, these will still be removed from the collection.
|
||||
// also must happen before processing children because children abuses this flag
|
||||
// remove episode parents. even if the following stuff fails, these will still be removed from the collection
|
||||
items.RemoveAll(i => i.IsEpisodes);
|
||||
|
||||
if (importEpisodes)
|
||||
@@ -192,6 +198,14 @@ namespace AudibleUtilities
|
||||
{
|
||||
var children = await getEpisodeChildrenAsync(parent);
|
||||
|
||||
// actual individual episode, not the parent of a series.
|
||||
// for now I'm keeping it inside this method since it fits the work flow, incl. importEpisodes logic
|
||||
if (!children.Any())
|
||||
{
|
||||
results.Add(parent);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||
@@ -202,7 +216,8 @@ namespace AudibleUtilities
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
Sequence = parent.Relationships.Single(r => r.Asin == child.Asin).Sort.ToString(),
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin).Sort.ToString(),
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="2.3.2.1" />
|
||||
<PackageReference Include="AudibleApi" Version="2.7.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,8 +9,8 @@ namespace DataLayer.Configurations
|
||||
{
|
||||
entity.HasKey(bc => new { bc.BookId, bc.ContributorId, bc.Role });
|
||||
|
||||
entity.HasIndex(b => b.BookId);
|
||||
entity.HasIndex(b => b.ContributorId);
|
||||
entity.HasIndex(bc => bc.BookId);
|
||||
entity.HasIndex(bc => bc.ContributorId);
|
||||
|
||||
entity
|
||||
.HasOne(bc => bc.Book)
|
||||
|
||||
@@ -21,12 +21,12 @@ namespace DataLayer.Configurations
|
||||
// - update LibraryBook import code
|
||||
// - would likely challenge assumptions throughout Libation which have been true up until now
|
||||
|
||||
entity.HasKey(b => b.BookId);
|
||||
entity.HasKey(lb => lb.BookId);
|
||||
|
||||
entity
|
||||
.HasOne(le => le.Book)
|
||||
.HasOne(lb => lb.Book)
|
||||
.WithOne()
|
||||
.HasForeignKey<LibraryBook>(le => le.BookId);
|
||||
.HasForeignKey<LibraryBook>(lb => lb.BookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ namespace DataLayer.Configurations
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SeriesBook> entity)
|
||||
{
|
||||
entity.HasKey(bc => new { bc.SeriesId, bc.BookId });
|
||||
entity.HasKey(sb => new { sb.SeriesId, sb.BookId });
|
||||
|
||||
entity.HasIndex(b => b.SeriesId);
|
||||
entity.HasIndex(b => b.BookId);
|
||||
entity.HasIndex(sb => sb.SeriesId);
|
||||
entity.HasIndex(sb => sb.BookId);
|
||||
|
||||
entity
|
||||
.HasOne(sb => sb.Series)
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace DataLayer.Configurations
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Series> entity)
|
||||
{
|
||||
entity.HasKey(b => b.SeriesId);
|
||||
entity.HasIndex(b => b.AudibleSeriesId);
|
||||
entity.HasKey(s => s.SeriesId);
|
||||
entity.HasIndex(s => s.AudibleSeriesId);
|
||||
|
||||
entity
|
||||
.Metadata
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -12,13 +12,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="1.0.7.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.11">
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.0.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.11">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace DataLayer
|
||||
public class Category
|
||||
{
|
||||
// Empty is a special case. use private ctor w/o validation
|
||||
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "" };
|
||||
public static Category GetEmpty() => new() { CategoryId = -1, AudibleCategoryId = "", Name = "" };
|
||||
|
||||
internal int CategoryId { get; private set; }
|
||||
public string AudibleCategoryId { get; private set; }
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace DataLayer
|
||||
public class Contributor
|
||||
{
|
||||
// Empty is a special case. use private ctor w/o validation
|
||||
public static Contributor GetEmpty() => new Contributor { ContributorId = -1, Name = "" };
|
||||
public static Contributor GetEmpty() => new() { ContributorId = -1, Name = "" };
|
||||
|
||||
// contributors search links are just name with url-encoding. space can be + or %20
|
||||
// author search link: /search?searchAuthor=Robert+Bevan
|
||||
@@ -31,16 +31,12 @@ namespace DataLayer
|
||||
public string AudibleContributorId { get; private set; }
|
||||
|
||||
private Contributor() { }
|
||||
public Contributor(string name)
|
||||
public Contributor(string name, string audibleContributorId = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Name = ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
|
||||
_booksLink = new HashSet<BookContributor>();
|
||||
|
||||
Name = name;
|
||||
}
|
||||
public Contributor(string name, string audibleContributorId) : this(name)
|
||||
{
|
||||
// don't overwrite with null or whitespace but not an error
|
||||
if (!string.IsNullOrWhiteSpace(audibleContributorId))
|
||||
AudibleContributorId = audibleContributorId;
|
||||
|
||||
@@ -19,7 +19,8 @@ namespace DataLayer
|
||||
public static Book GetBook(this IQueryable<Book> books, string productId)
|
||||
=> books
|
||||
.GetBooks()
|
||||
.SingleOrDefault(b => b.AudibleProductId == productId);
|
||||
// 'Single' is more accurate but 'First' is faster and less error prone
|
||||
.FirstOrDefault(b => b.AudibleProductId == productId);
|
||||
|
||||
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
||||
public static IQueryable<Book> GetBooks(this IQueryable<Book> books, Expression<Func<Book, bool>> predicate)
|
||||
|
||||
@@ -4,62 +4,53 @@ using System.Linq;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class BookImporter : ItemsImporterBase
|
||||
{
|
||||
public BookImporter(LibationContext context) : base(context) { }
|
||||
protected override IValidator Validator => new BookValidator();
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new BookValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
public Dictionary<string, Book> Cache { get; private set; } = new();
|
||||
|
||||
private ContributorImporter contributorImporter { get; }
|
||||
private SeriesImporter seriesImporter { get; }
|
||||
private CategoryImporter categoryImporter { get; }
|
||||
|
||||
public BookImporter(LibationContext context) : base(context)
|
||||
{
|
||||
contributorImporter = new ContributorImporter(DbContext);
|
||||
seriesImporter = new SeriesImporter(DbContext);
|
||||
categoryImporter = new CategoryImporter(DbContext);
|
||||
}
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// pre-req.s
|
||||
new ContributorImporter(DbContext).Import(importItems);
|
||||
new SeriesImporter(DbContext).Import(importItems);
|
||||
new CategoryImporter(DbContext).Import(importItems);
|
||||
|
||||
// get distinct
|
||||
var productIds = importItems.Select(i => i.DtoItem.ProductId).Distinct().ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_books(productIds);
|
||||
|
||||
contributorImporter.Import(importItems);
|
||||
seriesImporter.Import(importItems);
|
||||
categoryImporter.Import(importItems);
|
||||
|
||||
// load db existing => hash table
|
||||
loadLocal_books(importItems);
|
||||
|
||||
// upsert
|
||||
var qtyNew = upsertBooks(importItems);
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private void loadLocal_books(List<string> productIds)
|
||||
private void loadLocal_books(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
// if this context has already loaded books, don't need to reload them. vestige from when context was long-lived. in practice, we now typically use a fresh context. this is quick though so no harm in leaving it.
|
||||
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId).ToList();
|
||||
var remainingProductIds = productIds
|
||||
.Except(localProductIds)
|
||||
// get distinct
|
||||
var productIds = importItems
|
||||
.Select(i => i.DtoItem.ProductId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
#region // explanation of DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
/*
|
||||
articles suggest loading to Local with
|
||||
context.Books.Load();
|
||||
we want Books and associated fields
|
||||
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
this is emulating Load() but with also getting associated fields
|
||||
|
||||
from: Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
||||
// Summary:
|
||||
// Enumerates the query. When using Entity Framework, this causes the results of
|
||||
// the query to be loaded into the associated context. This is equivalent to calling
|
||||
// ToList and then throwing away the list (without the overhead of actually creating
|
||||
// the list).
|
||||
public static void Load<TSource>([NotNullAttribute] this IQueryable<TSource> source);
|
||||
*/
|
||||
#endregion
|
||||
|
||||
// GetBooks() eager loads Series, category, et al
|
||||
if (remainingProductIds.Any())
|
||||
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
Cache = DbContext.Books
|
||||
.GetBooks(b => productIds.Contains(b.AudibleProductId))
|
||||
.ToDictionarySafe(b => b.AudibleProductId);
|
||||
}
|
||||
|
||||
private int upsertBooks(IEnumerable<ImportItem> importItems)
|
||||
@@ -68,8 +59,7 @@ namespace DtoImporterService
|
||||
|
||||
foreach (var item in importItems)
|
||||
{
|
||||
var book = DbContext.Books.Local.FirstOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
|
||||
if (book is null)
|
||||
if (!Cache.TryGetValue(item.DtoItem.ProductId, out var book))
|
||||
{
|
||||
book = createNewBook(item);
|
||||
qtyNew++;
|
||||
@@ -94,7 +84,7 @@ namespace DtoImporterService
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
var authors = item
|
||||
.Authors
|
||||
.Select(a => DbContext.Contributors.Local.Single(c => a.Name == c.Name))
|
||||
.Select(a => contributorImporter.Cache[a.Name])
|
||||
.ToList();
|
||||
|
||||
var narrators
|
||||
@@ -104,7 +94,7 @@ namespace DtoImporterService
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
: item
|
||||
.Narrators
|
||||
.Select(n => DbContext.Contributors.Local.Single(c => n.Name == c.Name))
|
||||
.Select(n => contributorImporter.Cache[n.Name])
|
||||
.ToList();
|
||||
|
||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||
@@ -118,24 +108,44 @@ namespace DtoImporterService
|
||||
// 2+
|
||||
: item.Categories[1].CategoryId;
|
||||
|
||||
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
|
||||
var category = categoryImporter.Cache[lastCategory];
|
||||
|
||||
var book = DbContext.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.TitleWithSubtitle,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
authors,
|
||||
narrators,
|
||||
category,
|
||||
importItem.LocaleName)
|
||||
).Entity;
|
||||
Book book;
|
||||
try
|
||||
{
|
||||
book = DbContext.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.TitleWithSubtitle,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
authors,
|
||||
narrators,
|
||||
category,
|
||||
importItem.LocaleName)
|
||||
).Entity;
|
||||
Cache.Add(book.AudibleProductId, book);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error adding book. {@DebugInfo}", new {
|
||||
item.ProductId,
|
||||
item.TitleWithSubtitle,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
QtyAuthors = authors?.Count,
|
||||
QtyNarrators = narrators?.Count,
|
||||
Category = category?.Name,
|
||||
importItem.LocaleName
|
||||
});
|
||||
throw;
|
||||
}
|
||||
|
||||
var publisherName = item.Publisher;
|
||||
if (!string.IsNullOrWhiteSpace(publisherName))
|
||||
{
|
||||
var publisher = DbContext.Contributors.Local.Single(c => publisherName == c.Name);
|
||||
var publisher = contributorImporter.Cache[publisherName];
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
@@ -152,7 +162,9 @@ namespace DtoImporterService
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
book.PictureId = item.PictureId;
|
||||
if (item.PictureId is not null)
|
||||
book.PictureId = item.PictureId;
|
||||
|
||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
||||
|
||||
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
||||
@@ -164,7 +176,7 @@ namespace DtoImporterService
|
||||
{
|
||||
foreach (var seriesEntry in item.Series)
|
||||
{
|
||||
var series = DbContext.Series.Local.FirstOrDefault(s => seriesEntry.SeriesId == s.AudibleSeriesId);
|
||||
var series = seriesImporter.Cache[seriesEntry.SeriesId];
|
||||
book.UpsertSeries(series, seriesEntry.Sequence);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ using System.Linq;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class CategoryImporter : ItemsImporterBase
|
||||
{
|
||||
public CategoryImporter(LibationContext context) : base(context) { }
|
||||
protected override IValidator Validator => new CategoryValidator();
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new CategoryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
public Dictionary<string, Category> Cache { get; private set; } = new();
|
||||
|
||||
public CategoryImporter(LibationContext context) : base(context) { }
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
@@ -19,7 +22,9 @@ namespace DtoImporterService
|
||||
var categoryIds = importItems
|
||||
.Select(i => i.DtoItem)
|
||||
.GetCategoriesDistinct()
|
||||
.Select(c => c.CategoryId).ToList();
|
||||
.Select(c => c.CategoryId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// load db existing => .Local
|
||||
loadLocal_categories(categoryIds);
|
||||
@@ -35,17 +40,13 @@ namespace DtoImporterService
|
||||
|
||||
private void loadLocal_categories(List<string> categoryIds)
|
||||
{
|
||||
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId).ToList();
|
||||
var remainingCategoryIds = categoryIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
// must include default/empty/missing
|
||||
categoryIds.Add(Category.GetEmpty().AudibleCategoryId);
|
||||
|
||||
// load existing => local
|
||||
// remember to include default/empty/missing
|
||||
var emptyName = Contributor.GetEmpty().Name;
|
||||
if (remainingCategoryIds.Any())
|
||||
DbContext.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId) || c.Name == emptyName).ToList();
|
||||
Cache = DbContext.Categories
|
||||
.Where(c => categoryIds.Contains(c.AudibleCategoryId))
|
||||
.ToDictionarySafe(c => c.AudibleCategoryId);
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
@@ -66,14 +67,11 @@ namespace DtoImporterService
|
||||
|
||||
Category parentCategory = null;
|
||||
if (i == 1)
|
||||
// should be "Single()" but user is getting a strange error
|
||||
parentCategory = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == pair[0].CategoryId);
|
||||
Cache.TryGetValue(pair[0].CategoryId, out parentCategory);
|
||||
|
||||
// should be "SingleOrDefault()" but user is getting a strange error
|
||||
var category = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == id);
|
||||
if (category is null)
|
||||
if (!Cache.TryGetValue(id, out var category))
|
||||
{
|
||||
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
|
||||
category = addCategory(id, name);
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
@@ -83,5 +81,24 @@ namespace DtoImporterService
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private Category addCategory(string id, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var category = new Category(new AudibleCategoryId(id), name);
|
||||
|
||||
var entityEntry = DbContext.Categories.Add(category);
|
||||
var entity = entityEntry.Entity;
|
||||
|
||||
Cache.Add(entity.AudibleCategoryId, entity);
|
||||
return entity;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error adding category. {@DebugInfo}", new { id, name });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ using System.Linq;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class ContributorImporter : ItemsImporterBase
|
||||
{
|
||||
public ContributorImporter(LibationContext context) : base(context) { }
|
||||
protected override IValidator Validator => new ContributorValidator();
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new ContributorValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
public Dictionary<string, Contributor> Cache { get; private set; } = new();
|
||||
|
||||
public ContributorImporter(LibationContext context) : base(context) { }
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
@@ -50,58 +53,61 @@ namespace DtoImporterService
|
||||
// must include default/empty/missing
|
||||
contributorNames.Add(Contributor.GetEmpty().Name);
|
||||
|
||||
//// BAD: very inefficient
|
||||
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
|
||||
|
||||
// GOOD: Except() is efficient. Due to hashing, it's close to O(n)
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
|
||||
var remainingContribNames = contributorNames
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
|
||||
// load existing => local
|
||||
if (remainingContribNames.Any())
|
||||
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList();
|
||||
Cache = DbContext.Contributors
|
||||
.Where(c => contributorNames.Contains(c.Name))
|
||||
.ToDictionarySafe(c => c.Name);
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPeople(List<Person> people)
|
||||
{
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
|
||||
var newPeople = people
|
||||
.Select(p => p.Name)
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
var hash = people
|
||||
// new people only
|
||||
.Where(p => !Cache.ContainsKey(p.Name))
|
||||
// remove duplicates by Name. first in wins
|
||||
.ToDictionarySafe(p => p.Name);
|
||||
|
||||
var groupby = people.GroupBy(
|
||||
p => p.Name,
|
||||
p => p,
|
||||
(key, g) => new { Name = key, People = g.ToList() }
|
||||
);
|
||||
foreach (var name in newPeople)
|
||||
foreach (var kvp in hash)
|
||||
{
|
||||
var p = groupby.Single(g => g.Name == name).People.First();
|
||||
DbContext.Contributors.Add(new Contributor(p.Name, p.Asin));
|
||||
var person = kvp.Value;
|
||||
addContributor(person.Name, person.Asin);
|
||||
}
|
||||
|
||||
return newPeople.Count;
|
||||
return hash.Count;
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
private int upsertPublishers(List<string> publishers)
|
||||
{
|
||||
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
|
||||
var newPublishers = publishers
|
||||
.Distinct()
|
||||
.Except(localNames)
|
||||
.ToList();
|
||||
var hash = publishers
|
||||
// new publishers only
|
||||
.Where(p => !Cache.ContainsKey(p))
|
||||
// remove duplicates
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var pub in newPublishers)
|
||||
DbContext.Contributors.Add(new Contributor(pub));
|
||||
foreach (var pub in hash)
|
||||
addContributor(pub);
|
||||
|
||||
return newPublishers.Count;
|
||||
return hash.Count;
|
||||
}
|
||||
}
|
||||
|
||||
private Contributor addContributor(string name, string id = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var newContrib = new Contributor(name);
|
||||
|
||||
var entityEntry = DbContext.Contributors.Add(newContrib);
|
||||
var entity = entityEntry.Entity;
|
||||
|
||||
Cache.Add(entity.Name, entity);
|
||||
return entity;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error adding contributor. {@DebugInfo}", new { name, id });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -53,5 +53,9 @@ namespace DtoImporterService
|
||||
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<ImportItem>>
|
||||
{
|
||||
protected ItemsImporterBase(LibationContext context) : base(context) { }
|
||||
|
||||
protected abstract IValidator Validator { get; }
|
||||
public sealed override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems)
|
||||
=> Validator.Validate(importItems.Select(i => i.DtoItem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,24 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class LibraryBookImporter : ItemsImporterBase
|
||||
{
|
||||
public LibraryBookImporter(LibationContext context) : base(context) { }
|
||||
protected override IValidator Validator => new LibraryValidator();
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
private BookImporter bookImporter { get; }
|
||||
|
||||
public LibraryBookImporter(LibationContext context) : base(context)
|
||||
{
|
||||
bookImporter = new BookImporter(DbContext);
|
||||
}
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
new BookImporter(DbContext).Import(importItems);
|
||||
bookImporter.Import(importItems);
|
||||
|
||||
var qtyNew = upsertLibraryBooks(importItems);
|
||||
return qtyNew;
|
||||
@@ -35,18 +41,32 @@ namespace DtoImporterService
|
||||
// CURRENT SOLUTION: don't re-insert
|
||||
|
||||
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
|
||||
var newItems = importItems
|
||||
.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId))
|
||||
.ToList();
|
||||
|
||||
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
|
||||
// just use the first
|
||||
var hash = newItems.ToDictionarySafe(dto => dto.DtoItem.ProductId);
|
||||
foreach (var kvp in hash)
|
||||
{
|
||||
var newItem = kvp.Value;
|
||||
|
||||
foreach (var newItem in newItems)
|
||||
{
|
||||
var libraryBook = new LibraryBook(
|
||||
DbContext.Books.Local.Single(b => b.AudibleProductId == newItem.DtoItem.ProductId),
|
||||
bookImporter.Cache[newItem.DtoItem.ProductId],
|
||||
newItem.DtoItem.DateAdded,
|
||||
newItem.AccountId);
|
||||
DbContext.LibraryBooks.Add(libraryBook);
|
||||
try
|
||||
{
|
||||
DbContext.LibraryBooks.Add(libraryBook);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account });
|
||||
}
|
||||
}
|
||||
|
||||
var qtyNew = newItems.Count;
|
||||
var qtyNew = hash.Count;
|
||||
return qtyNew;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ namespace DtoImporterService
|
||||
public record timeLogEntry(string msg, long totalElapsed, long delta);
|
||||
public static class PerfLogger
|
||||
{
|
||||
private static Stopwatch sw = new Stopwatch();
|
||||
private static List<timeLogEntry> __log { get; } = new List<timeLogEntry> { new("begin", 0, 0) };
|
||||
private static Stopwatch sw { get; } = new();
|
||||
private static List<timeLogEntry> __log { get; } = new() { new("begin", 0, 0) };
|
||||
|
||||
public static void logTime(string s)
|
||||
{
|
||||
|
||||
@@ -4,14 +4,17 @@ using System.Linq;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace DtoImporterService
|
||||
{
|
||||
public class SeriesImporter : ItemsImporterBase
|
||||
{
|
||||
public SeriesImporter(LibationContext context) : base(context) { }
|
||||
protected override IValidator Validator => new SeriesValidator();
|
||||
|
||||
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new SeriesValidator().Validate(importItems.Select(i => i.DtoItem));
|
||||
public Dictionary<string, DataLayer.Series> Cache { get; private set; } = new();
|
||||
|
||||
public SeriesImporter(LibationContext context) : base(context) { }
|
||||
|
||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||
{
|
||||
@@ -31,15 +34,12 @@ namespace DtoImporterService
|
||||
|
||||
private void loadLocal_series(List<AudibleApi.Common.Series> series)
|
||||
{
|
||||
var seriesIds = series.Select(s => s.SeriesId).ToList();
|
||||
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
|
||||
var remainingSeriesIds = seriesIds
|
||||
.Distinct()
|
||||
.Except(localIds)
|
||||
.ToList();
|
||||
var seriesIds = series.Select(s => s.SeriesId).Distinct().ToList();
|
||||
|
||||
if (remainingSeriesIds.Any())
|
||||
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
|
||||
if (seriesIds.Any())
|
||||
Cache = DbContext.Series
|
||||
.Where(s => seriesIds.Contains(s.AudibleSeriesId))
|
||||
.ToDictionarySafe(s => s.AudibleSeriesId);
|
||||
}
|
||||
|
||||
private int upsertSeries(List<AudibleApi.Common.Series> requestedSeries)
|
||||
@@ -48,10 +48,10 @@ namespace DtoImporterService
|
||||
|
||||
foreach (var s in requestedSeries)
|
||||
{
|
||||
var series = DbContext.Series.Local.FirstOrDefault(c => c.AudibleSeriesId == s.SeriesId);
|
||||
if (series is null)
|
||||
// AudibleApi.Common.Series.SeriesId == DataLayer.AudibleSeriesId
|
||||
if (!Cache.TryGetValue(s.SeriesId, out var series))
|
||||
{
|
||||
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
|
||||
series = addSeries(s.SeriesId);
|
||||
qtyNew++;
|
||||
}
|
||||
series.UpdateName(s.SeriesName);
|
||||
@@ -59,5 +59,24 @@ namespace DtoImporterService
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private DataLayer.Series addSeries(string seriesId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var series = new DataLayer.Series(new AudibleSeriesId(seriesId));
|
||||
|
||||
var entityEntry = DbContext.Series.Add(series);
|
||||
var entity = entityEntry.Entity;
|
||||
|
||||
Cache.Add(entity.AudibleSeriesId, entity);
|
||||
return entity;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error adding series. {@DebugInfo}", new { seriesId });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
DtoImporterService/_importer notes.txt
Normal file
37
DtoImporterService/_importer notes.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
* Local (eg DbContext.Books.Local): indexes/hashes PK and nothing else. Local.Find(PK) is fast. All other searches (eg FirstOrDefault) have awful performance. It deceptively *feels* like we get this partially for free since added/modified entries live here.
|
||||
* live db: for all importers, fields used for lookup are indexed
|
||||
|
||||
Using BookImporter as an example: since AudibleProductId is indexed, hitting the live db is much faster than using Local. Fastest is putting all in a local hash table
|
||||
|
||||
Note: GetBook/GetBooks eager loads Series, category, et al
|
||||
|
||||
for 1,200 iterations
|
||||
* load to LocalView
|
||||
DbContext.Books.Local.FirstOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId)
|
||||
27,125 ms
|
||||
* read from live db
|
||||
DbContext.Books.GetBook(item.DtoItem.ProductId)
|
||||
12,224 ms
|
||||
* load to hash table: Dictionary<string, Book>
|
||||
dictionary[item.DtoItem.ProductId];
|
||||
1 ms (yes: ONE)
|
||||
|
||||
With hashtable, somehow memory usage was not significantly affected
|
||||
|
||||
-----------------------------------
|
||||
|
||||
why were we using Local to begin with?
|
||||
|
||||
articles suggest loading to Local with
|
||||
context.Books.Load();
|
||||
this loads this table but not associated fields
|
||||
we want Books and associated fields
|
||||
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
|
||||
this is emulating Load() but with also getting associated fields
|
||||
from: Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
|
||||
// Summary:
|
||||
// Enumerates the query. When using Entity Framework, this causes the results of
|
||||
// the query to be loaded into the associated context. This is equivalent to calling
|
||||
// ToList and then throwing away the list (without the overhead of actually creating
|
||||
// the list).
|
||||
public static void Load<TSource>([NotNullAttribute] this IQueryable<TSource> source);
|
||||
@@ -11,30 +11,33 @@ namespace FileLiberator
|
||||
public event EventHandler<byte[]> CoverImageDiscovered;
|
||||
public abstract void Cancel();
|
||||
|
||||
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
|
||||
RequestCoverArt?.Invoke(this, setCoverArtDel);
|
||||
}
|
||||
|
||||
protected void OnTitleDiscovered(string title)
|
||||
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
|
||||
protected void OnTitleDiscovered(object _, string title)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
|
||||
TitleDiscovered?.Invoke(this, title);
|
||||
}
|
||||
|
||||
protected void OnAuthorsDiscovered(string authors)
|
||||
protected void OnAuthorsDiscovered(string authors) => OnAuthorsDiscovered(null, authors);
|
||||
protected void OnAuthorsDiscovered(object _, string authors)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
|
||||
AuthorsDiscovered?.Invoke(this, authors);
|
||||
}
|
||||
|
||||
protected void OnNarratorsDiscovered(string narrators)
|
||||
protected void OnNarratorsDiscovered(string narrators) => OnNarratorsDiscovered(null, narrators);
|
||||
protected void OnNarratorsDiscovered(object _, string narrators)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
|
||||
NarratorsDiscovered?.Invoke(this, narrators);
|
||||
}
|
||||
|
||||
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
|
||||
RequestCoverArt?.Invoke(this, setCoverArtDel);
|
||||
}
|
||||
|
||||
protected void OnCoverImageDiscovered(byte[] coverImage)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(CoverImageDiscovered), CoverImageBytes = coverImage?.Length });
|
||||
|
||||
@@ -11,72 +11,46 @@ namespace FileLiberator
|
||||
{
|
||||
public static class AudioFileStorageExt
|
||||
{
|
||||
private static void AddParameterReplacement(this FileTemplate fileTemplate, TemplateTags templateTags, object value)
|
||||
=> fileTemplate.AddParameterReplacement(templateTags.TagName, value);
|
||||
|
||||
internal class MultipartRenamer
|
||||
private class MultipartRenamer
|
||||
{
|
||||
public LibraryBook libraryBook { get; }
|
||||
private LibraryBook libraryBook { get; }
|
||||
|
||||
public MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
|
||||
internal MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
|
||||
|
||||
internal string MultipartFilename(string outputFileName, int partsPosition, int partsTotal, AAXClean.NewSplitCallback newSplitCallback)
|
||||
=> MultipartFilename(Configuration.Instance.ChapterFileTemplate, AudibleFileStorage.DecryptInProgressDirectory, Path.GetExtension(outputFileName), partsPosition, partsTotal, newSplitCallback?.Chapter?.Title ?? "");
|
||||
|
||||
internal string MultipartFilename(string template, string fullDirPath, string extension, int partsPosition, int partsTotal, string chapterTitle)
|
||||
{
|
||||
var fileTemplate = GetFileTemplateSingle(template, libraryBook, fullDirPath, extension);
|
||||
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.ChCount, partsTotal);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.ChNumber, partsPosition);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(partsPosition, partsTotal));
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.ChTitle, chapterTitle);
|
||||
|
||||
return fileTemplate.GetFilePath();
|
||||
}
|
||||
internal string MultipartFilename(AaxDecrypter.MultiConvertFileProperties props)
|
||||
=> Templates.ChapterFile.GetFilename(libraryBook.ToDto(), props);
|
||||
}
|
||||
|
||||
public static Func<string, int, int, AAXClean.NewSplitCallback, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
public static Func<AaxDecrypter.MultiConvertFileProperties, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> new MultipartRenamer(libraryBook).MultipartFilename;
|
||||
|
||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> GetCustomDirFilename(_, libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension);
|
||||
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> GetCustomDirFilename(_, libraryBook, AudibleFileStorage.BooksDirectory, extension);
|
||||
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// File path for where to move files into.
|
||||
/// Path: directory nested inside of Books directory
|
||||
/// File name: n/a
|
||||
/// </summary>
|
||||
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> GetFileTemplateSingle(Configuration.Instance.FolderTemplate, libraryBook, AudibleFileStorage.BooksDirectory, null)
|
||||
.GetFilePath();
|
||||
=> Templates.Folder.GetFilename(libraryBook.ToDto());
|
||||
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// Path: in progress directory.
|
||||
/// File name: final file name.
|
||||
/// </summary>
|
||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file does not exist
|
||||
/// </summary>
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file already exists
|
||||
/// </summary>
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
=> GetFileTemplateSingle(Configuration.Instance.FileTemplate, libraryBook, dirFullPath, extension)
|
||||
.GetFilePath();
|
||||
|
||||
internal static FileTemplate GetFileTemplateSingle(string template, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(dirFullPath, nameof(dirFullPath));
|
||||
|
||||
var fullfilename = Path.Combine(dirFullPath, template + FileUtility.GetStandardizedExtension(extension));
|
||||
var fileTemplate = new FileTemplate(fullfilename) { IllegalCharacterReplacements = "_" };
|
||||
|
||||
var title = libraryBook.Book.Title ?? "";
|
||||
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Id, libraryBook.Book.AudibleProductId);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Title, title);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.TitleShort, title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':')));
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Author, libraryBook.Book.AuthorNames);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBook.Book.Authors.FirstOrDefault()?.Name);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBook.Book.NarratorNames);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBook.Book.Narrators.FirstOrDefault()?.Name);
|
||||
|
||||
var seriesLink = libraryBook.Book.SeriesLink.FirstOrDefault();
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.Series, seriesLink?.Series.Name);
|
||||
fileTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, seriesLink?.Order);
|
||||
|
||||
return fileTemplate;
|
||||
}
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
@@ -72,8 +73,8 @@ namespace FileLiberator
|
||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = m4bBook.Duration;
|
||||
double remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
@@ -119,18 +119,28 @@ namespace FileLiberator
|
||||
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
abDownloader
|
||||
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm ? new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic)
|
||||
: Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
|
||||
outFileName, cacheDir, audiobookDlLic, outputFormat,
|
||||
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook)
|
||||
)
|
||||
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic, outputFormat);
|
||||
abDownloader.DecryptProgressUpdate += (_, progress) => OnStreamingProgressChanged(progress);
|
||||
abDownloader.DecryptTimeRemaining += (_, remaining) => OnStreamingTimeRemaining(remaining);
|
||||
abDownloader.RetrievedTitle += (_, title) => OnTitleDiscovered(title);
|
||||
abDownloader.RetrievedAuthors += (_, authors) => OnAuthorsDiscovered(authors);
|
||||
abDownloader.RetrievedNarrators += (_, narrators) => OnNarratorsDiscovered(narrators);
|
||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic);
|
||||
else
|
||||
{
|
||||
AaxcDownloadConvertBase converter
|
||||
= Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
|
||||
outFileName, cacheDir, audiobookDlLic, outputFormat,
|
||||
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook))
|
||||
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic, outputFormat);
|
||||
|
||||
if (Configuration.Instance.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames);
|
||||
|
||||
abDownloader = converter;
|
||||
}
|
||||
|
||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||
|
||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
@@ -166,7 +176,7 @@ namespace FileLiberator
|
||||
throw new Exception(errorString("Locale"));
|
||||
}
|
||||
|
||||
private void AaxcDownloader_RetrievedCoverArt(object sender, byte[] e)
|
||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||
{
|
||||
if (e is not null)
|
||||
OnCoverImageDiscovered(e);
|
||||
|
||||
@@ -12,8 +12,7 @@ namespace FileLiberator
|
||||
{
|
||||
var client = new HttpClient();
|
||||
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => OnStreamingProgressChanged(e);
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
|
||||
OnStreamingBegin(proposedDownloadFilePath);
|
||||
|
||||
|
||||
@@ -62,8 +62,7 @@ namespace FileLiberator
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
var progress = new Progress<DownloadProgress>();
|
||||
progress.ProgressChanged += (_, e) => OnStreamingProgressChanged(e);
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
|
||||
var client = new HttpClient();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -18,12 +18,14 @@ namespace FileLiberator
|
||||
StreamingBegin?.Invoke(this, filePath);
|
||||
}
|
||||
|
||||
protected void OnStreamingProgressChanged(DownloadProgress progress)
|
||||
protected void OnStreamingProgressChanged(DownloadProgress progress) => OnStreamingProgressChanged(null, progress);
|
||||
protected void OnStreamingProgressChanged(object _, DownloadProgress progress)
|
||||
{
|
||||
StreamingProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
|
||||
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining)
|
||||
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining) => OnStreamingTimeRemaining(null, timeRemaining);
|
||||
protected void OnStreamingTimeRemaining(object _, TimeSpan timeRemaining)
|
||||
{
|
||||
StreamingTimeRemaining?.Invoke(this, timeRemaining);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
@@ -22,5 +23,21 @@ namespace FileLiberator
|
||||
var apiExtended = await AudibleUtilities.ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
return apiExtended.Api;
|
||||
}
|
||||
|
||||
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
||||
{
|
||||
Account = libraryBook.Account,
|
||||
|
||||
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||
Title = libraryBook.Book.Title ?? "",
|
||||
Locale = libraryBook.Book.Locale,
|
||||
|
||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||
|
||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
||||
|
||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace FileManager
|
||||
lock (fsCacheLocker)
|
||||
{
|
||||
fsCache.Clear();
|
||||
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
||||
fsCache.AddRange(FileUtility.SaferEnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ namespace FileManager
|
||||
Stop();
|
||||
|
||||
lock (fsCacheLocker)
|
||||
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
||||
fsCache.AddRange(FileUtility.SaferEnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
||||
|
||||
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
||||
@@ -135,7 +135,7 @@ namespace FileManager
|
||||
private void AddPath(string path)
|
||||
{
|
||||
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
|
||||
AddUniqueFiles(Directory.EnumerateFiles(path, SearchPattern, SearchOption));
|
||||
AddUniqueFiles(FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption));
|
||||
else
|
||||
AddUniqueFile(path);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="2.0.2.1" />
|
||||
<PackageReference Include="Polly" Version="7.2.2" />
|
||||
<PackageReference Include="Dinah.Core" Version="4.0.6.1" />
|
||||
<PackageReference Include="Polly" Version="7.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,13 +6,13 @@ using Dinah.Core;
|
||||
namespace FileManager
|
||||
{
|
||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||
public class FileTemplate
|
||||
public class FileNamingTemplate
|
||||
{
|
||||
/// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: <name></summary>
|
||||
public string Template { get; }
|
||||
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public FileTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
public FileNamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
|
||||
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/</summary>
|
||||
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dinah.Core;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
@@ -65,6 +66,8 @@ namespace FileManager
|
||||
|
||||
var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension;
|
||||
|
||||
fullfilename = removeInvalidWhitespace(fullfilename);
|
||||
|
||||
var i = 0;
|
||||
while (File.Exists(fullfilename))
|
||||
{
|
||||
@@ -86,36 +89,80 @@ namespace FileManager
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
var invalidChars = Path.GetInvalidPathChars().Union(new[] {
|
||||
path = replaceInvalidChars(path, illegalCharacterReplacements);
|
||||
path = standardizeSlashes(path);
|
||||
path = replaceColons(path, illegalCharacterReplacements);
|
||||
path = removeDoubleSlashes(path);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static char[] invalidChars { get; } = Path.GetInvalidPathChars().Union(new[] {
|
||||
'*', '?',
|
||||
// these are weird. If you run Path.GetInvalidPathChars() in C# interactive, these characters are included.
|
||||
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
|
||||
// In live code, Path.GetInvalidPathChars() does not include them
|
||||
'"', '<', '>'
|
||||
}).ToArray();
|
||||
private static string replaceInvalidChars(string path, string illegalCharacterReplacements)
|
||||
=> string.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars));
|
||||
|
||||
var fixedPath = string
|
||||
.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars))
|
||||
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
private static string standardizeSlashes(string path)
|
||||
=> path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
|
||||
private static string replaceColons(string path, string illegalCharacterReplacements)
|
||||
{
|
||||
// replace all colons except within the first 2 chars
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < fixedPath.Length; i++)
|
||||
for (var i = 0; i < path.Length; i++)
|
||||
{
|
||||
var c = fixedPath[i];
|
||||
var c = path[i];
|
||||
if (i >= 2 && c == ':')
|
||||
builder.Append(illegalCharacterReplacements);
|
||||
else
|
||||
builder.Append(c);
|
||||
}
|
||||
fixedPath = builder.ToString();
|
||||
|
||||
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (fixedPath.Contains(dblSeparator))
|
||||
fixedPath = fixedPath.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
|
||||
|
||||
return fixedPath;
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string removeDoubleSlashes(string path)
|
||||
{
|
||||
if (path.Length < 2)
|
||||
return path;
|
||||
|
||||
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
|
||||
|
||||
var remainder = path[1..];
|
||||
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (remainder.Contains(dblSeparator))
|
||||
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
|
||||
|
||||
return path[0] + remainder;
|
||||
}
|
||||
|
||||
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
|
||||
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
|
||||
|
||||
/// <summary>no part of the path may begin or end in whitespace</summary>
|
||||
private static string removeInvalidWhitespace(string fullfilename)
|
||||
{
|
||||
// no whitespace at beginning or end
|
||||
// replace whitespace around path slashes
|
||||
// regex (with space added for clarity)
|
||||
// \s* \\ \s* => \
|
||||
// no ending dots. beginning dots are valid
|
||||
|
||||
// regex is easier by ending with separator
|
||||
fullfilename += Path.DirectorySeparatorChar;
|
||||
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString());
|
||||
// take seperator back off
|
||||
fullfilename = RemoveLastCharacter(fullfilename);
|
||||
|
||||
fullfilename = removeDoubleSlashes(fullfilename);
|
||||
return fullfilename;
|
||||
}
|
||||
|
||||
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
|
||||
|
||||
/// <summary>
|
||||
/// Move file.
|
||||
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
|
||||
@@ -142,15 +189,19 @@ namespace FileManager
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(source))
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted", new { source });
|
||||
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file", new { source });
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
@@ -161,19 +212,61 @@ namespace FileManager
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(source))
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
SaferDelete(destination);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination));
|
||||
File.Move(source, destination);
|
||||
Serilog.Log.Logger.Information("File successfully moved", new { source, destination });
|
||||
Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
|
||||
SaferDelete(destination);
|
||||
|
||||
var dir = Path.GetDirectoryName(destination);
|
||||
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination });
|
||||
File.Move(source, destination);
|
||||
Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to move file", new { source, destination });
|
||||
Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Starting directory</param>
|
||||
/// <param name="patternMatch">Filename pattern match</param>
|
||||
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
|
||||
/// <returns>List of files</returns>
|
||||
public static IEnumerable<string> SaferEnumerateFiles(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
{
|
||||
var foundFiles = Enumerable.Empty<string>();
|
||||
|
||||
if (searchOption == SearchOption.AllDirectories)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEnumerable<string> subDirs = Directory.EnumerateDirectories(path);
|
||||
// Add files in subdirectories recursively to the list
|
||||
foreach (string dir in subDirs)
|
||||
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (PathTooLongException) { }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Add files from the current directory
|
||||
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
|
||||
return foundFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ namespace FileManager
|
||||
if (IsReadOnly)
|
||||
return;
|
||||
|
||||
File.WriteAllText(Filepath, "{}");
|
||||
System.Threading.Thread.Sleep(100);
|
||||
createNewFile();
|
||||
}
|
||||
|
||||
public string GetString(string propertyName)
|
||||
@@ -205,9 +204,39 @@ namespace FileManager
|
||||
|
||||
private JObject readFile()
|
||||
{
|
||||
if (!File.Exists(Filepath))
|
||||
{
|
||||
var msg = "Unrecoverable error. Settings file cannot be found";
|
||||
var ex = new FileNotFoundException(msg, Filepath);
|
||||
Serilog.Log.Logger.Error(ex, msg);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
var settingsJsonContents = File.ReadAllText(Filepath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settingsJsonContents))
|
||||
{
|
||||
createNewFile();
|
||||
settingsJsonContents = File.ReadAllText(Filepath);
|
||||
}
|
||||
|
||||
var jObject = JsonConvert.DeserializeObject<JObject>(settingsJsonContents);
|
||||
|
||||
if (jObject is null)
|
||||
{
|
||||
var msg = "Unrecoverable error. Unable to read settings from Settings file";
|
||||
var ex = new NullReferenceException(msg);
|
||||
Serilog.Log.Logger.Error(ex, msg);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
return jObject;
|
||||
}
|
||||
|
||||
private void createNewFile()
|
||||
{
|
||||
File.WriteAllText(Filepath, "{}");
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.28803.156
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
@@ -62,7 +62,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator.Tests", "_Tes
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests\FileManager.Tests\FileManager.Tests.csproj", "{F2E04270-4551-41C4-99FF-E7125BED708C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
|
||||
@@ -35,7 +35,8 @@ namespace LibationCli
|
||||
var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync((a) => ApiExtended.CreateAsync(a), _accounts);
|
||||
|
||||
Console.WriteLine("Scan complete.");
|
||||
Console.WriteLine($"Total processed: {TotalBooksProcessed}\r\nNew: {NewBooksAdded}");
|
||||
Console.WriteLine($"Total processed: {TotalBooksProcessed}");
|
||||
Console.WriteLine($"New: {NewBooksAdded}");
|
||||
}
|
||||
|
||||
private Account[] getAccounts()
|
||||
|
||||
@@ -47,7 +47,7 @@ namespace LibationFileManager
|
||||
{
|
||||
// primary lookup
|
||||
var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
|
||||
if (cachedFile != null)
|
||||
if (cachedFile is not null && File.Exists(cachedFile))
|
||||
return cachedFile;
|
||||
|
||||
// secondary lookup attempt
|
||||
@@ -73,8 +73,8 @@ namespace LibationFileManager
|
||||
protected override string GetFilePathCustom(string productId)
|
||||
{
|
||||
var regex = GetBookSearchRegex(productId);
|
||||
return Directory
|
||||
.EnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
|
||||
return FileUtility
|
||||
.SaferEnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(s => regex.IsMatch(s));
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,13 @@ namespace LibationFileManager
|
||||
set => persistentDictionary.SetString(nameof(BadBook), value.ToString());
|
||||
}
|
||||
|
||||
[Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
|
||||
public bool ShowImportedStats
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(ShowImportedStats));
|
||||
set => persistentDictionary.SetNonString(nameof(ShowImportedStats), value);
|
||||
}
|
||||
|
||||
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
|
||||
public bool ImportEpisodes
|
||||
{
|
||||
@@ -170,14 +177,10 @@ namespace LibationFileManager
|
||||
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
|
||||
}
|
||||
|
||||
private string getTemplate(string settingName, Templates templ)
|
||||
{
|
||||
var value = persistentDictionary.GetString(settingName)?.Trim();
|
||||
return templ.IsValid(value) ? value : templ.DefaultTemplate;
|
||||
}
|
||||
private string getTemplate(string settingName, Templates templ) => templ.GetValid(persistentDictionary.GetString(settingName));
|
||||
private void setTemplate(string settingName, Templates templ, string newValue)
|
||||
{
|
||||
var template = newValue.Trim();
|
||||
var template = newValue?.Trim();
|
||||
if (templ.IsValid(template))
|
||||
persistentDictionary.SetString(settingName, template);
|
||||
}
|
||||
@@ -267,7 +270,7 @@ namespace LibationFileManager
|
||||
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
|
||||
if (!valueWasChanged)
|
||||
{
|
||||
Log.Logger.Information("LogLevel.set attempt. No change");
|
||||
Log.Logger.Debug("LogLevel.set attempt. No change");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,18 @@ namespace LibationFileManager
|
||||
if (File.Exists(jsonFile))
|
||||
{
|
||||
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(jsonFile));
|
||||
|
||||
// file exists but deser is null. this will never happen when file is healthy
|
||||
if (list is null)
|
||||
{
|
||||
lock (locker)
|
||||
{
|
||||
Serilog.Log.Logger.Error("Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFile });
|
||||
File.Delete(jsonFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cache = new Cache<CacheEntry>(list);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Exceptions" Version="8.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
29
LibationFileManager/LibraryBookDto.cs
Normal file
29
LibationFileManager/LibraryBookDto.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public class BookDto
|
||||
{
|
||||
public string AudibleProductId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Locale { get; set; }
|
||||
|
||||
public IEnumerable<string> Authors { get; set; }
|
||||
public string AuthorNames => string.Join(", ", Authors);
|
||||
public string FirstAuthor => Authors.FirstOrDefault();
|
||||
|
||||
public IEnumerable<string> Narrators { get; set; }
|
||||
public string NarratorNames => string.Join(", ", Narrators);
|
||||
public string FirstNarrator => Narrators.FirstOrDefault();
|
||||
|
||||
public string SeriesName { get; set; }
|
||||
public string SeriesNumber { get; set; }
|
||||
}
|
||||
|
||||
public class LibraryBookDto : BookDto
|
||||
{
|
||||
public string Account { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -73,16 +73,10 @@ namespace LibationFileManager
|
||||
if (!cache.ContainsKey(def) || cache[def] == null)
|
||||
{
|
||||
var path = getPath(def);
|
||||
byte[] bytes;
|
||||
|
||||
if (File.Exists(path))
|
||||
bytes = File.ReadAllBytes(path);
|
||||
else
|
||||
{
|
||||
bytes = downloadBytes(def);
|
||||
saveFile(def, bytes);
|
||||
}
|
||||
|
||||
var bytes
|
||||
= File.Exists(path)
|
||||
? File.ReadAllBytes(path)
|
||||
: downloadBytes(def);
|
||||
cache[def] = bytes;
|
||||
}
|
||||
return cache[def];
|
||||
@@ -104,7 +98,6 @@ namespace LibationFileManager
|
||||
continue;
|
||||
|
||||
var bytes = downloadBytes(def);
|
||||
saveFile(def, bytes);
|
||||
lock (cacheLocker)
|
||||
cache[def] = bytes;
|
||||
|
||||
@@ -115,14 +108,24 @@ namespace LibationFileManager
|
||||
private static HttpClient imageDownloadClient { get; } = new HttpClient();
|
||||
private static byte[] downloadBytes(PictureDefinition def)
|
||||
{
|
||||
var sz = (int)def.Size;
|
||||
return imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}._SL{sz}_.jpg").Result;
|
||||
}
|
||||
if (def.PictureId is null)
|
||||
return getDefaultImage(def.Size);
|
||||
|
||||
private static void saveFile(PictureDefinition def, byte[] bytes)
|
||||
{
|
||||
var path = getPath(def);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
try
|
||||
{
|
||||
var sz = (int)def.Size;
|
||||
var bytes = imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}._SL{sz}_.jpg").Result;
|
||||
|
||||
// save image file. make sure to not save default image
|
||||
var path = getPath(def);
|
||||
File.WriteAllBytes(path, bytes);
|
||||
|
||||
return bytes;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return getDefaultImage(def.Size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ namespace LibationFileManager
|
||||
{
|
||||
// not customizable. don't move to config
|
||||
private static string databasePath => Path.Combine(Configuration.Instance.LibationFiles, "LibationContext.db");
|
||||
public static string ConnectionString => $"Data Source={databasePath};Foreign Keys=False;";
|
||||
public static string ConnectionString => $"Data Source={databasePath};Foreign Keys=False;Pooling=False;";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +54,17 @@ namespace LibationFileManager
|
||||
|
||||
private static void ensureCache()
|
||||
{
|
||||
if (cache is null)
|
||||
lock (locker)
|
||||
cache = !File.Exists(TagsFile)
|
||||
? new Dictionary<string, string>()
|
||||
: JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
|
||||
if (cache is not null)
|
||||
return;
|
||||
|
||||
lock (locker)
|
||||
{
|
||||
if (File.Exists(TagsFile))
|
||||
cache = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
|
||||
|
||||
// if file doesn't exist. or if file is corrupt and deserialize returns null
|
||||
cache ??= new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ namespace LibationFileManager
|
||||
IsChapterOnly = isChapterOnly;
|
||||
}
|
||||
|
||||
// putting these first is the incredibly lazy way to make them show up first in the EditTemplateDialog
|
||||
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true);
|
||||
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true);
|
||||
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #", true);
|
||||
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros", true);
|
||||
|
||||
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
|
||||
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title");
|
||||
public static TemplateTags TitleShort { get; } = new TemplateTags("title short", "Title. Stop at first colon");
|
||||
@@ -28,10 +34,11 @@ namespace LibationFileManager
|
||||
public static TemplateTags Series { get; } = new TemplateTags("series", "Name of series");
|
||||
// can't also have a leading zeros version. Too many weird edge cases. Eg: "1-4"
|
||||
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series");
|
||||
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
|
||||
public static TemplateTags Locale { get; } = new TemplateTags("locale", "Region/country");
|
||||
|
||||
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true);
|
||||
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true);
|
||||
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter number", true);
|
||||
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter number with leading zeros", true);
|
||||
// Special case. Isn't mapped to a replacement in Templates.cs
|
||||
// Included here for display by EditTemplateDialog
|
||||
public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
@@ -8,7 +12,7 @@ namespace LibationFileManager
|
||||
{
|
||||
protected static string[] Valid => Array.Empty<string>();
|
||||
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
|
||||
public const string ERROR_FULL_PATH_IS_INVALID = @"No full paths allowed. Eg: should not start with C:\";
|
||||
public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\";
|
||||
public const string ERROR_INVALID_FILE_NAME_CHAR = @"Only file name friendly characters allowed. Eg: no colons or slashes";
|
||||
|
||||
public const string WARNING_EMPTY = "Template is empty.";
|
||||
@@ -17,41 +21,31 @@ namespace LibationFileManager
|
||||
public const string WARNING_HAS_CHAPTER_TAGS = "Chapter tags should only be used in the template used for naming files which are split by chapter. Eg: <ch title>";
|
||||
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
|
||||
|
||||
public static Templates Folder { get; } = new FolderTemplate();
|
||||
public static Templates File { get; } = new FileTemplate();
|
||||
public static Templates ChapterFile { get; } = new ChapterFileTemplate();
|
||||
public static FolderTemplate Folder { get; } = new FolderTemplate();
|
||||
public static FileTemplate File { get; } = new FileTemplate();
|
||||
public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate();
|
||||
|
||||
public abstract string Name { get; }
|
||||
public abstract string Description { get; }
|
||||
public abstract string DefaultTemplate { get; }
|
||||
protected abstract bool IsChapterized { get; }
|
||||
|
||||
protected Templates() { }
|
||||
|
||||
#region validation
|
||||
internal string GetValid(string configValue)
|
||||
{
|
||||
var value = configValue?.Trim();
|
||||
return IsValid(value) ? value : DefaultTemplate;
|
||||
}
|
||||
|
||||
public abstract IEnumerable<string> GetErrors(string template);
|
||||
public bool IsValid(string template) => !GetErrors(template).Any();
|
||||
|
||||
public abstract IEnumerable<string> GetWarnings(string template);
|
||||
public bool HasWarnings(string template) => GetWarnings(template).Any();
|
||||
|
||||
public IEnumerable<TemplateTags> GetTemplateTags()
|
||||
=> TemplateTags.GetAll()
|
||||
// yeah, this line is a little funky but it works when you think through it. also: trust the unit tests
|
||||
.Where(t => IsChapterized || !t.IsChapterOnly);
|
||||
|
||||
public int TagCount(string template)
|
||||
=> GetTemplateTags()
|
||||
// for <id><id> == 1, use:
|
||||
// .Count(t => template.Contains($"<{t.TagName}>"))
|
||||
// .Sum() impl: <id><id> == 2
|
||||
.Sum(t => template.Split($"<{t.TagName}>").Length - 1);
|
||||
|
||||
public static bool ContainsChapterOnlyTags(string template)
|
||||
=> TemplateTags.GetAll()
|
||||
.Where(t => t.IsChapterOnly)
|
||||
.Any(t => ContainsTag(template, t.TagName));
|
||||
|
||||
public static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>");
|
||||
|
||||
protected static string[] getFileErrors(string template)
|
||||
protected static string[] GetFileErrors(string template)
|
||||
{
|
||||
// File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save.
|
||||
|
||||
@@ -60,15 +54,15 @@ namespace LibationFileManager
|
||||
return new[] { ERROR_NULL_IS_INVALID };
|
||||
|
||||
if (template.Contains(':')
|
||||
|| template.Contains(System.IO.Path.DirectorySeparatorChar)
|
||||
|| template.Contains(System.IO.Path.AltDirectorySeparatorChar)
|
||||
|| template.Contains(Path.DirectorySeparatorChar)
|
||||
|| template.Contains(Path.AltDirectorySeparatorChar)
|
||||
)
|
||||
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
|
||||
|
||||
return Valid;
|
||||
}
|
||||
|
||||
protected IEnumerable<string> getWarnings(string template)
|
||||
protected IEnumerable<string> GetStandardWarnings(string template)
|
||||
{
|
||||
var warnings = GetErrors(template).ToList();
|
||||
if (template is null)
|
||||
@@ -88,13 +82,113 @@ namespace LibationFileManager
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private class FolderTemplate : Templates
|
||||
internal int TagCount(string template)
|
||||
=> GetTemplateTags()
|
||||
// for <id><id> == 1, use:
|
||||
// .Count(t => template.Contains($"<{t.TagName}>"))
|
||||
// .Sum() impl: <id><id> == 2
|
||||
.Sum(t => template.Split($"<{t.TagName}>").Length - 1);
|
||||
|
||||
internal static bool ContainsChapterOnlyTags(string template)
|
||||
=> TemplateTags.GetAll()
|
||||
.Where(t => t.IsChapterOnly)
|
||||
.Any(t => ContainsTag(template, t.TagName));
|
||||
|
||||
internal static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>");
|
||||
#endregion
|
||||
|
||||
#region to file name
|
||||
/// <summary>
|
||||
/// EditTemplateDialog: Get template generated filename for portion of path
|
||||
/// </summary>
|
||||
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template)
|
||||
=> string.IsNullOrWhiteSpace(template)
|
||||
? ""
|
||||
: getFileNamingTemplate(libraryBookDto, template, null, null)
|
||||
.GetFilePath();
|
||||
|
||||
private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
|
||||
|
||||
dirFullPath = dirFullPath?.Trim() ?? "";
|
||||
|
||||
// for non-series, remove <if series-> and <-if series> tags and everything in between
|
||||
// for series, remove <if series-> and <-if series> tags, what's in between will remain
|
||||
template = ifSeriesRegex.Replace(
|
||||
template,
|
||||
string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1");
|
||||
|
||||
var t = template + FileUtility.GetStandardizedExtension(extension);
|
||||
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);
|
||||
|
||||
var fileNamingTemplate = new FileNamingTemplate(fullfilename) { IllegalCharacterReplacements = "_" };
|
||||
|
||||
var title = libraryBookDto.Title ?? "";
|
||||
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
|
||||
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Id, libraryBookDto.AudibleProductId);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Author, libraryBookDto.AuthorNames);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBookDto.FirstAuthor);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBookDto.NarratorNames);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBookDto.FirstNarrator);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, libraryBookDto.SeriesNumber);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale);
|
||||
|
||||
return fileNamingTemplate;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public IEnumerable<TemplateTags> GetTemplateTags()
|
||||
=> TemplateTags.GetAll()
|
||||
// yeah, this line is a little funky but it works when you think through it. also: trust the unit tests
|
||||
.Where(t => IsChapterized || !t.IsChapterOnly);
|
||||
|
||||
public string Sanitize(string template)
|
||||
{
|
||||
var value = template ?? "";
|
||||
|
||||
// don't use alt slash
|
||||
value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
|
||||
// don't allow double slashes
|
||||
var sing = $"{Path.DirectorySeparatorChar}";
|
||||
var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (value.Contains(dbl))
|
||||
value = value.Replace(dbl, sing);
|
||||
|
||||
// trim. don't start or end with slash
|
||||
while (true)
|
||||
{
|
||||
var start = value.Length;
|
||||
value = value
|
||||
.Trim()
|
||||
.Trim(Path.DirectorySeparatorChar);
|
||||
var end = value.Length;
|
||||
if (start == end)
|
||||
break;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public class FolderTemplate : Templates
|
||||
{
|
||||
public override string Name => "Folder Template";
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate));
|
||||
public override string DefaultTemplate { get; } = "<title short> [<id>]";
|
||||
protected override bool IsChapterized { get; } = false;
|
||||
|
||||
internal FolderTemplate() : base() { }
|
||||
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template)
|
||||
{
|
||||
// null is invalid. whitespace is valid but not recommended
|
||||
@@ -108,33 +202,55 @@ namespace LibationFileManager
|
||||
return Valid;
|
||||
}
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template) => getWarnings(template);
|
||||
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
|
||||
#endregion
|
||||
|
||||
#region to file name
|
||||
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
|
||||
public string GetFilename(LibraryBookDto libraryBookDto)
|
||||
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, AudibleFileStorage.BooksDirectory, null)
|
||||
.GetFilePath();
|
||||
#endregion
|
||||
}
|
||||
|
||||
private class FileTemplate : Templates
|
||||
public class FileTemplate : Templates
|
||||
{
|
||||
public override string Name => "File Template";
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate));
|
||||
public override string DefaultTemplate { get; } = "<title> [<id>]";
|
||||
protected override bool IsChapterized { get; } = false;
|
||||
|
||||
public override IEnumerable<string> GetErrors(string template) => getFileErrors(template);
|
||||
internal FileTemplate() : base() { }
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template) => getWarnings(template);
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
|
||||
#endregion
|
||||
|
||||
#region to file name
|
||||
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
|
||||
public string GetFilename(LibraryBookDto libraryBookDto, string dirFullPath, string extension)
|
||||
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FileTemplate, dirFullPath, extension)
|
||||
.GetFilePath();
|
||||
#endregion
|
||||
}
|
||||
|
||||
private class ChapterFileTemplate : Templates
|
||||
public class ChapterFileTemplate : Templates
|
||||
{
|
||||
public override string Name => "Chapter File Template";
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
|
||||
public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
|
||||
protected override bool IsChapterized { get; } = true;
|
||||
|
||||
public override IEnumerable<string> GetErrors(string template) => getFileErrors(template);
|
||||
internal ChapterFileTemplate() : base() { }
|
||||
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template)
|
||||
{
|
||||
var warnings = getWarnings(template).ToList();
|
||||
var warnings = GetStandardWarnings(template).ToList();
|
||||
if (template is null)
|
||||
return warnings;
|
||||
|
||||
@@ -143,7 +259,26 @@ namespace LibationFileManager
|
||||
warnings.Add(WARNING_NO_CHAPTER_NUMBER_TAG);
|
||||
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region to file name
|
||||
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
|
||||
public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
|
||||
=> GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory);
|
||||
|
||||
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath)
|
||||
{
|
||||
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
|
||||
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
|
||||
|
||||
return fileNamingTemplate.GetFilePath();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
LibationFileManager/UtilityExtensions.cs
Normal file
13
LibationFileManager/UtilityExtensions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FileManager;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public static class UtilityExtensions
|
||||
{
|
||||
public static void AddParameterReplacement(this FileNamingTemplate fileNamingTemplate, TemplateTags templateTags, object value)
|
||||
=> fileNamingTemplate.AddParameterReplacement(templateTags.TagName, value);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -14,11 +14,6 @@ namespace LibationWinForms.BookLiberation
|
||||
|
||||
private Func<byte[]> GetCoverArtDelegate;
|
||||
|
||||
// book info
|
||||
private string title;
|
||||
private string authorNames;
|
||||
private string narratorNames;
|
||||
|
||||
#region Processable event handler overrides
|
||||
public override void Processable_Begin(object sender, LibraryBook libraryBook)
|
||||
{
|
||||
@@ -31,8 +26,8 @@ namespace LibationWinForms.BookLiberation
|
||||
|
||||
//Set default values from library
|
||||
AudioDecodable_TitleDiscovered(sender, libraryBook.Book.Title);
|
||||
AudioDecodable_AuthorsDiscovered(sender, string.Join(", ", libraryBook.Book.Authors));
|
||||
AudioDecodable_NarratorsDiscovered(sender, string.Join(", ", libraryBook.Book.NarratorNames));
|
||||
AudioDecodable_AuthorsDiscovered(sender, libraryBook.Book.AuthorNames);
|
||||
AudioDecodable_NarratorsDiscovered(sender, libraryBook.Book.NarratorNames);
|
||||
AudioDecodable_CoverImageDiscovered(sender,
|
||||
PictureStorage.GetPicture(
|
||||
new PictureDefinition(
|
||||
@@ -60,14 +55,23 @@ namespace LibationWinForms.BookLiberation
|
||||
updateRemainingTime((int)timeRemaining.TotalSeconds);
|
||||
}
|
||||
|
||||
private void updateRemainingTime(int remaining)
|
||||
=> remainingTimeLbl.UIThreadAsync(() => remainingTimeLbl.Text = $"ETA:\r\n{formatTime(remaining)}");
|
||||
|
||||
private string formatTime(int seconds)
|
||||
{
|
||||
var timeSpan = new TimeSpan(0, 0, seconds);
|
||||
return
|
||||
timeSpan.TotalHours >= 1 ? $"{timeSpan:%h}h {timeSpan:mm}m {timeSpan:ss}s"
|
||||
: timeSpan.TotalMinutes >= 1 ? $"{timeSpan:%m}m {timeSpan:ss}s"
|
||||
: $"{seconds} sec";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region AudioDecodable event handlers
|
||||
public override void AudioDecodable_RequestCoverArt(object sender, Action<byte[]> setCoverArtDelegate)
|
||||
{
|
||||
base.AudioDecodable_RequestCoverArt(sender, setCoverArtDelegate);
|
||||
setCoverArtDelegate(GetCoverArtDelegate?.Invoke());
|
||||
}
|
||||
private string title;
|
||||
private string authorNames;
|
||||
private string narratorNames;
|
||||
|
||||
public override void AudioDecodable_TitleDiscovered(object sender, string title)
|
||||
{
|
||||
@@ -91,27 +95,20 @@ namespace LibationWinForms.BookLiberation
|
||||
updateBookInfo();
|
||||
}
|
||||
|
||||
private void updateBookInfo()
|
||||
=> bookInfoLbl.UIThreadAsync(() => bookInfoLbl.Text = $"{title}\r\nBy {authorNames}\r\nNarrated by {narratorNames}");
|
||||
|
||||
public override void AudioDecodable_RequestCoverArt(object sender, Action<byte[]> setCoverArtDelegate)
|
||||
{
|
||||
base.AudioDecodable_RequestCoverArt(sender, setCoverArtDelegate);
|
||||
setCoverArtDelegate(GetCoverArtDelegate?.Invoke());
|
||||
}
|
||||
|
||||
public override void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt)
|
||||
{
|
||||
base.AudioDecodable_CoverImageDiscovered(sender, coverArt);
|
||||
pictureBox1.UIThreadAsync(() => pictureBox1.Image = Dinah.Core.Drawing.ImageReader.ToImage(coverArt));
|
||||
}
|
||||
#endregion
|
||||
|
||||
// thread-safe UI updates
|
||||
private void updateBookInfo()
|
||||
=> bookInfoLbl.UIThreadAsync(() => bookInfoLbl.Text = $"{title}\r\nBy {authorNames}\r\nNarrated by {narratorNames}");
|
||||
|
||||
private void updateRemainingTime(int remaining)
|
||||
=> remainingTimeLbl.UIThreadAsync(() => remainingTimeLbl.Text = $"ETA:\r\n{formatTime(remaining)}");
|
||||
|
||||
private string formatTime(int seconds)
|
||||
{
|
||||
var timeSpan = new TimeSpan(0, 0, seconds);
|
||||
return
|
||||
timeSpan.TotalHours >= 1 ? $"{timeSpan:%h}h {timeSpan:mm}m {timeSpan:ss}s"
|
||||
: timeSpan.TotalMinutes >= 1 ? $"{timeSpan:%m}m {timeSpan:ss}s"
|
||||
: $"{seconds} sec";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,12 @@ namespace LibationWinForms.BookLiberation.BaseForms
|
||||
#endregion
|
||||
|
||||
#region AudioDecodable event handlers
|
||||
public virtual void AudioDecodable_RequestCoverArt(object sender, Action<byte[]> setCoverArtDelegate) { }
|
||||
public virtual void AudioDecodable_TitleDiscovered(object sender, string title) { }
|
||||
public virtual void AudioDecodable_AuthorsDiscovered(object sender, string authors) { }
|
||||
public virtual void AudioDecodable_NarratorsDiscovered(object sender, string narrators) { }
|
||||
|
||||
public virtual void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt) { }
|
||||
public virtual void AudioDecodable_RequestCoverArt(object sender, Action<byte[]> setCoverArtDelegate) { }
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ namespace LibationWinForms.Dialogs
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!inputIsValid())
|
||||
return;
|
||||
|
||||
// without transaction, accounts persister will write ANY EDIT immediately to file
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
@@ -129,6 +132,28 @@ namespace LibationWinForms.Dialogs
|
||||
}
|
||||
}
|
||||
|
||||
private bool inputIsValid()
|
||||
{
|
||||
var dtos = getRowDtos();
|
||||
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.AccountId))
|
||||
{
|
||||
MessageBox.Show("Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.LocaleName))
|
||||
{
|
||||
MessageBox.Show("Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void persist(AccountsSettings accountsSettings)
|
||||
{
|
||||
var existingAccounts = accountsSettings.Accounts;
|
||||
@@ -152,11 +177,6 @@ namespace LibationWinForms.Dialogs
|
||||
// upsert each. validation occurs through Account and AccountsSettings
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.AccountId))
|
||||
throw new Exception("Please enter an account id for all accounts");
|
||||
if (string.IsNullOrWhiteSpace(dto.LocaleName))
|
||||
throw new Exception("Please select a locale (i.e.: country or region) for all accounts");
|
||||
|
||||
var acct = accountsSettings.Upsert(dto.AccountId, dto.LocaleName);
|
||||
acct.LibraryScan = dto.LibraryScan;
|
||||
acct.AccountName
|
||||
|
||||
@@ -33,16 +33,18 @@
|
||||
this.templateTb = new System.Windows.Forms.TextBox();
|
||||
this.templateLbl = new System.Windows.Forms.Label();
|
||||
this.resetToDefaultBtn = new System.Windows.Forms.Button();
|
||||
this.outputTb = new System.Windows.Forms.TextBox();
|
||||
this.listView1 = new System.Windows.Forms.ListView();
|
||||
this.columnHeader1 = new System.Windows.Forms.ColumnHeader();
|
||||
this.columnHeader2 = new System.Windows.Forms.ColumnHeader();
|
||||
this.richTextBox1 = new System.Windows.Forms.RichTextBox();
|
||||
this.warningsLbl = new System.Windows.Forms.Label();
|
||||
this.exampleLbl = new System.Windows.Forms.Label();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// saveBtn
|
||||
//
|
||||
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.saveBtn.Location = new System.Drawing.Point(714, 496);
|
||||
this.saveBtn.Location = new System.Drawing.Point(714, 345);
|
||||
this.saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.saveBtn.Name = "saveBtn";
|
||||
this.saveBtn.Size = new System.Drawing.Size(88, 27);
|
||||
@@ -55,7 +57,7 @@
|
||||
//
|
||||
this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel;
|
||||
this.cancelBtn.Location = new System.Drawing.Point(832, 496);
|
||||
this.cancelBtn.Location = new System.Drawing.Point(832, 345);
|
||||
this.cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.cancelBtn.Name = "cancelBtn";
|
||||
this.cancelBtn.Size = new System.Drawing.Size(88, 27);
|
||||
@@ -94,19 +96,6 @@
|
||||
this.resetToDefaultBtn.UseVisualStyleBackColor = true;
|
||||
this.resetToDefaultBtn.Click += new System.EventHandler(this.resetToDefaultBtn_Click);
|
||||
//
|
||||
// outputTb
|
||||
//
|
||||
this.outputTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.outputTb.Location = new System.Drawing.Point(346, 56);
|
||||
this.outputTb.Multiline = true;
|
||||
this.outputTb.Name = "outputTb";
|
||||
this.outputTb.ReadOnly = true;
|
||||
this.outputTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.outputTb.Size = new System.Drawing.Size(574, 434);
|
||||
this.outputTb.TabIndex = 4;
|
||||
//
|
||||
// listView1
|
||||
//
|
||||
this.listView1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
@@ -117,20 +106,53 @@
|
||||
this.listView1.HideSelection = false;
|
||||
this.listView1.Location = new System.Drawing.Point(12, 56);
|
||||
this.listView1.Name = "listView1";
|
||||
this.listView1.Size = new System.Drawing.Size(328, 434);
|
||||
this.listView1.TabIndex = 100;
|
||||
this.listView1.Size = new System.Drawing.Size(328, 283);
|
||||
this.listView1.TabIndex = 3;
|
||||
this.listView1.UseCompatibleStateImageBehavior = false;
|
||||
this.listView1.View = System.Windows.Forms.View.Details;
|
||||
//
|
||||
// columnHeader1
|
||||
//
|
||||
this.columnHeader1.Text = "Tag";
|
||||
this.columnHeader1.Width = 90;
|
||||
this.columnHeader1.Width = 137;
|
||||
//
|
||||
// columnHeader2
|
||||
//
|
||||
this.columnHeader2.Text = "Description";
|
||||
this.columnHeader2.Width = 230;
|
||||
this.columnHeader2.Width = 170;
|
||||
//
|
||||
// richTextBox1
|
||||
//
|
||||
this.richTextBox1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.richTextBox1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
|
||||
this.richTextBox1.Location = new System.Drawing.Point(346, 74);
|
||||
this.richTextBox1.Name = "richTextBox1";
|
||||
this.richTextBox1.ReadOnly = true;
|
||||
this.richTextBox1.Size = new System.Drawing.Size(574, 185);
|
||||
this.richTextBox1.TabIndex = 5;
|
||||
this.richTextBox1.Text = "";
|
||||
//
|
||||
// warningsLbl
|
||||
//
|
||||
this.warningsLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.warningsLbl.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point);
|
||||
this.warningsLbl.ForeColor = System.Drawing.Color.Firebrick;
|
||||
this.warningsLbl.Location = new System.Drawing.Point(346, 262);
|
||||
this.warningsLbl.Name = "warningsLbl";
|
||||
this.warningsLbl.Size = new System.Drawing.Size(574, 77);
|
||||
this.warningsLbl.TabIndex = 6;
|
||||
this.warningsLbl.Text = "[warnings]";
|
||||
//
|
||||
// exampleLbl
|
||||
//
|
||||
this.exampleLbl.AutoSize = true;
|
||||
this.exampleLbl.Location = new System.Drawing.Point(346, 56);
|
||||
this.exampleLbl.Name = "exampleLbl";
|
||||
this.exampleLbl.Size = new System.Drawing.Size(55, 15);
|
||||
this.exampleLbl.TabIndex = 4;
|
||||
this.exampleLbl.Text = "Example:";
|
||||
//
|
||||
// EditTemplateDialog
|
||||
//
|
||||
@@ -138,9 +160,11 @@
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.CancelButton = this.cancelBtn;
|
||||
this.ClientSize = new System.Drawing.Size(933, 539);
|
||||
this.ClientSize = new System.Drawing.Size(933, 388);
|
||||
this.Controls.Add(this.exampleLbl);
|
||||
this.Controls.Add(this.warningsLbl);
|
||||
this.Controls.Add(this.richTextBox1);
|
||||
this.Controls.Add(this.listView1);
|
||||
this.Controls.Add(this.outputTb);
|
||||
this.Controls.Add(this.resetToDefaultBtn);
|
||||
this.Controls.Add(this.templateLbl);
|
||||
this.Controls.Add(this.templateTb);
|
||||
@@ -163,9 +187,11 @@
|
||||
private System.Windows.Forms.TextBox templateTb;
|
||||
private System.Windows.Forms.Label templateLbl;
|
||||
private System.Windows.Forms.Button resetToDefaultBtn;
|
||||
private System.Windows.Forms.TextBox outputTb;
|
||||
private System.Windows.Forms.ListView listView1;
|
||||
private System.Windows.Forms.ColumnHeader columnHeader1;
|
||||
private System.Windows.Forms.ColumnHeader columnHeader2;
|
||||
private System.Windows.Forms.RichTextBox richTextBox1;
|
||||
private System.Windows.Forms.Label warningsLbl;
|
||||
private System.Windows.Forms.Label exampleLbl;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,19 @@ namespace LibationWinForms.Dialogs
|
||||
{
|
||||
public partial class EditTemplateDialog : Form
|
||||
{
|
||||
// final value. post-validity check
|
||||
public string TemplateText { get; private set; }
|
||||
|
||||
// hold the work-in-progress value. not guaranteed to be valid
|
||||
private string _workingTemplateText;
|
||||
private string workingTemplateText
|
||||
{
|
||||
get => _workingTemplateText;
|
||||
set => _workingTemplateText = template.Sanitize(value);
|
||||
}
|
||||
|
||||
private void resetTextBox(string value) => this.templateTb.Text = workingTemplateText = value;
|
||||
|
||||
private Configuration config { get; } = Configuration.Instance;
|
||||
|
||||
private Templates template { get; }
|
||||
@@ -35,108 +46,119 @@ namespace LibationWinForms.Dialogs
|
||||
return;
|
||||
}
|
||||
|
||||
warningsLbl.Text = "";
|
||||
|
||||
this.Text = $"Edit {template.Name}";
|
||||
|
||||
this.templateLbl.Text = template.Description;
|
||||
this.templateTb.Text = inputTemplateText;
|
||||
resetTextBox(inputTemplateText);
|
||||
|
||||
// populate list view
|
||||
foreach (var tag in template.GetTemplateTags())
|
||||
listView1.Items.Add(new ListViewItem(new[] { $"<{tag.TagName}>", tag.Description }));
|
||||
}
|
||||
|
||||
private void resetToDefaultBtn_Click(object sender, EventArgs e) => templateTb.Text = template.DefaultTemplate;
|
||||
private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(template.DefaultTemplate);
|
||||
|
||||
private void templateTb_TextChanged(object sender, EventArgs e)
|
||||
{
|
||||
var t = templateTb.Text;
|
||||
workingTemplateText = templateTb.Text;
|
||||
|
||||
var warnings
|
||||
= !template.HasWarnings(t)
|
||||
? ""
|
||||
: "Warnings:\r\n" +
|
||||
template
|
||||
.GetWarnings(t)
|
||||
.Select(err => $"- {err}")
|
||||
.Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
var isFolder = template == Templates.Folder;
|
||||
|
||||
var libraryBookDto = new LibraryBookDto
|
||||
{
|
||||
Account = "my account",
|
||||
AudibleProductId = "123456789",
|
||||
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
Locale = "us",
|
||||
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
|
||||
Narrators = new List<string> { "Stephen Fry" },
|
||||
SeriesName = "Sherlock Holmes",
|
||||
SeriesNumber = "1"
|
||||
};
|
||||
var chapterName = "A Flight for Life";
|
||||
var chapterNumber = 4;
|
||||
var chaptersTotal = 10;
|
||||
|
||||
var books = config.Books;
|
||||
var folderTemplate = template == Templates.Folder ? t : config.FolderTemplate;
|
||||
folderTemplate = folderTemplate.Trim().Trim(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }).Trim();
|
||||
var fileTemplate = template == Templates.Folder ? config.FileTemplate : t;
|
||||
fileTemplate = fileTemplate.Trim();
|
||||
var folder = Templates.Folder.GetPortionFilename(
|
||||
libraryBookDto,
|
||||
isFolder ? workingTemplateText : config.FolderTemplate);
|
||||
var file
|
||||
= template == Templates.ChapterFile
|
||||
? Templates.ChapterFile.GetPortionFilename(
|
||||
libraryBookDto,
|
||||
workingTemplateText,
|
||||
new() { OutputFileName = "", PartsPosition = chapterNumber, PartsTotal = chaptersTotal, Title = chapterName },
|
||||
"")
|
||||
: Templates.File.GetPortionFilename(
|
||||
libraryBookDto,
|
||||
isFolder ? config.FileTemplate : workingTemplateText);
|
||||
var ext = config.DecryptToLossy ? "mp3" : "m4b";
|
||||
|
||||
var path = Path.Combine(books, folderTemplate, $"{fileTemplate}.{ext}");
|
||||
|
||||
// this logic should be external
|
||||
path = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
var sing = $"{Path.DirectorySeparatorChar}";
|
||||
var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (path.Contains(dbl))
|
||||
path = path.Replace(dbl, sing);
|
||||
|
||||
// once path is finalized
|
||||
const char ZERO_WIDTH_SPACE = '\u200B';
|
||||
path = path.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
|
||||
var sing = $"{Path.DirectorySeparatorChar}";
|
||||
|
||||
// result: can wrap long paths. eg:
|
||||
// |-- LINE WRAP BOUNDARIES --|
|
||||
// \books\author with a very <= normal line break on space between words
|
||||
// long name\narrator narrator
|
||||
// \title <= line break on the zero-with space we added before slashes
|
||||
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
|
||||
|
||||
var book = new DataLayer.Book(
|
||||
new DataLayer.AudibleProductId("123456789"),
|
||||
"A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
"Fake description",
|
||||
1234,
|
||||
DataLayer.ContentType.Product,
|
||||
new List<DataLayer.Contributor>
|
||||
{
|
||||
new("Arthur Conan Doyle"),
|
||||
new("Stephen Fry - introductions")
|
||||
},
|
||||
new List<DataLayer.Contributor> { new("Stephen Fry") },
|
||||
new DataLayer.Category(new DataLayer.AudibleCategoryId("cat12345"), "Mystery"),
|
||||
"us"
|
||||
);
|
||||
var libraryBook = new DataLayer.LibraryBook(book, DateTime.Now, "my account");
|
||||
warningsLbl.Text
|
||||
= !template.HasWarnings(workingTemplateText)
|
||||
? ""
|
||||
: "Warning:\r\n" +
|
||||
template
|
||||
.GetWarnings(workingTemplateText)
|
||||
.Select(err => $"- {err}")
|
||||
.Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
|
||||
outputTb.Text = @$"
|
||||
var bold = new System.Drawing.Font(richTextBox1.Font, System.Drawing.FontStyle.Bold);
|
||||
var reg = new System.Drawing.Font(richTextBox1.Font, System.Drawing.FontStyle.Regular);
|
||||
|
||||
Example:
|
||||
richTextBox1.Clear();
|
||||
richTextBox1.SelectionFont = reg;
|
||||
|
||||
{books}
|
||||
{folderTemplate}
|
||||
{fileTemplate}
|
||||
{ext}
|
||||
{path}
|
||||
richTextBox1.AppendText(slashWrap(books));
|
||||
richTextBox1.AppendText(sing);
|
||||
|
||||
{book.AudibleProductId}
|
||||
{book.Title}
|
||||
{book.AuthorNames}
|
||||
{book.NarratorNames}
|
||||
series: {"Sherlock Holmes"}
|
||||
if (isFolder)
|
||||
richTextBox1.SelectionFont = bold;
|
||||
|
||||
{warnings}
|
||||
richTextBox1.AppendText(slashWrap(folder));
|
||||
|
||||
".Trim();
|
||||
if (isFolder)
|
||||
richTextBox1.SelectionFont = reg;
|
||||
|
||||
richTextBox1.AppendText(sing);
|
||||
|
||||
if (!isFolder)
|
||||
richTextBox1.SelectionFont = bold;
|
||||
|
||||
richTextBox1.AppendText(file);
|
||||
|
||||
if (!isFolder)
|
||||
richTextBox1.SelectionFont = reg;
|
||||
|
||||
richTextBox1.AppendText($".{ext}");
|
||||
}
|
||||
|
||||
private void saveBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (!template.IsValid(templateTb.Text))
|
||||
if (!template.IsValid(workingTemplateText))
|
||||
{
|
||||
var errors = template
|
||||
.GetErrors(templateTb.Text)
|
||||
.GetErrors(workingTemplateText)
|
||||
.Select(err => $"- {err}")
|
||||
.Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
TemplateText = templateTb.Text;
|
||||
TemplateText = workingTemplateText;
|
||||
|
||||
this.DialogResult = DialogResult.OK;
|
||||
this.Close();
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
try
|
||||
{
|
||||
(TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync((account) => ApiExtended.CreateAsync(account, new WinformLoginChoiceEager(account)), _accounts);
|
||||
(TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync(account => ApiExtended.CreateAsync(account, new WinformLoginChoiceEager(account)), _accounts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -28,140 +28,152 @@
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LoginExternalDialog));
|
||||
this.submitBtn = new System.Windows.Forms.Button();
|
||||
this.localeLbl = new System.Windows.Forms.Label();
|
||||
this.usernameLbl = new System.Windows.Forms.Label();
|
||||
this.loginUrlLbl = new System.Windows.Forms.Label();
|
||||
this.loginUrlTb = new System.Windows.Forms.TextBox();
|
||||
this.copyBtn = new System.Windows.Forms.Button();
|
||||
this.launchBrowserBtn = new System.Windows.Forms.Button();
|
||||
this.instructionsLbl = new System.Windows.Forms.Label();
|
||||
this.responseUrlTb = new System.Windows.Forms.TextBox();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// submitBtn
|
||||
//
|
||||
this.submitBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.submitBtn.Location = new System.Drawing.Point(665, 400);
|
||||
this.submitBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.submitBtn.Name = "submitBtn";
|
||||
this.submitBtn.Size = new System.Drawing.Size(88, 27);
|
||||
this.submitBtn.TabIndex = 8;
|
||||
this.submitBtn.Text = "Submit";
|
||||
this.submitBtn.UseVisualStyleBackColor = true;
|
||||
this.submitBtn.Click += new System.EventHandler(this.submitBtn_Click);
|
||||
//
|
||||
// localeLbl
|
||||
//
|
||||
this.localeLbl.AutoSize = true;
|
||||
this.localeLbl.Location = new System.Drawing.Point(14, 10);
|
||||
this.localeLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
|
||||
this.localeLbl.Name = "localeLbl";
|
||||
this.localeLbl.Size = new System.Drawing.Size(61, 15);
|
||||
this.localeLbl.TabIndex = 0;
|
||||
this.localeLbl.Text = "Locale: {0}";
|
||||
//
|
||||
// usernameLbl
|
||||
//
|
||||
this.usernameLbl.AutoSize = true;
|
||||
this.usernameLbl.Location = new System.Drawing.Point(14, 25);
|
||||
this.usernameLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
|
||||
this.usernameLbl.Name = "usernameLbl";
|
||||
this.usernameLbl.Size = new System.Drawing.Size(80, 15);
|
||||
this.usernameLbl.TabIndex = 1;
|
||||
this.usernameLbl.Text = "Username: {0}";
|
||||
//
|
||||
// loginUrlLbl
|
||||
//
|
||||
this.loginUrlLbl.AutoSize = true;
|
||||
this.loginUrlLbl.Location = new System.Drawing.Point(14, 61);
|
||||
this.loginUrlLbl.Name = "loginUrlLbl";
|
||||
this.loginUrlLbl.Size = new System.Drawing.Size(180, 15);
|
||||
this.loginUrlLbl.TabIndex = 2;
|
||||
this.loginUrlLbl.Text = "Paste this URL into your browser:";
|
||||
//
|
||||
// loginUrlTb
|
||||
//
|
||||
this.loginUrlTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LoginExternalDialog));
|
||||
this.submitBtn = new System.Windows.Forms.Button();
|
||||
this.localeLbl = new System.Windows.Forms.Label();
|
||||
this.usernameLbl = new System.Windows.Forms.Label();
|
||||
this.loginUrlLbl = new System.Windows.Forms.Label();
|
||||
this.loginUrlTb = new System.Windows.Forms.TextBox();
|
||||
this.copyBtn = new System.Windows.Forms.Button();
|
||||
this.launchBrowserBtn = new System.Windows.Forms.Button();
|
||||
this.instructionsLbl = new System.Windows.Forms.Label();
|
||||
this.responseUrlTb = new System.Windows.Forms.TextBox();
|
||||
this.tldrLbl = new System.Windows.Forms.Label();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// submitBtn
|
||||
//
|
||||
this.submitBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.submitBtn.Location = new System.Drawing.Point(665, 458);
|
||||
this.submitBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.submitBtn.Name = "submitBtn";
|
||||
this.submitBtn.Size = new System.Drawing.Size(88, 27);
|
||||
this.submitBtn.TabIndex = 8;
|
||||
this.submitBtn.Text = "Submit";
|
||||
this.submitBtn.UseVisualStyleBackColor = true;
|
||||
this.submitBtn.Click += new System.EventHandler(this.submitBtn_Click);
|
||||
//
|
||||
// localeLbl
|
||||
//
|
||||
this.localeLbl.AutoSize = true;
|
||||
this.localeLbl.Location = new System.Drawing.Point(14, 10);
|
||||
this.localeLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
|
||||
this.localeLbl.Name = "localeLbl";
|
||||
this.localeLbl.Size = new System.Drawing.Size(61, 15);
|
||||
this.localeLbl.TabIndex = 0;
|
||||
this.localeLbl.Text = "Locale: {0}";
|
||||
//
|
||||
// usernameLbl
|
||||
//
|
||||
this.usernameLbl.AutoSize = true;
|
||||
this.usernameLbl.Location = new System.Drawing.Point(14, 25);
|
||||
this.usernameLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
|
||||
this.usernameLbl.Name = "usernameLbl";
|
||||
this.usernameLbl.Size = new System.Drawing.Size(80, 15);
|
||||
this.usernameLbl.TabIndex = 1;
|
||||
this.usernameLbl.Text = "Username: {0}";
|
||||
//
|
||||
// loginUrlLbl
|
||||
//
|
||||
this.loginUrlLbl.AutoSize = true;
|
||||
this.loginUrlLbl.Location = new System.Drawing.Point(14, 61);
|
||||
this.loginUrlLbl.Name = "loginUrlLbl";
|
||||
this.loginUrlLbl.Size = new System.Drawing.Size(180, 15);
|
||||
this.loginUrlLbl.TabIndex = 2;
|
||||
this.loginUrlLbl.Text = "Paste this URL into your browser:";
|
||||
//
|
||||
// loginUrlTb
|
||||
//
|
||||
this.loginUrlTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.loginUrlTb.Location = new System.Drawing.Point(14, 79);
|
||||
this.loginUrlTb.Multiline = true;
|
||||
this.loginUrlTb.Name = "loginUrlTb";
|
||||
this.loginUrlTb.ReadOnly = true;
|
||||
this.loginUrlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.loginUrlTb.Size = new System.Drawing.Size(739, 92);
|
||||
this.loginUrlTb.TabIndex = 3;
|
||||
//
|
||||
// copyBtn
|
||||
//
|
||||
this.copyBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.copyBtn.Location = new System.Drawing.Point(14, 177);
|
||||
this.copyBtn.Name = "copyBtn";
|
||||
this.copyBtn.Size = new System.Drawing.Size(165, 23);
|
||||
this.copyBtn.TabIndex = 4;
|
||||
this.copyBtn.Text = "Copy URL to clipboard";
|
||||
this.copyBtn.UseVisualStyleBackColor = true;
|
||||
this.copyBtn.Click += new System.EventHandler(this.copyBtn_Click);
|
||||
//
|
||||
// launchBrowserBtn
|
||||
//
|
||||
this.launchBrowserBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.launchBrowserBtn.Location = new System.Drawing.Point(588, 177);
|
||||
this.launchBrowserBtn.Name = "launchBrowserBtn";
|
||||
this.launchBrowserBtn.Size = new System.Drawing.Size(165, 23);
|
||||
this.launchBrowserBtn.TabIndex = 5;
|
||||
this.launchBrowserBtn.Text = "Launch in browser";
|
||||
this.launchBrowserBtn.UseVisualStyleBackColor = true;
|
||||
this.launchBrowserBtn.Click += new System.EventHandler(this.launchBrowserBtn_Click);
|
||||
//
|
||||
// instructionsLbl
|
||||
//
|
||||
this.instructionsLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.instructionsLbl.AutoSize = true;
|
||||
this.instructionsLbl.Location = new System.Drawing.Point(14, 203);
|
||||
this.instructionsLbl.Name = "instructionsLbl";
|
||||
this.instructionsLbl.Size = new System.Drawing.Size(436, 90);
|
||||
this.instructionsLbl.TabIndex = 6;
|
||||
this.instructionsLbl.Text = resources.GetString("instructionsLbl.Text");
|
||||
//
|
||||
// responseUrlTb
|
||||
//
|
||||
this.responseUrlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)
|
||||
this.loginUrlTb.Location = new System.Drawing.Point(14, 79);
|
||||
this.loginUrlTb.Multiline = true;
|
||||
this.loginUrlTb.Name = "loginUrlTb";
|
||||
this.loginUrlTb.ReadOnly = true;
|
||||
this.loginUrlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.loginUrlTb.Size = new System.Drawing.Size(739, 117);
|
||||
this.loginUrlTb.TabIndex = 3;
|
||||
//
|
||||
// copyBtn
|
||||
//
|
||||
this.copyBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.copyBtn.Location = new System.Drawing.Point(14, 202);
|
||||
this.copyBtn.Name = "copyBtn";
|
||||
this.copyBtn.Size = new System.Drawing.Size(165, 23);
|
||||
this.copyBtn.TabIndex = 4;
|
||||
this.copyBtn.Text = "Copy URL to clipboard";
|
||||
this.copyBtn.UseVisualStyleBackColor = true;
|
||||
this.copyBtn.Click += new System.EventHandler(this.copyBtn_Click);
|
||||
//
|
||||
// launchBrowserBtn
|
||||
//
|
||||
this.launchBrowserBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.launchBrowserBtn.Location = new System.Drawing.Point(589, 202);
|
||||
this.launchBrowserBtn.Name = "launchBrowserBtn";
|
||||
this.launchBrowserBtn.Size = new System.Drawing.Size(165, 23);
|
||||
this.launchBrowserBtn.TabIndex = 5;
|
||||
this.launchBrowserBtn.Text = "Launch in browser";
|
||||
this.launchBrowserBtn.UseVisualStyleBackColor = true;
|
||||
this.launchBrowserBtn.Click += new System.EventHandler(this.launchBrowserBtn_Click);
|
||||
//
|
||||
// instructionsLbl
|
||||
//
|
||||
this.instructionsLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.instructionsLbl.AutoSize = true;
|
||||
this.instructionsLbl.Location = new System.Drawing.Point(14, 261);
|
||||
this.instructionsLbl.Name = "instructionsLbl";
|
||||
this.instructionsLbl.Size = new System.Drawing.Size(436, 90);
|
||||
this.instructionsLbl.TabIndex = 6;
|
||||
this.instructionsLbl.Text = resources.GetString("instructionsLbl.Text");
|
||||
//
|
||||
// responseUrlTb
|
||||
//
|
||||
this.responseUrlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.responseUrlTb.Location = new System.Drawing.Point(14, 296);
|
||||
this.responseUrlTb.Multiline = true;
|
||||
this.responseUrlTb.Name = "responseUrlTb";
|
||||
this.responseUrlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.responseUrlTb.Size = new System.Drawing.Size(739, 98);
|
||||
this.responseUrlTb.TabIndex = 7;
|
||||
//
|
||||
// LoginExternalDialog
|
||||
//
|
||||
this.AcceptButton = this.submitBtn;
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(766, 440);
|
||||
this.Controls.Add(this.responseUrlTb);
|
||||
this.Controls.Add(this.instructionsLbl);
|
||||
this.Controls.Add(this.launchBrowserBtn);
|
||||
this.Controls.Add(this.copyBtn);
|
||||
this.Controls.Add(this.loginUrlTb);
|
||||
this.Controls.Add(this.loginUrlLbl);
|
||||
this.Controls.Add(this.usernameLbl);
|
||||
this.Controls.Add(this.localeLbl);
|
||||
this.Controls.Add(this.submitBtn);
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.MaximizeBox = false;
|
||||
this.MinimizeBox = false;
|
||||
this.Name = "LoginExternalDialog";
|
||||
this.ShowIcon = false;
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Audible External Login";
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
this.responseUrlTb.Location = new System.Drawing.Point(14, 354);
|
||||
this.responseUrlTb.Multiline = true;
|
||||
this.responseUrlTb.Name = "responseUrlTb";
|
||||
this.responseUrlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.responseUrlTb.Size = new System.Drawing.Size(739, 98);
|
||||
this.responseUrlTb.TabIndex = 7;
|
||||
//
|
||||
// tldrLbl
|
||||
//
|
||||
this.tldrLbl.AutoSize = true;
|
||||
this.tldrLbl.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point);
|
||||
this.tldrLbl.Location = new System.Drawing.Point(14, 237);
|
||||
this.tldrLbl.Name = "tldrLbl";
|
||||
this.tldrLbl.Size = new System.Drawing.Size(421, 15);
|
||||
this.tldrLbl.TabIndex = 9;
|
||||
this.tldrLbl.Text = "tl;dr : an ERROR on Amazon is GOOD. Sorry, I can\'t control their weird login";
|
||||
//
|
||||
// LoginExternalDialog
|
||||
//
|
||||
this.AcceptButton = this.submitBtn;
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(766, 498);
|
||||
this.Controls.Add(this.tldrLbl);
|
||||
this.Controls.Add(this.responseUrlTb);
|
||||
this.Controls.Add(this.instructionsLbl);
|
||||
this.Controls.Add(this.launchBrowserBtn);
|
||||
this.Controls.Add(this.copyBtn);
|
||||
this.Controls.Add(this.loginUrlTb);
|
||||
this.Controls.Add(this.loginUrlLbl);
|
||||
this.Controls.Add(this.usernameLbl);
|
||||
this.Controls.Add(this.localeLbl);
|
||||
this.Controls.Add(this.submitBtn);
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.MaximizeBox = false;
|
||||
this.MinimizeBox = false;
|
||||
this.Name = "LoginExternalDialog";
|
||||
this.ShowIcon = false;
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Audible External Login";
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
}
|
||||
|
||||
@@ -175,5 +187,6 @@
|
||||
private System.Windows.Forms.Button launchBrowserBtn;
|
||||
private System.Windows.Forms.Label instructionsLbl;
|
||||
private System.Windows.Forms.TextBox responseUrlTb;
|
||||
}
|
||||
private System.Windows.Forms.Label tldrLbl;
|
||||
}
|
||||
}
|
||||
993
LibationWinForms/Dialogs/SettingsDialog.Designer.cs
generated
993
LibationWinForms/Dialogs/SettingsDialog.Designer.cs
generated
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ namespace LibationWinForms.Dialogs
|
||||
loggingLevelCb.SelectedItem = config.LogLevel;
|
||||
}
|
||||
|
||||
this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats));
|
||||
this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes));
|
||||
this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes));
|
||||
this.booksLocationDescLbl.Text = desc(nameof(config.Books));
|
||||
@@ -46,6 +47,7 @@ namespace LibationWinForms.Dialogs
|
||||
"Books");
|
||||
booksSelectControl.SelectDirectory(config.Books);
|
||||
|
||||
showImportedStatsCb.Checked = config.ShowImportedStats;
|
||||
importEpisodesCb.Checked = config.ImportEpisodes;
|
||||
downloadEpisodesCb.Checked = config.DownloadEpisodes;
|
||||
allowLibationFixupCbox.Checked = config.AllowLibationFixup;
|
||||
@@ -108,19 +110,11 @@ namespace LibationWinForms.Dialogs
|
||||
private void chapterFileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterFile, chapterFileTemplateTb);
|
||||
private static void editTemplate(Templates template, TextBox textBox)
|
||||
{
|
||||
#if !DEBUG
|
||||
TEMP_TEMP_TEMP();
|
||||
return;
|
||||
#endif
|
||||
|
||||
var form = new EditTemplateDialog(template, textBox.Text);
|
||||
if (form.ShowDialog() == DialogResult.OK)
|
||||
textBox.Text = form.TemplateText;
|
||||
}
|
||||
|
||||
private static void TEMP_TEMP_TEMP()
|
||||
=> MessageBox.Show("Sorry, not yet. Coming soon :)");
|
||||
|
||||
private void saveBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
var newBooks = booksSelectControl.SelectedDirectory;
|
||||
@@ -174,6 +168,7 @@ namespace LibationWinForms.Dialogs
|
||||
MessageBoxVerboseLoggingWarning.ShowIfTrue();
|
||||
}
|
||||
|
||||
config.ShowImportedStats = showImportedStatsCb.Checked;
|
||||
config.ImportEpisodes = importEpisodesCb.Checked;
|
||||
config.DownloadEpisodes = downloadEpisodesCb.Checked;
|
||||
config.AllowLibationFixup = allowLibationFixupCbox.Checked;
|
||||
|
||||
@@ -61,7 +61,7 @@ namespace LibationWinForms
|
||||
// suppressed filter while init'ing UI
|
||||
var prev_isProcessingGridSelect = isProcessingGridSelect;
|
||||
isProcessingGridSelect = true;
|
||||
this.UIThreadSync(() => setGrid());
|
||||
this.UIThreadSync(setGrid);
|
||||
isProcessingGridSelect = prev_isProcessingGridSelect;
|
||||
|
||||
// UI init complete. now we can apply filter
|
||||
@@ -334,7 +334,8 @@ namespace LibationWinForms
|
||||
var totalProcessed = dialog.TotalBooksProcessed;
|
||||
var newAdded = dialog.NewBooksAdded;
|
||||
|
||||
MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}");
|
||||
if (Configuration.Instance.ShowImportedStats)
|
||||
MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}");
|
||||
}
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace LibationWinForms
|
||||
/// </summary>
|
||||
internal class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable
|
||||
{
|
||||
#region implementation properties
|
||||
#region implementation properties NOT exposed to the view
|
||||
// hide from public fields from Data Source GUI with [Browsable(false)]
|
||||
|
||||
[Browsable(false)]
|
||||
@@ -28,10 +28,52 @@ namespace LibationWinForms
|
||||
|
||||
#endregion
|
||||
|
||||
#region Model properties exposed to the view
|
||||
private Image _cover;
|
||||
public Image Cover
|
||||
{
|
||||
get => _cover;
|
||||
private set
|
||||
{
|
||||
_cover = value;
|
||||
NotifyPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string ProductRating { get; }
|
||||
public string PurchaseDate { get; }
|
||||
public string MyRating { get; }
|
||||
public string Series { get; }
|
||||
public string Title { get; }
|
||||
public string Length { get; }
|
||||
public string Authors { get; }
|
||||
public string Narrators { get; }
|
||||
public string Category { get; }
|
||||
public string Misc { get; }
|
||||
public string Description { get; }
|
||||
public string DisplayTags
|
||||
{
|
||||
get => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
|
||||
set => Book.UserDefinedItem.Tags = value;
|
||||
}
|
||||
|
||||
// these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint
|
||||
public (LiberatedStatus BookStatus, LiberatedStatus? PdfStatus) Liberate
|
||||
{
|
||||
get => (LibraryCommands.Liberated_Status(LibraryBook.Book), LibraryCommands.Pdf_Status(LibraryBook.Book));
|
||||
|
||||
set
|
||||
{
|
||||
LibraryBook.Book.UserDefinedItem.BookStatus = value.BookStatus;
|
||||
LibraryBook.Book.UserDefinedItem.PdfStatus = value.PdfStatus;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public event EventHandler Committed;
|
||||
|
||||
private Book Book => LibraryBook.Book;
|
||||
private Image _cover;
|
||||
|
||||
public GridEntry(LibraryBook libraryBook)
|
||||
{
|
||||
@@ -145,53 +187,13 @@ namespace LibationWinForms
|
||||
Committed?.Invoke(this, null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Model properties exposed to the view
|
||||
public Image Cover
|
||||
{
|
||||
get
|
||||
{
|
||||
return _cover;
|
||||
}
|
||||
private set
|
||||
{
|
||||
_cover = value;
|
||||
NotifyPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string ProductRating { get; }
|
||||
public string PurchaseDate { get; }
|
||||
public string MyRating { get; }
|
||||
public string Series { get; }
|
||||
public string Title { get; }
|
||||
public string Length { get; }
|
||||
public string Authors { get; }
|
||||
public string Narrators { get; }
|
||||
public string Category { get; }
|
||||
public string Misc { get; }
|
||||
public string Description { get; }
|
||||
public string DisplayTags
|
||||
{
|
||||
get => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
|
||||
set => Book.UserDefinedItem.Tags = value;
|
||||
}
|
||||
// these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint
|
||||
public (LiberatedStatus BookStatus, LiberatedStatus? PdfStatus) Liberate
|
||||
{
|
||||
get => (LibraryCommands.Liberated_Status(LibraryBook.Book), LibraryCommands.Pdf_Status(LibraryBook.Book));
|
||||
|
||||
set
|
||||
{
|
||||
LibraryBook.Book.UserDefinedItem.BookStatus = value.BookStatus;
|
||||
LibraryBook.Book.UserDefinedItem.PdfStatus = value.PdfStatus;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Sorting
|
||||
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
|
||||
// Used by Dinah.Core.DataBinding.SortableBindingList<T> for all sorting
|
||||
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
|
||||
public virtual IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
|
||||
|
||||
private Dictionary<string, Func<object>> _memberValues { get; }
|
||||
|
||||
@@ -225,9 +227,6 @@ namespace LibationWinForms
|
||||
{ typeof(LiberatedStatus), new ObjectComparer<LiberatedStatus>() },
|
||||
};
|
||||
|
||||
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
|
||||
public virtual IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
|
||||
|
||||
private static readonly string[] _sortPrefixIgnores = { "the", "a", "an" };
|
||||
private static string GetSortName(string unformattedName)
|
||||
{
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net5.0-windows</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>libation.ico</ApplicationIcon>
|
||||
<AssemblyName>Libation</AssemblyName>
|
||||
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
@@ -29,7 +28,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="2.0.2.2" />
|
||||
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="4.0.6.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -71,7 +71,7 @@ namespace LibationWinForms
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Liberate_Click(GridEntry liveGridEntry)
|
||||
private static async Task Liberate_Click(GridEntry liveGridEntry)
|
||||
{
|
||||
var libraryBook = liveGridEntry.LibraryBook;
|
||||
|
||||
@@ -91,7 +91,7 @@ namespace LibationWinForms
|
||||
await BookLiberation.ProcessorAutomationController.BackupSingleBookAsync(libraryBook);
|
||||
}
|
||||
|
||||
private void Details_Click(GridEntry liveGridEntry)
|
||||
private static void Details_Click(GridEntry liveGridEntry)
|
||||
{
|
||||
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
|
||||
if (bookDetailsForm.ShowDialog() != DialogResult.OK)
|
||||
@@ -107,6 +107,11 @@ namespace LibationWinForms
|
||||
|
||||
#endregion
|
||||
|
||||
private SortableBindingList<GridEntry> bindingList;
|
||||
|
||||
/// <summary>Insert ad hoc library books to top of grid</summary>
|
||||
public void AddToTop(DataLayer.LibraryBook libraryBook) => bindingList.Insert(0, new GridEntry(libraryBook));
|
||||
|
||||
#region UI display functions
|
||||
|
||||
private bool hasBeenDisplayed = false;
|
||||
@@ -145,7 +150,8 @@ namespace LibationWinForms
|
||||
.ToList();
|
||||
|
||||
// BIND
|
||||
gridEntryBindingSource.DataSource = new SortableBindingList<GridEntry>(orderedGridEntries);
|
||||
bindingList = new SortableBindingList<GridEntry>(orderedGridEntries);
|
||||
gridEntryBindingSource.DataSource = bindingList;
|
||||
|
||||
// FILTER
|
||||
Filter();
|
||||
|
||||
@@ -30,9 +30,7 @@ namespace LibationWinForms
|
||||
//// Only use while debugging. Acts erratically in the wild
|
||||
//AllocConsole();
|
||||
|
||||
Application.SetHighDpiMode(HighDpiMode.SystemAware);
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
@@ -140,9 +138,10 @@ namespace LibationWinForms
|
||||
}
|
||||
}
|
||||
|
||||
// INIT DEFAULT SETTINGS
|
||||
// if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog
|
||||
config.Books ??= Path.Combine(defaultLibationFilesDir, "Books");
|
||||
config.InProgress ??= Configuration.WinTemp;
|
||||
AppScaffolding.LibationScaffolding.PopulateMissingConfigValues(config);
|
||||
|
||||
if (new SettingsDialog().ShowDialog() != DialogResult.OK)
|
||||
{
|
||||
@@ -229,7 +228,6 @@ namespace LibationWinForms
|
||||
var libhackFiles = Directory.EnumerateDirectories(config.Books, "*.libhack", SearchOption.AllDirectories);
|
||||
|
||||
using var context = ApplicationServices.DbContexts.GetContext();
|
||||
context.Books.Load();
|
||||
|
||||
var jArr = JArray.Parse(File.ReadAllText(filePaths));
|
||||
|
||||
@@ -249,7 +247,7 @@ namespace LibationWinForms
|
||||
if (fileType == FileType.Unknown || fileType == FileType.AAXC)
|
||||
continue;
|
||||
|
||||
var book = context.Books.Local.FirstOrDefault(b => b.AudibleProductId == asin);
|
||||
var book = context.Books.FirstOrDefault(b => b.AudibleProductId == asin);
|
||||
if (book is null)
|
||||
continue;
|
||||
|
||||
|
||||
49
README.md
49
README.md
@@ -12,6 +12,8 @@
|
||||
- [The bad](#the-bad)
|
||||
- [The ugly](#the-ugly)
|
||||
2. [Getting started](#getting-started)
|
||||
- [Download Libation](#download-libation-1)
|
||||
- [Installation](#installation)
|
||||
- [Create Accounts](#create-accounts)
|
||||
- [Import your library](#import-your-library)
|
||||
- [Download your books -- DRM-free!](#download-your-books----drm-free)
|
||||
@@ -27,6 +29,7 @@
|
||||
- [Files and folders](#files-and-folders)
|
||||
- [Linux and Mac (unofficial)](#linux-and-mac)
|
||||
- [Settings](#settings)
|
||||
- [Custom File Naming](#custom-file-naming)
|
||||
- [Command Line Interface](#command-line-interface)
|
||||
|
||||
## Audible audiobook manager
|
||||
@@ -63,7 +66,11 @@ I made this for myself and I want to share it with the great programming and aud
|
||||
|
||||
## Getting started
|
||||
|
||||
#### [Download Libation](https://github.com/rmcrackan/Libation/releases)
|
||||
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
|
||||
|
||||
### Installation
|
||||
|
||||
To install Libation, extract the zip file to a folder, for example `C:\Libation`, and then run Libation.exe from that folder to begin the configuration process and configure your account(s).
|
||||
|
||||
### Create Accounts
|
||||
|
||||
@@ -85,6 +92,26 @@ Or if you have multiple accounts, you'll get to choose whether to scan all accou
|
||||
|
||||

|
||||
|
||||
If this is a new installation, or you're scanning an account you haven't scanned before, you'll be prompted to enter your password for the Audible account.
|
||||
|
||||

|
||||
|
||||
Enter the password and click Submit. Audible will prompt you with a CAPTCHA image.
|
||||
|
||||

|
||||
|
||||
Enter the CAPTCHA answer characters and click Submit. If all has gone well, Libation will start scanning the account.
|
||||
|
||||
In rare instances, the Captcha image/response will fail in an endless loop. If this happens, delete the problem account, and then click Save. Re-add the account and click Save again. Now try to scan the account again. This time, instead of typing your password, click the link that says "Or click here". This will open the Audible External Login dialog shown below.
|
||||
|
||||

|
||||
|
||||
You can either copy the URL shown and paste it into your browser or launch the browser directly by clicking Launch in Browser. Audible will display its standard login page. Login, including answering the CAPTCHA on the next page. In some cases, you might have to approve the login from the email account associated with that login, but once the login is successful, you'll see an error message.
|
||||
|
||||

|
||||
|
||||
This actually means you've successfully logged in. Copy the entire URL shown in your browser and return to Libation. Paste that URL into the text box at the bottom of the Audible External Login window and click Submit.
|
||||
|
||||
You'll see this window while it's scanning:
|
||||
|
||||

|
||||
@@ -149,7 +176,6 @@ When you set up Libation, you'll specify a Books directory. Libation looks insid
|
||||
|
||||
* .m4b: your audiobook in m4b format. This is the most pure version of your audiobook and retains the highest quality. Now that it's decrypted, you can play it on any audio player and put it on any device. If you'd like, you can also use 3rd party tools to turn it into an mp3. The freedom to do what you want with your files was the original inspiration for Libation.
|
||||
* .cue: this is a file which logs where chapter breaks occur. Many tools are able to use this if you want to split your book into files along chapter lines.
|
||||
* .nfo: This is just some general info about the book and includes some technical stats about the audiofile.
|
||||
|
||||
### Export your library
|
||||
|
||||
@@ -237,12 +263,18 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
||||
|
||||
### Linux and Mac
|
||||
|
||||
Although Libation only currently officially supports Windows, [some users](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158) have had success with WINE.
|
||||
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592))
|
||||
|
||||
### Settings
|
||||
|
||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
||||
|
||||
### Custom File Naming
|
||||
|
||||
In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `<title short> - <ch# 0> of <ch count> - <ch title>` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`.
|
||||
|
||||
These templates apply to GUI and CLI.
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
||||
@@ -281,14 +313,3 @@ export library to file
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
||||
```
|
||||
|
||||
Currently logs are written to Console and to file. This means they'll be printed in the CLI. To disable, find this in Settings.json and delete the 3 lines after `"WriteTo": [`
|
||||
|
||||
```
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information",
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console"
|
||||
},
|
||||
```
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.0">
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Moq" Version="4.17.2" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using FileLiberator;
|
||||
using FluentAssertions;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
using static AudioFileStorageExtTests.Shared;
|
||||
|
||||
namespace AudioFileStorageExtTests
|
||||
{
|
||||
public static class Shared
|
||||
{
|
||||
public static DataLayer.LibraryBook GetLibraryBook(string asin)
|
||||
{
|
||||
var book = new DataLayer.Book(new DataLayer.AudibleProductId(asin), "title", "desc", 1, DataLayer.ContentType.Product, new List<DataLayer.Contributor> { new DataLayer.Contributor("author") }, new List<DataLayer.Contributor> { new DataLayer.Contributor("narrator") }, new DataLayer.Category(new DataLayer.AudibleCategoryId("seriesId"), "name"), "us");
|
||||
var libraryBook = new DataLayer.LibraryBook(book, DateTime.Now, "my us");
|
||||
return libraryBook;
|
||||
}
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class MultipartRenamer_MultipartFilename
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow("asin", "[<id>] <ch# 0> of <ch count> - <ch title>", @"C:\foo\", "txt", 6, 10, "chap", @"C:\foo\[asin] 06 of 10 - chap.txt")]
|
||||
[DataRow("asin", "<ch#>", @"C:\foo\", "txt", 6, 10, "chap", @"C:\foo\6.txt")]
|
||||
public void Tests(string asin, string template, string dir, string ext, int pos, int total, string chapter, string expected)
|
||||
=> new AudioFileStorageExt.MultipartRenamer(GetLibraryBook(asin))
|
||||
.MultipartFilename(template, dir, ext, pos, total, chapter)
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class GetFileTemplateSingle
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow(null, "asin", @"C:\", "ext")]
|
||||
[DataRow("f.txt", null, @"C:\", "ext")]
|
||||
[DataRow("f.txt", "asin", null, "ext")]
|
||||
[ExpectedException(typeof(ArgumentNullException))]
|
||||
public void arg_null_exception(string template, string asin, string dirFullPath, string extension)
|
||||
=> AudioFileStorageExt.GetFileTemplateSingle(template, GetLibraryBook(asin), dirFullPath, extension);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("", "asin", @"C:\foo\bar", "ext")]
|
||||
[DataRow(" ", "asin", @"C:\foo\bar", "ext")]
|
||||
[DataRow("f.txt", "", @"C:\foo\bar", "ext")]
|
||||
[DataRow("f.txt", " ", @"C:\foo\bar", "ext")]
|
||||
[DataRow("f.txt", "asin", "", "ext")]
|
||||
[DataRow("f.txt", "asin", " ", "ext")]
|
||||
[ExpectedException(typeof(ArgumentException))]
|
||||
public void arg_exception(string template, string asin, string dirFullPath, string extension)
|
||||
=> AudioFileStorageExt.GetFileTemplateSingle(template, GetLibraryBook(asin), dirFullPath, extension);
|
||||
|
||||
[TestMethod]
|
||||
public void null_extension() => Tests("f.txt", "asin", @"C:\foo\bar", null, @"C:\foo\bar\f.txt");
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("f.txt", "asin", @"C:\foo\bar", "ext", @"C:\foo\bar\f.txt.ext")]
|
||||
[DataRow("f", "asin", @"C:\foo\bar", "ext", @"C:\foo\bar\f.ext")]
|
||||
[DataRow("<id>", "asin", @"C:\foo\bar", "ext", @"C:\foo\bar\asin.ext")]
|
||||
public void Tests(string template, string asin, string dirFullPath, string extension, string expected)
|
||||
=> AudioFileStorageExt.GetFileTemplateSingle(template, GetLibraryBook(asin), dirFullPath, extension)
|
||||
.GetFilePath()
|
||||
.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.0">
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.0">
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -7,7 +7,7 @@ using FileManager;
|
||||
using FluentAssertions;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace FileTemplateTests
|
||||
namespace FileNamingTemplateTests
|
||||
{
|
||||
[TestClass]
|
||||
public class GetFilePath
|
||||
@@ -17,7 +17,7 @@ namespace FileTemplateTests
|
||||
{
|
||||
var expected = @"C:\foo\bar\my_ book LONG_1234567890_1234567890_1234567890_123 [ID123456].txt";
|
||||
var f1 = OLD_GetValidFilename(@"C:\foo\bar", "my: book LONG_1234567890_1234567890_1234567890_12345", "txt", "ID123456");
|
||||
var f2 = NEW_GetValidFilename_FileTemplate(@"C:\foo\bar", "my: book LONG_1234567890_1234567890_1234567890_12345", "txt", "ID123456");
|
||||
var f2 = NEW_GetValidFilename_FileNamingTemplate(@"C:\foo\bar", "my: book LONG_1234567890_1234567890_1234567890_12345", "txt", "ID123456");
|
||||
|
||||
f1.Should().Be(expected);
|
||||
f1.Should().Be(f2);
|
||||
@@ -49,16 +49,16 @@ namespace FileTemplateTests
|
||||
|
||||
return fullfilename;
|
||||
}
|
||||
private static string NEW_GetValidFilename_FileTemplate(string dirFullPath, string filename, string extension, string metadataSuffix)
|
||||
private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string filename, string extension, string metadataSuffix)
|
||||
{
|
||||
var template = $"<title> [<id>]";
|
||||
|
||||
var fullfilename = Path.Combine(dirFullPath, template + FileUtility.GetStandardizedExtension(extension));
|
||||
|
||||
var fileTemplate = new FileTemplate(fullfilename) { IllegalCharacterReplacements = "_" };
|
||||
fileTemplate.AddParameterReplacement("title", filename);
|
||||
fileTemplate.AddParameterReplacement("id", metadataSuffix);
|
||||
return fileTemplate.GetFilePath();
|
||||
var fileNamingTemplate = new FileNamingTemplate(fullfilename) { IllegalCharacterReplacements = "_" };
|
||||
fileNamingTemplate.AddParameterReplacement("title", filename);
|
||||
fileNamingTemplate.AddParameterReplacement("id", metadataSuffix);
|
||||
return fileNamingTemplate.GetFilePath();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -66,7 +66,7 @@ namespace FileTemplateTests
|
||||
{
|
||||
var expected = @"C:\foo\bar\my file - 002 - title.txt";
|
||||
var f1 = OLD_GetMultipartFileName(@"C:\foo\bar\my file.txt", 2, 100, "title");
|
||||
var f2 = NEW_GetMultipartFileName_FileTemplate(@"C:\foo\bar\my file.txt", 2, 100, "title");
|
||||
var f2 = NEW_GetMultipartFileName_FileNamingTemplate(@"C:\foo\bar\my file.txt", 2, 100, "title");
|
||||
|
||||
f1.Should().Be(expected);
|
||||
f1.Should().Be(f2);
|
||||
@@ -89,7 +89,7 @@ namespace FileTemplateTests
|
||||
var path = Path.Combine(Path.GetDirectoryName(originalPath), fileName + extension);
|
||||
return path;
|
||||
}
|
||||
private static string NEW_GetMultipartFileName_FileTemplate(string originalPath, int partsPosition, int partsTotal, string suffix)
|
||||
private static string NEW_GetMultipartFileName_FileNamingTemplate(string originalPath, int partsPosition, int partsTotal, string suffix)
|
||||
{
|
||||
// 1-9 => 1-9
|
||||
// 10-99 => 01-99
|
||||
@@ -98,19 +98,18 @@ namespace FileTemplateTests
|
||||
|
||||
var t = Path.ChangeExtension(originalPath, null) + " - <chapter> - <title>" + Path.GetExtension(originalPath);
|
||||
|
||||
var fileTemplate = new FileTemplate(t) { IllegalCharacterReplacements = " " };
|
||||
fileTemplate.AddParameterReplacement("chapter", chapterCountLeadingZeros);
|
||||
fileTemplate.AddParameterReplacement("title", suffix);
|
||||
|
||||
return fileTemplate.GetFilePath();
|
||||
var fileNamingTemplate = new FileNamingTemplate(t) { IllegalCharacterReplacements = " " };
|
||||
fileNamingTemplate.AddParameterReplacement("chapter", chapterCountLeadingZeros);
|
||||
fileNamingTemplate.AddParameterReplacement("title", suffix);
|
||||
return fileNamingTemplate.GetFilePath();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void remove_slashes()
|
||||
{
|
||||
var fileTemplate = new FileTemplate(@"\foo\<title>.txt");
|
||||
fileTemplate.AddParameterReplacement("title", @"s\l/a\s/h\e/s");
|
||||
fileTemplate.GetFilePath().Should().Be(@"\foo\slashes.txt");
|
||||
var fileNamingTemplate = new FileNamingTemplate(@"\foo\<title>.txt");
|
||||
fileNamingTemplate.AddParameterReplacement("title", @"s\l/a\s/h\e/s");
|
||||
fileNamingTemplate.GetFilePath().Should().Be(@"\foo\slashes.txt");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,4 +113,51 @@ namespace FileUtilityTests
|
||||
public void Tests(string input, string expected)
|
||||
=> FileUtility.GetStandardizedExtension(input).Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class GetValidFilename
|
||||
{
|
||||
[TestMethod]
|
||||
// dot-files
|
||||
[DataRow(@"C:\a bc\x y z\.f i l e.txt")]
|
||||
// dot-folders
|
||||
[DataRow(@"C:\a bc\.x y z\f i l e.txt")]
|
||||
public void Valid(string input) => Tests(input, input);
|
||||
|
||||
[TestMethod]
|
||||
// folder spaces
|
||||
[DataRow(@"C:\ a bc \x y z ", @"C:\a bc\x y z")]
|
||||
// file spaces
|
||||
[DataRow(@"C:\a bc\x y z\ f i l e.txt ", @"C:\a bc\x y z\f i l e.txt")]
|
||||
// eliminate beginning space and end dots and spaces
|
||||
[DataRow(@"C:\a bc\ . . . x y z . . . \f i l e.txt", @"C:\a bc\. . . x y z\f i l e.txt")]
|
||||
// file end dots
|
||||
[DataRow(@"C:\a bc\x y z\f i l e.txt . . .", @"C:\a bc\x y z\f i l e.txt")]
|
||||
public void Tests(string input, string expected)
|
||||
=> FileUtility.GetValidFilename(input).Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class RemoveLastCharacter
|
||||
{
|
||||
[TestMethod]
|
||||
public void is_null() => Tests(null, null);
|
||||
|
||||
[TestMethod]
|
||||
public void empty() => Tests("", "");
|
||||
|
||||
[TestMethod]
|
||||
public void single_space() => Tests(" ", "");
|
||||
|
||||
[TestMethod]
|
||||
public void multiple_space() => Tests(" ", " ");
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("1", "")]
|
||||
[DataRow("1 ", "1")]
|
||||
[DataRow("12", "1")]
|
||||
[DataRow("123", "12")]
|
||||
public void Tests(string input, string expected)
|
||||
=> FileUtility.RemoveLastCharacter(input).Should().Be(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.0">
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -7,8 +7,26 @@ using FluentAssertions;
|
||||
using LibationFileManager;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
using static TemplatesTests.Shared;
|
||||
|
||||
namespace TemplatesTests
|
||||
{
|
||||
public static class Shared
|
||||
{
|
||||
public static LibraryBookDto GetLibraryBook(string asin, string seriesName = "Sherlock Holmes")
|
||||
=> new()
|
||||
{
|
||||
Account = "my account",
|
||||
AudibleProductId = asin,
|
||||
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
Locale = "us",
|
||||
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
|
||||
Narrators = new List<string> { "Stephen Fry" },
|
||||
SeriesName = seriesName ?? "",
|
||||
SeriesNumber = "1"
|
||||
};
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class ContainsChapterOnlyTags
|
||||
{
|
||||
@@ -29,6 +47,53 @@ namespace TemplatesTests
|
||||
[DataRow("<id><ch#>", "ch#", true)]
|
||||
public void Tests(string template, string tag, bool expected) => Templates.ContainsTag(template, tag).Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class getFileNamingTemplate
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow(null, "asin", @"C:\", "ext")]
|
||||
[ExpectedException(typeof(ArgumentNullException))]
|
||||
public void arg_null_exception(string template, string asin, string dirFullPath, string extension)
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook(asin), template, dirFullPath, extension);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("", "asin", @"C:\foo\bar", "ext")]
|
||||
[DataRow(" ", "asin", @"C:\foo\bar", "ext")]
|
||||
[ExpectedException(typeof(ArgumentException))]
|
||||
public void arg_exception(string template, string asin, string dirFullPath, string extension)
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook(asin), template, dirFullPath, extension);
|
||||
|
||||
[TestMethod]
|
||||
public void null_extension() => Tests("f.txt", "asin", @"C:\foo\bar", null, @"C:\foo\bar\f.txt");
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("f.txt", "asin", @"C:\foo\bar", "ext", @"C:\foo\bar\f.txt.ext")]
|
||||
[DataRow("f", "asin", @"C:\foo\bar", "ext", @"C:\foo\bar\f.ext")]
|
||||
[DataRow("<id>", "asin", @"C:\foo\bar", "ext", @"C:\foo\bar\asin.ext")]
|
||||
public void Tests(string template, string asin, string dirFullPath, string extension, string expected)
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook(asin), template, dirFullPath, extension)
|
||||
.GetFilePath()
|
||||
.Should().Be(expected);
|
||||
|
||||
[TestMethod]
|
||||
public void IfSeries_empty()
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook("asin", "Sherlock Holmes"), "foo<if series-><-if series>bar", @"C:\a\b", "ext")
|
||||
.GetFilePath()
|
||||
.Should().Be(@"C:\a\b\foobar.ext");
|
||||
|
||||
[TestMethod]
|
||||
public void IfSeries_no_series()
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook("asin", ""), "foo<if series->-<series>-<id>-<-if series>bar", @"C:\a\b", "ext")
|
||||
.GetFilePath()
|
||||
.Should().Be(@"C:\a\b\foobar.ext");
|
||||
|
||||
[TestMethod]
|
||||
public void IfSeries_with_series()
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook("asin", "Sherlock Holmes"), "foo<if series->-<series>-<id>-<-if series>bar", @"C:\a\b", "ext")
|
||||
.GetFilePath()
|
||||
.Should().Be(@"C:\a\b\foo-Sherlock Holmes-asin-bar.ext");
|
||||
}
|
||||
}
|
||||
|
||||
namespace Templates_Folder_Tests
|
||||
@@ -314,4 +379,15 @@ namespace Templates_ChapterFile_Tests
|
||||
[DataRow("<ID> case specific", 0)]
|
||||
public void Tests(string template, int expected) => Templates.ChapterFile.TagCount(template).Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class GetPortionFilename
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow("asin", "[<id>] <ch# 0> of <ch count> - <ch title>", @"C:\foo\", "txt", 6, 10, "chap", @"C:\foo\[asin] 06 of 10 - chap.txt")]
|
||||
[DataRow("asin", "<ch#>", @"C:\foo\", "txt", 6, 10, "chap", @"C:\foo\6.txt")]
|
||||
public void Tests(string asin, string template, string dir, string ext, int pos, int total, string chapter, string expected)
|
||||
=> Templates.ChapterFile.GetPortionFilename(GetLibraryBook(asin), template, new() { OutputFileName = $"xyz.{ext}", PartsPosition = pos, PartsTotal = total, Title = chapter }, dir)
|
||||
.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.0">
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Moq" Version="4.17.2" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -24,7 +24,7 @@ STRUCTURE
|
||||
* 2 Utilities (domain ignorant)
|
||||
Stand-alone libraries with no knowledge of anything having to do with Libation or other programs. In theory any of these should be able to one day be converted to a nuget pkg
|
||||
* 3 Domain Internal Utilities (db ignorant)
|
||||
Can have knowledge of Libation concepts. Cannot access the database.
|
||||
Cannot access the database. Can have knowledge of Libation concepts. Can even have knowledge of some db concepts, but no actual db access.
|
||||
* 4 Domain (db)
|
||||
All database access
|
||||
* 5 Domain Utilities (db aware)
|
||||
|
||||
BIN
images/alt-login1.png
Normal file
BIN
images/alt-login1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
BIN
images/alt-login2.png
Normal file
BIN
images/alt-login2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
images/alt-login3.png
Normal file
BIN
images/alt-login3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
images/alt-login4.png
Normal file
BIN
images/alt-login4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Reference in New Issue
Block a user