diff --git a/LibationLauncher/.msbump b/AppScaffolding/.msbump
similarity index 100%
rename from LibationLauncher/.msbump
rename to AppScaffolding/.msbump
diff --git a/AppScaffolding/AppScaffolding.csproj b/AppScaffolding/AppScaffolding.csproj
new file mode 100644
index 00000000..d98fb16c
--- /dev/null
+++ b/AppScaffolding/AppScaffolding.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+ net5.0
+ 5.6.8.1
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/AppScaffolding/LibationScaffolding.cs b/AppScaffolding/LibationScaffolding.cs
new file mode 100644
index 00000000..ecfcf307
--- /dev/null
+++ b/AppScaffolding/LibationScaffolding.cs
@@ -0,0 +1,325 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Dinah.Core;
+using Dinah.Core.IO;
+using Dinah.Core.Logging;
+using FileManager;
+using InternalUtilities;
+using Newtonsoft.Json.Linq;
+using Serilog;
+
+namespace AppScaffolding
+{
+ public static class LibationScaffolding
+ {
+ // AppScaffolding
+ private static Assembly _executingAssembly;
+ private static Assembly ExecutingAssembly
+ => _executingAssembly ??= Assembly.GetExecutingAssembly();
+
+ // LibationWinForms or LibationCli
+ private static Assembly _entryAssembly;
+ private static Assembly EntryAssembly
+ => _entryAssembly ??= Assembly.GetEntryAssembly();
+
+ // previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
+ private static Version _buildVersion;
+ public static Version BuildVersion
+ => _buildVersion
+ ??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
+ .Max(a => a.Version);
+
+ /// Run migrations before loading Configuration for the first time. Then load and return Configuration
+ public static Configuration RunPreConfigMigrations()
+ {
+ // must occur before access to Configuration instance
+ Migrations.migrate_to_v5_2_0__pre_config();
+
+ //***********************************************//
+ // //
+ // do not use Configuration before this line //
+ // //
+ //***********************************************//
+ return Configuration.Instance;
+ }
+
+ public static void RunPostConfigMigrations()
+ {
+ AudibleApiStorage.EnsureAccountsSettingsFileExists();
+
+ var config = Configuration.Instance;
+
+ //
+ // migrations go below here
+ //
+
+ Migrations.migrate_to_v5_2_0__post_config(config);
+ }
+
+ /// Initialize logging. Run after migration
+ public static void RunPostMigrationScaffolding()
+ {
+ var config = Configuration.Instance;
+
+ ensureSerilogConfig(config);
+ configureLogging(config);
+ logStartupState(config);
+ }
+
+ private static void ensureSerilogConfig(Configuration config)
+ {
+ 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", "File" },
+ { "Args",
+ new JObject
+ {
+ // for this sink to work, a path must be provided. we override this below
+ { "path", Path.Combine(config.LibationFiles, "_Log.log") },
+ { "rollingInterval", "Month" },
+ // Serilog template formatting examples
+ // - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
+ // 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}" }
+ }
+ }
+ }
+ }
+ },
+ { "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace
+ { "Enrich", new JArray{ "WithCaller" } },
+ };
+ config.SetObject("Serilog", serilogObj);
+ }
+
+ // to restore original: Console.SetOut(origOut);
+ private static TextWriter origOut { get; } = Console.Out;
+
+ private static void configureLogging(Configuration config)
+ {
+ config.ConfigureLogging();
+
+ // 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.
+ Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
+
+ #region Console => Serilog tests
+ /*
+ // all below apply to "Console." and "Console.Out."
+
+ // captured
+ Console.WriteLine("str");
+ Console.WriteLine(new { a = "anon" });
+ Console.WriteLine("{0}", "format");
+ Console.WriteLine("{0}{1}", "zero|", "one");
+ Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two");
+ Console.WriteLine("{0}", new object[] { "arr" });
+
+ // not captured
+ Console.WriteLine();
+ Console.WriteLine(true);
+ Console.WriteLine('0');
+ Console.WriteLine(1);
+ Console.WriteLine(2m);
+ Console.WriteLine(3f);
+ Console.WriteLine(4d);
+ Console.WriteLine(5L);
+ Console.WriteLine((uint)6);
+ Console.WriteLine((ulong)7);
+
+ Console.Write("str");
+ Console.Write(true);
+ Console.Write('0');
+ Console.Write(1);
+ Console.Write(2m);
+ Console.Write(3f);
+ Console.Write(4d);
+ Console.Write(5L);
+ Console.Write((uint)6);
+ Console.Write((ulong)7);
+ Console.Write(new { a = "anon" });
+ Console.Write("{0}", "format");
+ Console.Write("{0}{1}", "zero|", "one");
+ Console.Write("{0}{1}{2}", "zero|", "one|", "two");
+ Console.Write("{0}", new object[] { "arr" });
+ */
+ #endregion
+
+ // .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
+ //var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
+ //Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
+ }
+
+ private static void logStartupState(Configuration config)
+ {
+ // begin logging session with a form feed
+ Log.Logger.Information("\r\n\f");
+ Log.Logger.Information("Begin. {@DebugInfo}", new
+ {
+ AppName = EntryAssembly.GetName().Name,
+ Version = BuildVersion.ToString(),
+#if DEBUG
+ Mode = "Debug",
+#else
+ Mode = "Release",
+#endif
+
+ LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
+ LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
+ LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
+ LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
+ LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
+ LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
+
+ config.LibationFiles,
+ AudibleFileStorage.BooksDirectory,
+
+ config.InProgress,
+
+ DownloadsInProgressDir = AudibleFileStorage.DownloadsInProgress,
+ DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgress).Count(),
+
+ DecryptInProgressDir = AudibleFileStorage.DecryptInProgress,
+ DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgress).Count(),
+ });
+ }
+
+ public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease()
+ {
+ (bool, string, string, string) isFalse = (false, null, null, null);
+
+ // timed out
+ var latest = getLatestRelease(TimeSpan.FromSeconds(10));
+ if (latest is null)
+ return isFalse;
+
+ var latestVersionString = latest.TagName.Trim('v');
+ if (!Version.TryParse(latestVersionString, out var latestRelease))
+ return isFalse;
+
+ // we're up to date
+ if (latestRelease <= BuildVersion)
+ return isFalse;
+
+ // we have an update
+ var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
+ var zipUrl = zip?.BrowserDownloadUrl;
+
+ Log.Logger.Information("Update available: {@DebugInfo}", new
+ {
+ latestRelease = latestRelease.ToString(),
+ latest.HtmlUrl,
+ zipUrl
+ });
+
+ return (true, zipUrl, latest.HtmlUrl, zip.Name);
+ }
+ private static Octokit.Release getLatestRelease(TimeSpan timeout)
+ {
+ try
+ {
+ var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
+ if (task.Wait(timeout))
+ return task.Result;
+
+ Log.Logger.Information("Timed out");
+ }
+ catch (AggregateException aggEx)
+ {
+ Log.Logger.Error(aggEx, "Checking for new version too often");
+ }
+ return null;
+ }
+ private static Octokit.Release getLatestRelease()
+ {
+ var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation"));
+
+ // https://octokitnet.readthedocs.io/en/latest/releases/
+ var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult();
+ var latest = releases.First(r => !r.Draft && !r.Prerelease);
+ return latest;
+ }
+ }
+
+ internal static class Migrations
+ {
+ #region migrate to v5.2.0
+ // get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress
+ public static void migrate_to_v5_2_0__pre_config()
+ {
+ {
+ var settingsKey = "DownloadsInProgressEnum";
+ if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value))
+ {
+ UNSAFE_MigrationHelper.Settings_Delete(settingsKey);
+ UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value));
+ }
+ }
+
+ {
+ UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum");
+ }
+
+ { // appsettings.json
+ var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY;
+ if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value))
+ UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value));
+ }
+ }
+
+ private static string translatePath(string path)
+ => path switch
+ {
+ "AppDir" => @".\LibationFiles",
+ "MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")),
+ "UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")),
+ "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
+ }
+}
diff --git a/LibationLauncher/UNSAFE_MigrationHelper.cs b/AppScaffolding/UNSAFE_MigrationHelper.cs
similarity index 95%
rename from LibationLauncher/UNSAFE_MigrationHelper.cs
rename to AppScaffolding/UNSAFE_MigrationHelper.cs
index 8699e769..a0f937c8 100644
--- a/LibationLauncher/UNSAFE_MigrationHelper.cs
+++ b/AppScaffolding/UNSAFE_MigrationHelper.cs
@@ -6,7 +6,7 @@ using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
-namespace LibationLauncher
+namespace AppScaffolding
{
///
///
@@ -20,7 +20,7 @@ namespace LibationLauncher
internal static class UNSAFE_MigrationHelper
{
#region appsettings.json
- private const string APPSETTINGS_JSON = "appsettings.json";
+ private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
diff --git a/FileManager/Configuration.cs b/FileManager/Configuration.cs
index 770ce1c4..3faebd06 100644
--- a/FileManager/Configuration.cs
+++ b/FileManager/Configuration.cs
@@ -51,9 +51,12 @@ namespace FileManager
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
/// WILL ONLY set if already present. WILL NOT create new
- /// Value was changed
- public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
- => persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
+ public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
+ {
+ var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
+ if (settingWasChanged)
+ configuration?.Reload();
+ }
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
@@ -159,13 +162,9 @@ namespace FileManager
#region logging
private IConfigurationRoot configuration;
+
public void ConfigureLogging()
{
- //// with code. also persists to Settings.json
- //SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
- //// hack which achieves the same, in memory only
- //configuration["Serilog:WriteTo:1:Args:path"] = logPath;
-
configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.Build();
@@ -217,11 +216,11 @@ namespace FileManager
#region singleton stuff
public static Configuration Instance { get; } = new Configuration();
private Configuration() { }
- #endregion
+ #endregion
- #region LibationFiles
+ #region LibationFiles
- private const string APPSETTINGS_JSON = "appsettings.json";
+ private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
private const string LIBATION_FILES_KEY = "LibationFiles";
[Description("Location for storage of program-created files")]
@@ -238,12 +237,16 @@ namespace FileManager
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
persistentDictionary = new PersistentDictionary(SettingsFilePath);
- // Config init in Program.ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
+ // Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
var logPath = Path.Combine(LibationFiles, "Log.log");
- bool settingWasChanged = SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
- if (settingWasChanged)
- configuration?.Reload();
+
+ // BAD: Serilog.WriteTo[1].Args
+ // "[1]" assumes ordinal position
+ // GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
+ var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
+
+ SetWithJsonPath(jsonpath, "path", logPath, true);
return libationFilesPathCache;
}
diff --git a/FileManager/FileManager.csproj b/FileManager/FileManager.csproj
index 718e4303..b08b6f10 100644
--- a/FileManager/FileManager.csproj
+++ b/FileManager/FileManager.csproj
@@ -7,7 +7,6 @@
-
diff --git a/Libation.sln b/Libation.sln
index 5c653a70..f7b23ffe 100644
--- a/Libation.sln
+++ b/Libation.sln
@@ -41,8 +41,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoIm
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationServices", "ApplicationServices\ApplicationServices.csproj", "{B95650EA-25F0-449E-BA5D-99126BC5D730}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationLauncher", "LibationLauncher\LibationLauncher.csproj", "{F3B04A3A-20C8-4582-A54A-715AF6A5D859}"
-EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities.Tests", "_Tests\InternalUtilities.Tests\InternalUtilities.Tests.csproj", "{8447C956-B03E-4F59-9DD4-877793B849D9}"
@@ -51,6 +49,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -93,10 +95,6 @@ Global
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.Build.0 = Release|Any CPU
- {F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.Build.0 = Release|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -109,6 +107,14 @@ Global
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.Build.0 = Release|Any CPU
+ {595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -123,10 +129,11 @@ Global
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
- {F3B04A3A-20C8-4582-A54A-715AF6A5D859} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{8447C956-B03E-4F59-9DD4-877793B849D9} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
+ {428163C3-D558-4914-B570-A92069521877} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
+ {595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
diff --git a/LibationCli/LibationCli.csproj b/LibationCli/LibationCli.csproj
new file mode 100644
index 00000000..ce0975b5
--- /dev/null
+++ b/LibationCli/LibationCli.csproj
@@ -0,0 +1,33 @@
+
+
+
+
+ Exe
+ net5.0
+
+ true
+ true
+ win-x64
+ false
+ false
+
+
+
+ ..\LibationWinForms\bin\Debug
+
+
+
+ ..\LibationWinForms\bin\Release
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LibationCli/Options/ConvertOptions.cs b/LibationCli/Options/ConvertOptions.cs
new file mode 100644
index 00000000..fa3b744b
--- /dev/null
+++ b/LibationCli/Options/ConvertOptions.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using CommandLine;
+
+namespace LibationCli
+{
+ [Verb("convert", HelpText = "Convert mp4 to mp3.")]
+ public class ConvertOptions : ProcessableOptionsBase
+ {
+ protected override Task ProcessAsync() => RunAsync(CreateProcessable());
+ }
+}
diff --git a/LibationCli/Options/ExportOptions.cs b/LibationCli/Options/ExportOptions.cs
new file mode 100644
index 00000000..625663b6
--- /dev/null
+++ b/LibationCli/Options/ExportOptions.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ApplicationServices;
+using CommandLine;
+using InternalUtilities;
+
+namespace LibationCli
+{
+ [Verb("export", HelpText = "Must include path and flag for export file type: --xlsx , --csv , --json]")]
+ public class ExportOptions : OptionsBase
+ {
+ [Option(shortName: 'p', longName: "path", Required = true, HelpText = "Path to save file to.")]
+ public string FilePath { get; set; }
+
+ #region explanation of mutually exclusive options
+ /*
+ giving these SetName values makes them mutually exclusive. they are in different sets. eg:
+ class Options
+ {
+ [Option("username", SetName = "auth")]
+ public string Username { get; set; }
+ [Option("password", SetName = "auth")]
+ public string Password { get; set; }
+
+ [Option("guestaccess", SetName = "guest")]
+ public bool GuestAccess { get; set; }
+ }
+ */
+ #endregion
+ [Option(shortName: 'x', longName: "xlsx", SetName = "xlsx", Required = true)]
+ public bool xlsx { get; set; }
+
+ [Option(shortName: 'c', longName: "csv", SetName = "csv", Required = true)]
+ public bool csv { get; set; }
+
+ [Option(shortName: 'j', longName: "json", SetName = "json", Required = true)]
+ public bool json { get; set; }
+
+ protected override Task ProcessAsync()
+ {
+ if (xlsx)
+ LibraryExporter.ToXlsx(FilePath);
+ if (csv)
+ LibraryExporter.ToCsv(FilePath);
+ if (json)
+ LibraryExporter.ToJson(FilePath);
+
+ Console.WriteLine($"Library exported to: {FilePath}");
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/LibationCli/Options/LiberateOptions.cs b/LibationCli/Options/LiberateOptions.cs
new file mode 100644
index 00000000..0115ae3a
--- /dev/null
+++ b/LibationCli/Options/LiberateOptions.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using CommandLine;
+using DataLayer;
+using FileLiberator;
+
+namespace LibationCli
+{
+ [Verb("liberate", HelpText = "Liberate: book and pdf backups. Default: download and decrypt all un-liberated titles and download pdfs. "
+ + "Optional: use 'pdf' flag to only download pdfs.")]
+ public class LiberateOptions : ProcessableOptionsBase
+ {
+ [Option(shortName: 'p', longName: "pdf", Required = false, Default = false, HelpText = "Flag to only download pdfs")]
+ public bool PdfOnly { get; set; }
+
+ protected override Task ProcessAsync()
+ => PdfOnly
+ ? RunAsync(CreateProcessable())
+ : RunAsync(CreateBackupBook());
+
+ private static IProcessable CreateBackupBook()
+ {
+ var downloadPdf = CreateProcessable();
+
+ //Chain pdf download on DownloadDecryptBook.Completed
+ async void onDownloadDecryptBookCompleted(object sender, LibraryBook e)
+ {
+ await downloadPdf.TryProcessAsync(e);
+ }
+
+ var downloadDecryptBook = CreateProcessable(onDownloadDecryptBookCompleted);
+ return downloadDecryptBook;
+ }
+ }
+}
diff --git a/LibationCli/Options/ScanOptions.cs b/LibationCli/Options/ScanOptions.cs
new file mode 100644
index 00000000..9c9aa825
--- /dev/null
+++ b/LibationCli/Options/ScanOptions.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ApplicationServices;
+using CommandLine;
+using InternalUtilities;
+
+namespace LibationCli
+{
+ [Verb("scan", HelpText = "Scan library. Default: scan all accounts. Optional: use 'account' flag to specify a single account.")]
+ public class ScanOptions : OptionsBase
+ {
+ [Value(0, MetaName = "Accounts", HelpText = "Optional: nicknames of accounts to scan.", Required = false)]
+ public IEnumerable AccountNicknames { get; set; }
+
+ protected override async Task ProcessAsync()
+ {
+ var accounts = getAccounts();
+ if (!accounts.Any())
+ {
+ Console.WriteLine("No accounts. Exiting.");
+ Environment.ExitCode = (int)ExitCode.RunTimeError;
+ return;
+ }
+
+ var _accounts = accounts.ToArray();
+
+ var intro
+ = (_accounts.Length == 1)
+ ? "Scanning Audible library. This may take a few minutes."
+ : $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account.";
+ Console.WriteLine(intro);
+
+ var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync(
+ (account) => null,
+ _accounts);
+
+ Console.WriteLine("Scan complete.");
+ Console.WriteLine($"Total processed: {TotalBooksProcessed}\r\nNew: {NewBooksAdded}");
+ }
+
+ private Account[] getAccounts()
+ {
+ using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
+ var accounts = persister.AccountsSettings.GetAll().ToArray();
+
+ if (!AccountNicknames.Any())
+ return accounts;
+
+ var found = accounts.Where(acct => AccountNicknames.Contains(acct.AccountName)).ToArray();
+ var notFound = AccountNicknames.Except(found.Select(f => f.AccountName)).ToArray();
+
+ // no accounts found. do not continue
+ if (!found.Any())
+ {
+ Console.WriteLine("Accounts not found:");
+ foreach (var nf in notFound)
+ Console.WriteLine($"- {nf}");
+ return found;
+ }
+
+ // some accounts not found. continue after message
+ if (notFound.Any())
+ {
+ Console.WriteLine("Accounts found:");
+ foreach (var f in found)
+ Console.WriteLine($"- {f}");
+ Console.WriteLine("Accounts not found:");
+ foreach (var nf in notFound)
+ Console.WriteLine($"- {nf}");
+ }
+
+ // else: all accounts area found. silently continue
+
+ return found;
+ }
+ }
+}
diff --git a/LibationCli/Options/_OptionsBase.cs b/LibationCli/Options/_OptionsBase.cs
new file mode 100644
index 00000000..e1976adb
--- /dev/null
+++ b/LibationCli/Options/_OptionsBase.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using CommandLine;
+
+namespace LibationCli
+{
+ public abstract class OptionsBase
+ {
+ public async Task Run()
+ {
+ try
+ {
+ await ProcessAsync();
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = (int)ExitCode.RunTimeError;
+
+ Console.WriteLine("ERROR");
+ Console.WriteLine("=====");
+ Console.WriteLine(ex.Message);
+ Console.WriteLine();
+ Console.WriteLine(ex.StackTrace);
+ }
+ }
+
+ protected abstract Task ProcessAsync();
+ }
+}
diff --git a/LibationCli/Options/_ProcessableOptionsBase.cs b/LibationCli/Options/_ProcessableOptionsBase.cs
new file mode 100644
index 00000000..e95e15d1
--- /dev/null
+++ b/LibationCli/Options/_ProcessableOptionsBase.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ApplicationServices;
+using CommandLine;
+using DataLayer;
+using FileLiberator;
+
+namespace LibationCli
+{
+ // streamlined, non-Forms copy of ProcessorAutomationController
+ public abstract class ProcessableOptionsBase : OptionsBase
+ {
+ protected static TProcessable CreateProcessable(EventHandler completedAction = null)
+ where TProcessable : IProcessable, new()
+ {
+ var strProc = new TProcessable();
+
+ strProc.Begin += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}");
+ strProc.Completed += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Completed: {e}");
+
+ strProc.Completed += completedAction;
+
+ return strProc;
+ }
+
+ protected static async Task RunAsync(IProcessable Processable)
+ {
+ foreach (var libraryBook in Processable.GetValidLibraryBooks(DbContexts.GetLibrary_Flat_NoTracking()))
+ await ProcessOneAsync(Processable, libraryBook, false);
+
+ var done = "Done. All books have been processed";
+ Console.WriteLine(done);
+ Serilog.Log.Logger.Information(done);
+ }
+
+ private static async Task ProcessOneAsync(IProcessable Processable, LibraryBook libraryBook, bool validate)
+ {
+ try
+ {
+ var statusHandler = await Processable.ProcessSingleAsync(libraryBook, validate);
+
+ if (statusHandler.IsSuccess)
+ return;
+
+ foreach (var errorMessage in statusHandler.Errors)
+ Serilog.Log.Logger.Error(errorMessage);
+ }
+ catch (Exception ex)
+ {
+ var msg = "Error processing book. Skipping. This book will be tried again on next attempt. For options of skipping or marking as error, retry with main Libation app.";
+ Console.WriteLine(msg + ". See log for more details.");
+ Serilog.Log.Logger.Error(ex, $"{msg} {{@DebugInfo}}", new { Book = libraryBook.LogFriendly() });
+ }
+ }
+ }
+}
diff --git a/LibationCli/Program.cs b/LibationCli/Program.cs
new file mode 100644
index 00000000..6f85c5a6
--- /dev/null
+++ b/LibationCli/Program.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using CommandLine;
+using CommandLine.Text;
+using Dinah.Core;
+using Dinah.Core.Collections;
+using Dinah.Core.Collections.Generic;
+
+namespace LibationCli
+{
+ public enum ExitCode
+ {
+ ProcessCompletedSuccessfully = 0,
+ NonRunNonError = 1,
+ ParseError = 2,
+ RunTimeError = 3
+ }
+ class Program
+ {
+ static async Task Main(string[] args)
+ {
+ //***********************************************//
+ // //
+ // do not use Configuration before this line //
+ // //
+ //***********************************************//
+ Setup.Initialize();
+ Setup.SubscribeToDatabaseEvents();
+
+ var types = Setup.LoadVerbs();
+
+#if DEBUG
+ string input = null;
+
+ //input = " export --help";
+ //input = " scan cupidneedsglasses";
+ //input = " liberate ";
+
+
+ // note: this hack will fail for quoted file paths with spaces because it will break on those spaces
+ if (!string.IsNullOrWhiteSpace(input))
+ args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var setBreakPointHere = args;
+#endif
+
+ var result = Parser.Default.ParseArguments(args, types);
+
+ // if successfully parsed
+ // async: run parsed options
+ await result.WithParsedAsync(opt => opt.Run());
+
+ // if not successfully parsed
+ // sync: handle parse errors
+ result.WithNotParsed(errors => HandleErrors(result, errors));
+
+ return Environment.ExitCode;
+ }
+
+ private static void HandleErrors(ParserResult