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 result, IEnumerable errors) + { + var errorsList = errors.ToList(); + if (errorsList.Any(e => e.Tag.In(ErrorType.HelpRequestedError, ErrorType.VersionRequestedError, ErrorType.HelpVerbRequestedError))) + { + Environment.ExitCode = (int)ExitCode.NonRunNonError; + return; + } + + Environment.ExitCode = (int)ExitCode.ParseError; + + if (errorsList.Any(e => e.Tag.In(ErrorType.NoVerbSelectedError))) + { + Console.WriteLine("No verb selected"); + return; + } + + var helpText = HelpText.AutoBuild(result, + h => HelpText.DefaultParsingErrorsHandler(result, h), + e => e); + Console.WriteLine(helpText); + } + } +} diff --git a/LibationCli/Setup.cs b/LibationCli/Setup.cs new file mode 100644 index 00000000..30de7af2 --- /dev/null +++ b/LibationCli/Setup.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using AppScaffolding; +using CommandLine; +using CommandLine.Text; +using Dinah.Core; +using Dinah.Core.Collections; +using Dinah.Core.Collections.Generic; + +namespace LibationCli +{ + public static class Setup + { + public static void Initialize() + { + //***********************************************// + // // + // do not use Configuration before this line // + // // + //***********************************************// + var config = LibationScaffolding.RunPreConfigMigrations(); + + + LibationScaffolding.RunPostConfigMigrations(); + LibationScaffolding.RunPostMigrationScaffolding(); + +#if !DEBUG + checkForUpdate(); +#endif + } + + private static void checkForUpdate() + { + var (hasUpgrade, zipUrl, htmlUrl, zipName) = LibationScaffolding.GetLatestRelease(); + if (!hasUpgrade) + return; + + var origColor = Console.ForegroundColor; + try + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"UPDATE AVAILABLE @ {zipUrl}"); + } + finally + { + Console.ForegroundColor = origColor; + } + } + + public static void SubscribeToDatabaseEvents() + { + DataLayer.UserDefinedItem.ItemChanged += (sender, e) => ApplicationServices.LibraryCommands.UpdateUserDefinedItem(((DataLayer.UserDefinedItem)sender).Book); + } + + public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly() + .GetTypes() + .Where(t => t.GetCustomAttribute() is not null) + .ToArray(); + } +} diff --git a/LibationLauncher/LibationLauncher.csproj b/LibationLauncher/LibationLauncher.csproj deleted file mode 100644 index f53b5e47..00000000 --- a/LibationLauncher/LibationLauncher.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - - WinExe - net5.0-windows - true - libation.ico - Libation - - true - true - win-x64 - false - false - - 5.6.7.4 - - - - TRACE;DEBUG - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - \ No newline at end of file diff --git a/LibationLauncher/Program.cs b/LibationLauncher/Program.cs deleted file mode 100644 index 616bdf81..00000000 --- a/LibationLauncher/Program.cs +++ /dev/null @@ -1,508 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Windows.Forms; -using AudibleApi.Authorization; -using DataLayer; -using Dinah.Core; -using Dinah.Core.IO; -using Dinah.Core.Logging; -using FileManager; -using InternalUtilities; -using LibationWinForms; -using LibationWinForms.Dialogs; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json.Linq; -using Serilog; - -namespace LibationLauncher -{ - static class Program - { - [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] - [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] - static extern bool AllocConsole(); - - [STAThread] - static void Main() - { - //// uncomment to see Console. MUST be called before anything writes to Console. Might only work from VS - //AllocConsole(); - - Application.SetHighDpiMode(HighDpiMode.SystemAware); - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - - // must occur before access to Configuration instance - migrate_to_v5_2_0__pre_config(); - - - //***********************************************// - // // - // do not use Configuration before this line // - // // - //***********************************************// - - - var config = Configuration.Instance; - - createSettings(config); - - AudibleApiStorage.EnsureAccountsSettingsFileExists(); - - migrate_to_v5_0_0(config); - migrate_to_v5_2_0__post_config(config); - migrate_to_v5_5_0(config); - - ensureSerilogConfig(config); - configureLogging(config); - logStartupState(config); - -#if !DEBUG - checkForUpdate(config); -#endif - Application.Run(new Form1()); - } - - private static void createSettings(Configuration config) - { - // all returns should be preceded by either: - // - if config.LibationSettingsAreValid - // - error message, Exit() - - static void CancelInstallation() - { - MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); - Application.Exit(); - Environment.Exit(0); - } - - if (config.LibationSettingsAreValid) - return; - - var defaultLibationFilesDir = Configuration.UserProfile; - - // check for existing settigns in default location - var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); - if (Configuration.SettingsFileIsValid(defaultSettingsFile)) - config.SetLibationFiles(defaultLibationFilesDir); - - if (config.LibationSettingsAreValid) - return; - - var setupDialog = new SetupDialog(); - if (setupDialog.ShowDialog() != DialogResult.OK) - { - CancelInstallation(); - return; - } - - if (setupDialog.IsNewUser) - config.SetLibationFiles(defaultLibationFilesDir); - else if (setupDialog.IsReturningUser) - { - var libationFilesDialog = new LibationFilesDialog(); - - if (libationFilesDialog.ShowDialog() != DialogResult.OK) - { - CancelInstallation(); - return; - } - - config.SetLibationFiles(libationFilesDialog.SelectedDirectory); - if (config.LibationSettingsAreValid) - return; - - // path did not result in valid settings - var continueResult = MessageBox.Show( - $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", - "New install?", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question); - - if (continueResult != DialogResult.Yes) - { - CancelInstallation(); - return; - } - } - - // 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; - config.AllowLibationFixup = true; - config.DecryptToLossy = false; - - if (new SettingsDialog().ShowDialog() != DialogResult.OK) - { - CancelInstallation(); - return; - } - - if (config.LibationSettingsAreValid) - return; - - CancelInstallation(); - } - - #region migrate to v5.0.0 re-register device if device info not in settings - private static void migrate_to_v5_0_0(Configuration config) - { - if (!config.Exists(nameof(config.AllowLibationFixup))) - config.AllowLibationFixup = true; - - if (!File.Exists(AudibleApiStorage.AccountsSettingsFile)) - return; - - var accountsPersister = AudibleApiStorage.GetAccountsSettingsPersister(); - - var accounts = accountsPersister?.AccountsSettings?.Accounts; - if (accounts is null) - return; - - foreach (var account in accounts) - { - var identity = account?.IdentityTokens; - - if (identity is null) - continue; - - if (!string.IsNullOrWhiteSpace(identity.DeviceType) && - !string.IsNullOrWhiteSpace(identity.DeviceSerialNumber) && - !string.IsNullOrWhiteSpace(identity.AmazonAccountId)) - continue; - - var authorize = new Authorize(identity.Locale); - - try - { - authorize.DeregisterAsync(identity.ExistingAccessToken, identity.Cookies.ToKeyValuePair()).GetAwaiter().GetResult(); - identity.Invalidate(); - - var api = AudibleApiActions.GetApiAsync(new LibationWinForms.Login.WinformResponder(account), account).GetAwaiter().GetResult(); - } - catch - { - // Don't care if it fails - } - } - } - #endregion - - #region migrate to v5.2.0 - // get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress - private 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 - }; - - private 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 - - #region migrate to v5.5.0. FilePaths.json => db. long running. fire and forget - private static void migrate_to_v5_5_0(Configuration config) - => new System.Threading.Thread(() => migrate_to_v5_5_0_thread(config)) { IsBackground = true }.Start(); - private static void migrate_to_v5_5_0_thread(Configuration config) - { - try - { - var filePaths = Path.Combine(config.LibationFiles, "FilePaths.json"); - if (!File.Exists(filePaths)) - return; - - var fileLocations = Path.Combine(config.LibationFiles, "FileLocations.json"); - if (!File.Exists(fileLocations)) - File.Copy(filePaths, fileLocations); - - // files to be deleted at the end - var libhackFilesToDelete = new List(); - // .libhack files => errors - 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)); - - foreach (var jToken in jArr) - { - var asinToken = jToken["Id"]; - var fileTypeToken = jToken["FileType"]; - var pathToken = jToken["Path"]; - if (asinToken is null || fileTypeToken is null || pathToken is null || - asinToken.Type != JTokenType.String || fileTypeToken.Type != JTokenType.Integer || pathToken.Type != JTokenType.String) - continue; - - var asin = asinToken.Value(); - var fileType = (FileType)fileTypeToken.Value(); - var path = pathToken.Value(); - - if (fileType == FileType.Unknown || fileType == FileType.AAXC) - continue; - - var book = context.Books.Local.FirstOrDefault(b => b.AudibleProductId == asin); - if (book is null) - continue; - - // assign these strings and enums/ints unconditionally. EFCore will only update if changed - if (fileType == FileType.PDF) - book.UserDefinedItem.PdfStatus = LiberatedStatus.Liberated; - - if (fileType == FileType.Audio) - { - var lhack = libhackFiles.FirstOrDefault(f => f.ContainsInsensitive(asin)); - if (lhack is null) - book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated; - else - { - book.UserDefinedItem.BookStatus = LiberatedStatus.Error; - libhackFilesToDelete.Add(lhack); - } - } - } - - context.SaveChanges(); - - // only do this after save changes - foreach (var libhackFile in libhackFilesToDelete) - File.Delete(libhackFile); - - File.Delete(filePaths); - } - catch (Exception ex) - { - Log.Logger.Error(ex, "Error attempting to insert FilePaths into db"); - } - } - #endregion - - 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(); - - // Fwd Console to serilog. - // Serilog also writes to Console (should probably change this) so it might be asking for trouble. - // 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())); - - // .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 Libation. {@DebugInfo}", new - { - 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(), - }); - - MessageBoxVerboseLoggingWarning.ShowIfTrue(); - } - - private static void checkForUpdate(Configuration config) - { - string zipUrl; - string selectedPath; - - try - { - // timed out - var latest = getLatestRelease(TimeSpan.FromSeconds(10)); - if (latest is null) - return; - - var latestVersionString = latest.TagName.Trim('v'); - if (!Version.TryParse(latestVersionString, out var latestRelease)) - return; - - // we're up to date - if (latestRelease <= BuildVersion) - return; - - // we have an update - var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip")); - zipUrl = zip?.BrowserDownloadUrl; - - Log.Logger.Information("Update available: {@DebugInfo}", new { - latestRelease = latestRelease.ToString(), - latest.HtmlUrl, - zipUrl - }); - - if (zipUrl is null) - { - MessageBox.Show(latest.HtmlUrl, "New version available"); - return; - } - - var result = MessageBox.Show($"New version available @ {latest.HtmlUrl}\r\nDownload the zip file?", "New version available", MessageBoxButtons.YesNo, MessageBoxIcon.Question); - if (result != DialogResult.Yes) - return; - - using var fileSelector = new SaveFileDialog { FileName = zip.Name, Filter = "Zip Files (*.zip)|*.zip|All files (*.*)|*.*" }; - if (fileSelector.ShowDialog() != DialogResult.OK) - return; - selectedPath = fileSelector.FileName; - } - catch (AggregateException aggEx) - { - Log.Logger.Error(aggEx, "Checking for new version too often"); - return; - } - catch (Exception ex) - { - MessageBoxAlertAdmin.Show("Error checking for update", "Error checking for update", ex); - return; - } - - try - { - LibationWinForms.BookLiberation.ProcessorAutomationController.DownloadFile(zipUrl, selectedPath, true); - } - catch (Exception ex) - { - MessageBoxAlertAdmin.Show("Error downloading update", "Error downloading update", ex); - } - } - - private static Octokit.Release getLatestRelease(TimeSpan timeout) - { - var task = System.Threading.Tasks.Task.Run(() => getLatestRelease()); - if (task.Wait(timeout)) - return task.Result; - - Log.Logger.Information("Timed out"); - 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; - } - - private static Version BuildVersion => System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; - } -} diff --git a/LibationLauncher/libation.ico b/LibationLauncher/libation.ico deleted file mode 100644 index d3e00443..00000000 Binary files a/LibationLauncher/libation.ico and /dev/null differ diff --git a/LibationWinForms/LibationWinForms.csproj b/LibationWinForms/LibationWinForms.csproj index dacc738d..c5271389 100644 --- a/LibationWinForms/LibationWinForms.csproj +++ b/LibationWinForms/LibationWinForms.csproj @@ -2,14 +2,20 @@ - Library + WinExe net5.0-windows true libation.ico + Libation true true win-x64 + false + false + + + @@ -18,6 +24,7 @@ + diff --git a/LibationWinForms/Program.cs b/LibationWinForms/Program.cs new file mode 100644 index 00000000..0f874104 --- /dev/null +++ b/LibationWinForms/Program.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using AudibleApi.Authorization; +using DataLayer; +using Dinah.Core; +using FileManager; +using InternalUtilities; +using LibationWinForms.Dialogs; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json.Linq; +using Serilog; + +namespace LibationWinForms +{ + static class Program + { + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + static extern bool AllocConsole(); + + [STAThread] + static void Main() + { + //// Uncomment to see Console. Must be called before anything writes to Console. + //// Only use while debugging. Acts erratically in the wild + //AllocConsole(); + + Application.SetHighDpiMode(HighDpiMode.SystemAware); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + //***********************************************// + // // + // do not use Configuration before this line // + // // + //***********************************************// + // Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration + var config = AppScaffolding.LibationScaffolding.RunPreConfigMigrations(); + + RunInstaller(config); + + // most migrations go in here + AppScaffolding.LibationScaffolding.RunPostConfigMigrations(); + + // migrations which require Forms or are long-running + RunWindowsOnlyMigrations(config); + + MessageBoxVerboseLoggingWarning.ShowIfTrue(); + +#if !DEBUG + checkForUpdate(); +#endif + + AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(); + + Application.Run(new Form1()); + } + + private static void RunInstaller(Configuration config) + { + // all returns should be preceded by either: + // - if config.LibationSettingsAreValid + // - error message, Exit() + + if (config.LibationSettingsAreValid) + return; + + var defaultLibationFilesDir = Configuration.UserProfile; + + // check for existing settigns in default location + var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); + if (Configuration.SettingsFileIsValid(defaultSettingsFile)) + config.SetLibationFiles(defaultLibationFilesDir); + + if (config.LibationSettingsAreValid) + return; + + static void CancelInstallation() + { + MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); + Application.Exit(); + Environment.Exit(0); + } + + var setupDialog = new SetupDialog(); + if (setupDialog.ShowDialog() != DialogResult.OK) + { + CancelInstallation(); + return; + } + + if (setupDialog.IsNewUser) + config.SetLibationFiles(defaultLibationFilesDir); + else if (setupDialog.IsReturningUser) + { + var libationFilesDialog = new LibationFilesDialog(); + + if (libationFilesDialog.ShowDialog() != DialogResult.OK) + { + CancelInstallation(); + return; + } + + config.SetLibationFiles(libationFilesDialog.SelectedDirectory); + if (config.LibationSettingsAreValid) + return; + + // path did not result in valid settings + var continueResult = MessageBox.Show( + $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", + "New install?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (continueResult != DialogResult.Yes) + { + CancelInstallation(); + return; + } + } + + // 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; + config.AllowLibationFixup = true; + config.DecryptToLossy = false; + + if (new SettingsDialog().ShowDialog() != DialogResult.OK) + { + CancelInstallation(); + return; + } + + if (config.LibationSettingsAreValid) + return; + + CancelInstallation(); + } + + private static void RunWindowsOnlyMigrations(Configuration config) + { + // only supported in winforms. don't move to app scaffolding + migrate_to_v5_0_0(config); + + // long running. won't get a chance to finish in cli. don't move to app scaffolding + migrate_to_v5_5_0(config); + } + + #region migrate to v5.0.0 re-register device if device info not in settings + private static void migrate_to_v5_0_0(Configuration config) + { + if (!config.Exists(nameof(config.AllowLibationFixup))) + config.AllowLibationFixup = true; + + if (!File.Exists(AudibleApiStorage.AccountsSettingsFile)) + return; + + var accountsPersister = AudibleApiStorage.GetAccountsSettingsPersister(); + + var accounts = accountsPersister?.AccountsSettings?.Accounts; + if (accounts is null) + return; + + foreach (var account in accounts) + { + var identity = account?.IdentityTokens; + + if (identity is null) + continue; + + if (!string.IsNullOrWhiteSpace(identity.DeviceType) && + !string.IsNullOrWhiteSpace(identity.DeviceSerialNumber) && + !string.IsNullOrWhiteSpace(identity.AmazonAccountId)) + continue; + + var authorize = new Authorize(identity.Locale); + + try + { + authorize.DeregisterAsync(identity.ExistingAccessToken, identity.Cookies.ToKeyValuePair()).GetAwaiter().GetResult(); + identity.Invalidate(); + + var api = AudibleApiActions.GetApiAsync(new LibationWinForms.Login.WinformResponder(account), account).GetAwaiter().GetResult(); + } + catch + { + // Don't care if it fails + } + } + } + #endregion + + #region migrate to v5.5.0. FilePaths.json => db. long running. fire and forget + private static void migrate_to_v5_5_0(Configuration config) + => new System.Threading.Thread(() => migrate_to_v5_5_0_thread(config)) { IsBackground = true }.Start(); + private static void migrate_to_v5_5_0_thread(Configuration config) + { + try + { + var filePaths = Path.Combine(config.LibationFiles, "FilePaths.json"); + if (!File.Exists(filePaths)) + return; + + var fileLocations = Path.Combine(config.LibationFiles, "FileLocations.json"); + if (!File.Exists(fileLocations)) + File.Copy(filePaths, fileLocations); + + // files to be deleted at the end + var libhackFilesToDelete = new List(); + // .libhack files => errors + 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)); + + foreach (var jToken in jArr) + { + var asinToken = jToken["Id"]; + var fileTypeToken = jToken["FileType"]; + var pathToken = jToken["Path"]; + if (asinToken is null || fileTypeToken is null || pathToken is null || + asinToken.Type != JTokenType.String || fileTypeToken.Type != JTokenType.Integer || pathToken.Type != JTokenType.String) + continue; + + var asin = asinToken.Value(); + var fileType = (FileType)fileTypeToken.Value(); + var path = pathToken.Value(); + + if (fileType == FileType.Unknown || fileType == FileType.AAXC) + continue; + + var book = context.Books.Local.FirstOrDefault(b => b.AudibleProductId == asin); + if (book is null) + continue; + + // assign these strings and enums/ints unconditionally. EFCore will only update if changed + if (fileType == FileType.PDF) + book.UserDefinedItem.PdfStatus = LiberatedStatus.Liberated; + + if (fileType == FileType.Audio) + { + var lhack = libhackFiles.FirstOrDefault(f => f.ContainsInsensitive(asin)); + if (lhack is null) + book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated; + else + { + book.UserDefinedItem.BookStatus = LiberatedStatus.Error; + libhackFilesToDelete.Add(lhack); + } + } + } + + context.SaveChanges(); + + // only do this after save changes + foreach (var libhackFile in libhackFilesToDelete) + File.Delete(libhackFile); + + File.Delete(filePaths); + } + catch (Exception ex) + { + Log.Logger.Error(ex, "Error attempting to insert FilePaths into db"); + } + } + #endregion + + private static void checkForUpdate() + { + string zipUrl; + string htmlUrl; + string zipName; + try + { + bool hasUpgrade; + (hasUpgrade, zipUrl, htmlUrl, zipName) = AppScaffolding.LibationScaffolding.GetLatestRelease(); + + if (!hasUpgrade) + return; + } + catch (Exception ex) + { + MessageBoxAlertAdmin.Show("Error checking for update", "Error checking for update", ex); + return; + } + + if (zipUrl is null) + { + MessageBox.Show(htmlUrl, "New version available"); + return; + } + + var result = MessageBox.Show($"New version available @ {htmlUrl}\r\nDownload the zip file?", "New version available", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (result != DialogResult.Yes) + return; + + using var fileSelector = new SaveFileDialog { FileName = zipName, Filter = "Zip Files (*.zip)|*.zip|All files (*.*)|*.*" }; + if (fileSelector.ShowDialog() != DialogResult.OK) + return; + var selectedPath = fileSelector.FileName; + + try + { + LibationWinForms.BookLiberation.ProcessorAutomationController.DownloadFile(zipUrl, selectedPath, true); + } + catch (Exception ex) + { + MessageBoxAlertAdmin.Show("Error downloading update", "Error downloading update", ex); + } + } + } +} diff --git a/_DB_NOTES.txt b/_DB_NOTES.txt index 1f53b695..3490ede4 100644 --- a/_DB_NOTES.txt +++ b/_DB_NOTES.txt @@ -6,7 +6,7 @@ Startup project: DataLayer since we have mult contexts, must use -context: Add-Migration MyComment -context LibationContext Update-Database -context LibationContext -Startup project: reset to prev. eg: LibationLauncher +Startup project: reset to prev. eg: LibationWinForms Migrations, detailed diff --git a/__README - COLLABORATORS.txt b/__README - COLLABORATORS.txt index 1d4e9316..1d4204d6 100644 --- a/__README - COLLABORATORS.txt +++ b/__README - COLLABORATORS.txt @@ -30,7 +30,7 @@ STRUCTURE * 5 Domain Utilities (db aware) This is often where database, domain-ignorant util.s, and non-database capabilities come together to provide the domain-conscious access points * 6 Application - UI and application launcher + GUI, CLI, and shared scaffolding CODING GUIDELINES