diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj
index 5e65d72d..c065bf6d 100644
--- a/Source/AaxDecrypter/AaxDecrypter.csproj
+++ b/Source/AaxDecrypter/AaxDecrypter.csproj
@@ -13,7 +13,7 @@
-
+
diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs
index 3c10db4c..e8e0423f 100644
--- a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs
+++ b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs
@@ -51,6 +51,34 @@ namespace AaxDecrypter
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
+
+ //Add audiobook shelf tags
+ //https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213
+ const string tagDomain = "com.pilabor.tone";
+
+ AaxFile.AppleTags.Title = DownloadOptions.Title;
+
+ if (DownloadOptions.Subtitle is string subtitle)
+ AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle);
+
+ if (DownloadOptions.Publisher is string publisher)
+ AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher);
+
+ if (DownloadOptions.Language is string language)
+ AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language);
+
+ if (DownloadOptions.AudibleProductId is string asin)
+ {
+ AaxFile.AppleTags.Asin = asin;
+ AaxFile.AppleTags.AppleListBox.EditOrAddTag("asin", asin);
+ AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin);
+ }
+
+ if (DownloadOptions.SeriesName is string series)
+ AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
+
+ if (DownloadOptions.SeriesNumber is float part)
+ AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
}
//Finishing configuring lame encoder.
diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs
index ccbb3808..6edca962 100644
--- a/Source/AaxDecrypter/IDownloadOptions.cs
+++ b/Source/AaxDecrypter/IDownloadOptions.cs
@@ -21,7 +21,14 @@ namespace AaxDecrypter
long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
- NAudio.Lame.LameConfig LameConfig { get; }
+ string AudibleProductId { get; }
+ string Title { get; }
+ string Subtitle { get; }
+ string Publisher { get; }
+ string Language { get; }
+ string SeriesName { get; }
+ float? SeriesNumber { get; }
+ NAudio.Lame.LameConfig LameConfig { get; }
bool Downsample { get; }
bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; }
diff --git a/Source/AaxDecrypter/MpegUtil.cs b/Source/AaxDecrypter/MpegUtil.cs
index d7ab9c37..f863e963 100644
--- a/Source/AaxDecrypter/MpegUtil.cs
+++ b/Source/AaxDecrypter/MpegUtil.cs
@@ -1,4 +1,5 @@
using AAXClean;
+using AAXClean.Codecs;
using NAudio.Lame;
using System;
@@ -6,6 +7,7 @@ namespace AaxDecrypter
{
public static class MpegUtil
{
+ private const string TagDomain = "com.pilabor.tone";
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
{
double bitrateMultiple = 1;
@@ -36,6 +38,21 @@ namespace AaxDecrypter
else if (lameConfig.VBR == VBRMode.ABR)
lameConfig.ABRRateKbps = kbps;
}
+
+ //Setup metadata tags
+ lameConfig.ID3 = mp4File.AppleTags.ToIDTags();
+
+ if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
+ lameConfig.ID3.Subtitle = subtitle;
+
+ if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "LANGUAGE") is string lang)
+ lameConfig.ID3.UserDefinedText.Add("LANGUAGE", lang);
+
+ if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SERIES") is string series)
+ lameConfig.ID3.UserDefinedText.Add("SERIES", series);
+
+ if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
+ lameConfig.ID3.UserDefinedText.Add("PART", part);
}
}
}
diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs
index bd8ce19c..2a8be0de 100644
--- a/Source/AaxDecrypter/NetworkFileStream.cs
+++ b/Source/AaxDecrypter/NetworkFileStream.cs
@@ -14,7 +14,6 @@ namespace AaxDecrypter
public class NetworkFileStream : Stream, IUpdatable
{
public event EventHandler Updated;
- public event EventHandler DownloadCompleted;
#region Public Properties
@@ -41,6 +40,9 @@ namespace AaxDecrypter
[JsonIgnore]
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
+ [JsonIgnore]
+ public Task DownloadTask { get; private set; }
+
private long _speedLimit = 0;
/// bytes per second
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
@@ -52,7 +54,6 @@ namespace AaxDecrypter
private FileStream _readFile { get; }
private CancellationTokenSource _cancellationSource { get; } = new();
private EventWaitHandle _downloadedPiece { get; set; }
- private Task _backgroundDownloadTask { get; set; }
#endregion
@@ -128,7 +129,7 @@ namespace AaxDecrypter
if (uriToSameFile.Host != Uri.Host)
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
- if (_backgroundDownloadTask is not null)
+ if (DownloadTask is not null)
throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile;
@@ -141,7 +142,7 @@ namespace AaxDecrypter
{
if (ContentLength != 0 && WritePosition == ContentLength)
{
- _backgroundDownloadTask = Task.CompletedTask;
+ DownloadTask = Task.CompletedTask;
return;
}
@@ -167,7 +168,8 @@ namespace AaxDecrypter
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
- _backgroundDownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
+
+ DownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
}
/// Download to .
@@ -234,7 +236,6 @@ namespace AaxDecrypter
_writeFile.Close();
_downloadedPiece.Set();
OnUpdate();
- DownloadCompleted?.Invoke(this, null);
}
}
@@ -256,7 +257,7 @@ namespace AaxDecrypter
{
get
{
- if (_backgroundDownloadTask is null)
+ if (DownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
return ContentLength;
}
@@ -280,7 +281,7 @@ namespace AaxDecrypter
public override int Read(byte[] buffer, int offset, int count)
{
- if (_backgroundDownloadTask is null)
+ if (DownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
var toRead = Math.Min(count, Length - Position);
@@ -306,7 +307,7 @@ namespace AaxDecrypter
private void WaitToPosition(long requiredPosition)
{
while (WritePosition < requiredPosition
- && _backgroundDownloadTask?.IsCompleted is false
+ && DownloadTask?.IsCompleted is false
&& !IsCancelled)
{
_downloadedPiece.WaitOne(50);
@@ -326,7 +327,7 @@ namespace AaxDecrypter
if (disposing && !disposed)
{
_cancellationSource.Cancel();
- _backgroundDownloadTask?.GetAwaiter().GetResult();
+ DownloadTask?.GetAwaiter().GetResult();
_downloadedPiece?.Dispose();
_cancellationSource?.Dispose();
_readFile.Dispose();
diff --git a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs
index 193cd9b6..200a4b5f 100644
--- a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs
+++ b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs
@@ -26,11 +26,7 @@ namespace AaxDecrypter
protected override async Task Step_DownloadAndDecryptAudiobookAsync()
{
- TaskCompletionSource completionSource = new();
-
- InputFileStream.DownloadCompleted += (_, _) => completionSource.SetResult();
-
- await completionSource.Task;
+ await InputFileStream.DownloadTask;
if (IsCanceled)
return false;
diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs
index e474191f..31886b89 100644
--- a/Source/AppScaffolding/LibationScaffolding.cs
+++ b/Source/AppScaffolding/LibationScaffolding.cs
@@ -43,21 +43,6 @@ namespace AppScaffolding
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
public static Variety Variety { get; private set; }
- public static void SetReleaseIdentifier(Variety varietyType)
- {
- Variety = Enum.IsDefined(varietyType) ? varietyType : Variety.None;
-
- var releaseID = (ReleaseIdentifier)((int)varietyType | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
-
- if (Enum.IsDefined(releaseID))
- ReleaseIdentifier = releaseID;
- else
- {
- ReleaseIdentifier = ReleaseIdentifier.None;
- Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety = varietyType, Configuration.OS, RuntimeInformation.ProcessArchitecture });
- }
- }
-
// AppScaffolding
private static Assembly _executingAssembly;
private static Assembly ExecutingAssembly
@@ -111,6 +96,22 @@ namespace AppScaffolding
configureLogging(config);
logStartupState(config);
+ #region Determine Libation Variery and Release ID
+
+ Variety = File.Exists("System.Windows.Forms.dll") ? Variety.Classic : Variety.Chardonnay;
+
+ var releaseID = (ReleaseIdentifier)((int)Variety | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
+
+ if (Enum.IsDefined(releaseID))
+ ReleaseIdentifier = releaseID;
+ else
+ {
+ ReleaseIdentifier = ReleaseIdentifier.None;
+ Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety, Configuration.OS, RuntimeInformation.ProcessArchitecture });
+ }
+
+ #endregion
+
// all else should occur after logging
wireUpSystemEvents(config);
diff --git a/Source/DataLayer/EfClasses/AudioFormat.cs b/Source/DataLayer/EfClasses/AudioFormat.cs
index fc78c70a..b0f3374c 100644
--- a/Source/DataLayer/EfClasses/AudioFormat.cs
+++ b/Source/DataLayer/EfClasses/AudioFormat.cs
@@ -13,7 +13,11 @@ namespace DataLayer
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
- }
+ AAX_22_32 = LC_32_22050_stereo,
+ AAX_22_64 = LC_64_22050_stereo,
+ AAX_44_64 = LC_64_44100_stereo,
+ AAX_44_128 = LC_128_44100_stereo
+ }
public class AudioFormat : IComparable, IComparable
{
diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs
index 6ffc7f9b..c2d9701a 100644
--- a/Source/FileLiberator/DownloadDecryptBook.cs
+++ b/Source/FileLiberator/DownloadDecryptBook.cs
@@ -121,8 +121,9 @@ namespace FileLiberator
downloadValidation(libraryBook);
+ var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
var api = await libraryBook.GetApiAsync();
- var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
+ var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, quality);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
@@ -169,7 +170,10 @@ namespace FileLiberator
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
: 0;
- var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
+ //Set the requested AudioFormat for use in file naming templates
+ libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
+
+ var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
{
AudibleKey = contentLic?.Voucher?.Key,
AudibleIV = contentLic?.Voucher?.Iv,
diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs
index 7b46170d..76b80967 100644
--- a/Source/FileLiberator/DownloadOptions.cs
+++ b/Source/FileLiberator/DownloadOptions.cs
@@ -21,6 +21,13 @@ namespace FileLiberator
public TimeSpan RuntimeLength { get; init; }
public OutputFormat OutputFormat { get; init; }
public ChapterInfo ChapterInfo { get; init; }
+ public string Title => LibraryBook.Book.Title;
+ public string Subtitle => LibraryBook.Book.Subtitle;
+ public string Publisher => LibraryBook.Book.Publisher;
+ public string Language => LibraryBook.Book.Language;
+ public string AudibleProductId => LibraryBookDto.AudibleProductId;
+ public string SeriesName => LibraryBookDto.SeriesName;
+ public float? SeriesNumber => LibraryBookDto.SeriesNumber;
public NAudio.Lame.LameConfig LameConfig { get; init; }
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
diff --git a/Source/FileManager/LongPath.cs b/Source/FileManager/LongPath.cs
index 2175681a..956f46a4 100644
--- a/Source/FileManager/LongPath.cs
+++ b/Source/FileManager/LongPath.cs
@@ -163,6 +163,11 @@ namespace FileManager
public override string ToString() => Path;
+ public override int GetHashCode() => Path.GetHashCode();
+ public override bool Equals(object obj) => obj is LongPath other && Path == other.Path;
+ public static bool operator ==(LongPath path1, LongPath path2) => path1.Equals(path2);
+ public static bool operator !=(LongPath path1, LongPath path2) => !path1.Equals(path2);
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength);
diff --git a/Source/HangoverAvalonia/HangoverAvalonia.csproj b/Source/HangoverAvalonia/HangoverAvalonia.csproj
index fdc5bd73..040e313a 100644
--- a/Source/HangoverAvalonia/HangoverAvalonia.csproj
+++ b/Source/HangoverAvalonia/HangoverAvalonia.csproj
@@ -67,13 +67,13 @@
-
-
+
+
-
-
-
-
+
+
+
+
diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml b/Source/LibationAvalonia/Controls/Settings/Audio.axaml
index 713f6645..0972b388 100644
--- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml
+++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml
@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="600"
+ mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="650"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:AudioSettingsVM"
@@ -32,6 +32,18 @@
Grid.Row="0"
Grid.Column="0">
+
+
+
+
+
+
diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj
index 00a7bf61..77ae8993 100644
--- a/Source/LibationAvalonia/LibationAvalonia.csproj
+++ b/Source/LibationAvalonia/LibationAvalonia.csproj
@@ -70,13 +70,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/Source/LibationAvalonia/Program.cs b/Source/LibationAvalonia/Program.cs
index 5f2744b9..23f670a3 100644
--- a/Source/LibationAvalonia/Program.cs
+++ b/Source/LibationAvalonia/Program.cs
@@ -48,12 +48,6 @@ namespace LibationAvalonia
var classicLifetimeTask = Task.Run(() => new ClassicDesktopStyleApplicationLifetime());
var appBuilderTask = Task.Run(BuildAvaloniaApp);
- LibationScaffolding.SetReleaseIdentifier(Variety.Chardonnay);
-
- if (LibationScaffolding.ReleaseIdentifier is ReleaseIdentifier.None)
- return;
-
-
if (config.LibationSettingsAreValid)
{
if (!RunDbMigrations(config))
@@ -81,7 +75,7 @@ namespace LibationAvalonia
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
- return true;
+ return LibationScaffolding.ReleaseIdentifier is not ReleaseIdentifier.None;
}
catch (Exception exDebug)
{
diff --git a/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs
index 02987538..ef418af6 100644
--- a/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs
+++ b/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs
@@ -48,6 +48,7 @@ namespace LibationAvalonia.ViewModels.Settings
DownloadCoverArt = config.DownloadCoverArt;
RetainAaxFile = config.RetainAaxFile;
DownloadClipsBookmarks = config.DownloadClipsBookmarks;
+ FileDownloadQuality = config.FileDownloadQuality;
ClipBookmarkFormat = config.ClipsBookmarksFileFormat;
SplitFilesByChapter = config.SplitFilesByChapter;
MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits;
@@ -74,6 +75,7 @@ namespace LibationAvalonia.ViewModels.Settings
config.DownloadCoverArt = DownloadCoverArt;
config.RetainAaxFile = RetainAaxFile;
config.DownloadClipsBookmarks = DownloadClipsBookmarks;
+ config.FileDownloadQuality = FileDownloadQuality;
config.ClipsBookmarksFileFormat = ClipBookmarkFormat;
config.SplitFilesByChapter = SplitFilesByChapter;
config.MergeOpeningAndEndCredits = MergeOpeningAndEndCredits;
@@ -93,7 +95,9 @@ namespace LibationAvalonia.ViewModels.Settings
config.MaxSampleRate = SelectedSampleRate?.Value ?? config.MaxSampleRate;
}
+ public AvaloniaList DownloadQualities { get; } = new(Enum.GetValues());
public AvaloniaList ClipBookmarkFormats { get; } = new(Enum.GetValues());
+ public string FileDownloadQualityText { get; } = Configuration.GetDescription(nameof(Configuration.FileDownloadQuality));
public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet));
public string AllowLibationFixupText { get; } = Configuration.GetDescription(nameof(Configuration.AllowLibationFixup));
public string DownloadCoverArtText { get; } = Configuration.GetDescription(nameof(Configuration.DownloadCoverArt));
@@ -109,6 +113,7 @@ namespace LibationAvalonia.ViewModels.Settings
public bool DownloadCoverArt { get; set; }
public bool RetainAaxFile { get; set; }
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }
+ public Configuration.DownloadQuality FileDownloadQuality { get; set; }
public Configuration.ClipBookmarkFormat ClipBookmarkFormat { get; set; }
public bool MergeOpeningAndEndCredits { get; set; }
public bool StripAudibleBrandAudio { get; set; }
diff --git a/Source/LibationCli/ConsoleProgressBar.cs b/Source/LibationCli/ConsoleProgressBar.cs
new file mode 100644
index 00000000..44231680
--- /dev/null
+++ b/Source/LibationCli/ConsoleProgressBar.cs
@@ -0,0 +1,76 @@
+using System;
+using System.IO;
+
+namespace LibationCli;
+
+internal class ConsoleProgressBar
+{
+ public TextWriter Output { get; }
+ public int MaxWidth { get; }
+ public char ProgressChar { get; }
+ public char NoProgressChar { get; }
+
+ public double? Progress
+ {
+ get => m_Progress;
+ set
+ {
+ m_Progress = value ?? 0;
+ WriteProgress();
+ }
+ }
+
+ public TimeSpan RemainingTime
+ {
+ get => m_RemainingTime;
+ set
+ {
+ m_RemainingTime = value;
+ WriteProgress();
+ }
+ }
+
+ private double m_Progress;
+ private TimeSpan m_RemainingTime;
+ private int m_LastWriteLength = 0;
+ private const int MAX_ETA_LEN = 10;
+ private readonly int m_NumProgressPieces;
+
+ public ConsoleProgressBar(
+ TextWriter output,
+ int maxWidth = 80,
+ char progressCharr = '#',
+ char noProgressChar = '.')
+ {
+ Output = output;
+ MaxWidth = maxWidth;
+ ProgressChar = progressCharr;
+ NoProgressChar = noProgressChar;
+ m_NumProgressPieces = MaxWidth - MAX_ETA_LEN - 4;
+ }
+
+ private void WriteProgress()
+ {
+ var numCompleted = (int)Math.Round(double.Min(100, m_Progress) * m_NumProgressPieces / 100);
+ var numRemaining = m_NumProgressPieces - numCompleted;
+ var progressBar = $"[{new string(ProgressChar, numCompleted)}{new string(NoProgressChar, numRemaining)}] ";
+
+ progressBar += RemainingTime.TotalMinutes > 1000
+ ? "ETA ∞"
+ : $"ETA {(int)RemainingTime.TotalMinutes}:{RemainingTime.Seconds:D2}";
+
+ Output.Write(new string('\b', m_LastWriteLength) + progressBar);
+ if (progressBar.Length < m_LastWriteLength)
+ {
+ var extra = m_LastWriteLength - progressBar.Length;
+ Output.Write(new string(' ', extra) + new string('\b', extra));
+ }
+ m_LastWriteLength = progressBar.Length;
+ }
+
+ public void Clear()
+ => Output.Write(
+ new string('\b', m_LastWriteLength) +
+ new string(' ', m_LastWriteLength) +
+ new string('\b', m_LastWriteLength));
+}
diff --git a/Source/LibationCli/HelpVerb.cs b/Source/LibationCli/HelpVerb.cs
new file mode 100644
index 00000000..022873c8
--- /dev/null
+++ b/Source/LibationCli/HelpVerb.cs
@@ -0,0 +1,48 @@
+using AppScaffolding;
+using CommandLine;
+using CommandLine.Text;
+
+namespace LibationCli;
+
+[Verb("help", HelpText = "Display more information on a specific command.")]
+internal class HelpVerb
+{
+ ///
+ /// Name of the verb to get help about
+ ///
+ [Value(0, Default = "")]
+ public string HelpType { get; set; }
+
+ ///
+ /// Create a base for
+ ///
+ public static HelpText CreateHelpText() => new HelpText
+ {
+ AutoVersion = false,
+ AutoHelp = false,
+ Heading = $"LibationCli v{LibationScaffolding.BuildVersion.ToString(3)}",
+ AdditionalNewLineAfterOption = true,
+ MaximumDisplayWidth = 80
+ };
+
+ ///
+ /// Get the 's
+ ///
+ public HelpText GetHelpText()
+ {
+ var helpText = CreateHelpText();
+ var result = new Parser().ParseArguments(new string[] { HelpType }, Program.VerbTypes);
+ if (result.TypeInfo.Current == typeof(NullInstance))
+ {
+ //HelpType is not a defined verb so get LibationCli usage
+ helpText.AddVerbs(Program.VerbTypes);
+ }
+ else
+ {
+ helpText.AutoHelp = true;
+ helpText.AddDashesToOption = true;
+ helpText.AddOptions(result);
+ }
+ return helpText;
+ }
+}
diff --git a/Source/LibationCli/LibationCli.csproj b/Source/LibationCli/LibationCli.csproj
index 04926587..864e4ec6 100644
--- a/Source/LibationCli/LibationCli.csproj
+++ b/Source/LibationCli/LibationCli.csproj
@@ -3,7 +3,8 @@
Exe
- net7.0
+ net7.0-windows
+ win-x64
true
false
false
diff --git a/Source/LibationCli/Options/ConvertOptions.cs b/Source/LibationCli/Options/ConvertOptions.cs
index fa3b744b..b20f0152 100644
--- a/Source/LibationCli/Options/ConvertOptions.cs
+++ b/Source/LibationCli/Options/ConvertOptions.cs
@@ -1,8 +1,5 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
+using CommandLine;
using System.Threading.Tasks;
-using CommandLine;
namespace LibationCli
{
diff --git a/Source/LibationCli/Options/ExportOptions.cs b/Source/LibationCli/Options/ExportOptions.cs
index 6d43a029..e3cb3602 100644
--- a/Source/LibationCli/Options/ExportOptions.cs
+++ b/Source/LibationCli/Options/ExportOptions.cs
@@ -1,10 +1,8 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using ApplicationServices;
-using AudibleUtilities;
+using ApplicationServices;
using CommandLine;
+using System;
+using System.IO;
+using System.Threading.Tasks;
namespace LibationCli
{
@@ -29,26 +27,38 @@ namespace LibationCli
}
*/
#endregion
- [Option(shortName: 'x', longName: "xlsx", SetName = "xlsx", Required = true)]
+ [Option(shortName: 'x', longName: "xlsx", HelpText = "Microsoft Excel Spreadsheet", SetName = "xlsx")]
public bool xlsx { get; set; }
- [Option(shortName: 'c', longName: "csv", SetName = "csv", Required = true)]
+ [Option(shortName: 'c', longName: "csv", HelpText = "Comma-separated values", SetName = "csv")]
public bool csv { get; set; }
- [Option(shortName: 'j', longName: "json", SetName = "json", Required = true)]
+ [Option(shortName: 'j', longName: "json", HelpText = "JavaScript Object Notation", SetName = "json")]
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}");
+ Action exporter
+ = csv ? LibraryExporter.ToCsv
+ : json ? LibraryExporter.ToJson
+ : xlsx ? LibraryExporter.ToXlsx
+ : Path.GetExtension(FilePath)?.ToLower() switch
+ {
+ ".xlsx" => LibraryExporter.ToXlsx,
+ ".csv" => LibraryExporter.ToCsv,
+ ".json" => LibraryExporter.ToJson,
+ _ => null
+ };
+ if (exporter is null)
+ {
+ PrintVerbUsage($"Undefined export format for file type \"{Path.GetExtension(FilePath)}\"");
+ }
+ else
+ {
+ exporter(FilePath);
+ Console.WriteLine($"Library exported to: {FilePath}");
+ }
return Task.CompletedTask;
}
}
diff --git a/Source/LibationCli/Options/LiberateOptions.cs b/Source/LibationCli/Options/LiberateOptions.cs
index 5c904771..ab1a8e78 100644
--- a/Source/LibationCli/Options/LiberateOptions.cs
+++ b/Source/LibationCli/Options/LiberateOptions.cs
@@ -1,10 +1,7 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using CommandLine;
+using CommandLine;
using DataLayer;
using FileLiberator;
+using System.Threading.Tasks;
namespace LibationCli
{
diff --git a/Source/LibationCli/Options/ScanOptions.cs b/Source/LibationCli/Options/ScanOptions.cs
index 5f1be8e3..43c888dd 100644
--- a/Source/LibationCli/Options/ScanOptions.cs
+++ b/Source/LibationCli/Options/ScanOptions.cs
@@ -1,18 +1,18 @@
-using System;
+using ApplicationServices;
+using AudibleUtilities;
+using CommandLine;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
-using ApplicationServices;
-using AudibleUtilities;
-using CommandLine;
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; }
+ [Value(0, MetaName = "Accounts", HelpText = "Optional: user ID or nicknames of accounts to scan.", Required = false)]
+ public IEnumerable AccountNames { get; set; }
protected override async Task ProcessAsync()
{
@@ -42,13 +42,19 @@ namespace LibationCli
private Account[] getAccounts()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
- var accounts = persister.AccountsSettings.GetAll().ToArray();
+ var allAccounts = persister.AccountsSettings.GetAll().ToArray();
- if (!AccountNicknames.Any())
- return accounts;
+ if (!AccountNames.Any())
+ return allAccounts;
- var found = accounts.Where(acct => AccountNicknames.Contains(acct.AccountName)).ToArray();
- var notFound = AccountNicknames.Except(found.Select(f => f.AccountName)).ToArray();
+ var accountNames = AccountNames.Select(n => n.ToLower()).ToArray();
+
+ var found
+ = allAccounts
+ .Where(acct => accountNames.Contains(acct.AccountName.ToLower()) || accountNames.Contains(acct.AccountId.ToLower()))
+ .ToArray();
+
+ var notFound = allAccounts.Except(found).ToArray();
// no accounts found. do not continue
if (!found.Any())
diff --git a/Source/LibationCli/Options/SearchOptions.cs b/Source/LibationCli/Options/SearchOptions.cs
new file mode 100644
index 00000000..2c1d1b6d
--- /dev/null
+++ b/Source/LibationCli/Options/SearchOptions.cs
@@ -0,0 +1,56 @@
+using ApplicationServices;
+using CommandLine;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibationCli.Options;
+
+[Verb("search", HelpText = "Search for books in your library")]
+internal class SearchOptions : OptionsBase
+{
+ [Option('n', Default = 10, HelpText = "Number of search results per page")]
+ public int NumResultsPerPage { get; set; }
+
+ [Value(0, MetaName = "query", Required = true, HelpText = "Lucene search string")]
+ public IEnumerable Query { get; set; }
+
+ protected override Task ProcessAsync()
+ {
+ var query = string.Join(" ", Query).Trim('\"');
+ var results = SearchEngineCommands.Search(query).Docs.ToList();
+
+ Console.WriteLine($"Found {results.Count} matching results.");
+
+ string nextPrompt = "Press any key for the next " + NumResultsPerPage + " results or Esc for all results";
+ bool waitForNextBatch = true;
+
+ for (int i = 0; i < results.Count; i += NumResultsPerPage)
+ {
+ var sb = new StringBuilder();
+ for (int j = i; j < int.Min(results.Count, i + NumResultsPerPage); j++)
+ sb.AppendLine(getDocDisplay(results[j].Doc));
+
+ Console.Write(sb.ToString());
+
+ if (waitForNextBatch)
+ {
+ Console.Write(nextPrompt);
+ waitForNextBatch = Console.ReadKey(intercept: true).Key != ConsoleKey.Escape;
+ ReplaceConsoleText(Console.Out, nextPrompt.Length, "");
+ Console.CursorLeft = 0;
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private static string getDocDisplay(Lucene.Net.Documents.Document doc)
+ {
+ var title = doc.GetField("title");
+ var id = doc.GetField("_ID_");
+ return $"[{id.StringValue}] - {title.StringValue}";
+ }
+}
diff --git a/Source/LibationCli/Options/SetDownloadStatusOptions.cs b/Source/LibationCli/Options/SetDownloadStatusOptions.cs
index 0bde6a84..4205542a 100644
--- a/Source/LibationCli/Options/SetDownloadStatusOptions.cs
+++ b/Source/LibationCli/Options/SetDownloadStatusOptions.cs
@@ -1,37 +1,70 @@
-using System;
+using ApplicationServices;
+using CommandLine;
+using DataLayer;
+using Dinah.Core;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
-using ApplicationServices;
-using AudibleUtilities;
-using CommandLine;
namespace LibationCli
{
- [Verb("set-status", HelpText = """
- Set download statuses throughout library based on whether each book's audio file can be found.
- Must include at least one flag: --downloaded , --not-downloaded.
- Downloaded: If the audio file can be found, set download status to 'Downloaded'.
- Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
+ [Verb("set-status", HelpText = """
+ Set download statuses throughout library based on whether each book's audio file can be found.
""")]
- public class SetDownloadStatusOptions : OptionsBase
- {
- [Option(shortName: 'd', longName: "downloaded", Required = true)]
- public bool SetDownloaded { get; set; }
+ public class SetDownloadStatusOptions : OptionsBase
+ {
+ //https://github.com/commandlineparser/commandline/wiki/Option-Groups
+ [Option(shortName: 'd', longName: "downloaded", Group = "Download Status", HelpText = "set download status to 'Downloaded'")]
+ public bool SetDownloaded { get; set; }
- [Option(shortName: 'n', longName: "not-downloaded", Required = true)]
- public bool SetNotDownloaded { get; set; }
+ [Option(shortName: 'n', longName: "not-downloaded", Group = "Download Status", HelpText = "set download status to 'Not Downloaded'")]
+ public bool SetNotDownloaded { get; set; }
- protected override async Task ProcessAsync()
- {
- var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
+ [Option("force", HelpText = "Set the download status regardless of whether the book's audio file can be found. Only one download status option may be used with this option.")]
+ public bool Force { get; set; }
- var bulkSetStatus = new BulkSetDownloadStatus(libraryBooks, SetDownloaded, SetNotDownloaded);
- await Task.Run(() => bulkSetStatus.Discover());
- bulkSetStatus.Execute();
+ [Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books on which to set download status.")]
+ public IEnumerable Asins { get; set; }
- foreach (var msg in bulkSetStatus.Messages)
- Console.WriteLine(msg);
- }
- }
+ protected override async Task ProcessAsync()
+ {
+ if (Force && SetDownloaded && SetNotDownloaded)
+ {
+ PrintVerbUsage("ERROR:\nWhen run with --force option, only one download status option may be used.");
+ return;
+ }
+
+ var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
+
+ if (Asins.Any())
+ {
+ var asins = Asins.Select(a => a.TrimStart('[').TrimEnd(']').ToLower()).ToArray();
+ libraryBooks = libraryBooks.Where(lb => lb.Book.AudibleProductId.ToLower().In(asins)).ToList();
+
+ if (libraryBooks.Count == 0)
+ {
+ Console.Error.WriteLine("Could not find any books matching asins");
+ return;
+ }
+ }
+
+ if (Force)
+ {
+ var status = SetDownloaded ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
+
+ var num = libraryBooks.UpdateBookStatus(status);
+ Console.WriteLine($"Set LiberatedStatus to '{status}' on {"book".PluralizeWithCount(num)}");
+ }
+ else
+ {
+ var bulkSetStatus = new BulkSetDownloadStatus(libraryBooks, SetDownloaded, SetNotDownloaded);
+ await Task.Run(() => bulkSetStatus.Discover());
+ bulkSetStatus.Execute();
+
+ foreach (var msg in bulkSetStatus.Messages)
+ Console.WriteLine(msg);
+ }
+ }
+ }
}
diff --git a/Source/LibationCli/Options/VersionOptions.cs b/Source/LibationCli/Options/VersionOptions.cs
new file mode 100644
index 00000000..4ebced9e
--- /dev/null
+++ b/Source/LibationCli/Options/VersionOptions.cs
@@ -0,0 +1,59 @@
+using AppScaffolding;
+using CommandLine;
+using System;
+using System.Threading.Tasks;
+
+namespace LibationCli.Options;
+
+[Verb("version", HelpText = "Display version information.")]
+internal class VersionOptions : OptionsBase
+{
+ [Option('c', "check", Required = false, HelpText = "Check if an upgrade is available")]
+ public bool CheckForUpgrade { get; set; }
+
+ protected override Task ProcessAsync()
+ {
+ const string checkingForUpgrade = "Checking for upgrade...";
+ Console.WriteLine($"Libation {LibationScaffolding.Variety} v{LibationScaffolding.BuildVersion.ToString(3)}");
+
+ if (CheckForUpgrade)
+ {
+ Console.Write(checkingForUpgrade);
+
+ var origColor = Console.ForegroundColor;
+ try
+ {
+ var upgradeProperties = LibationScaffolding.GetLatestRelease();
+
+ if (upgradeProperties is null)
+ {
+ Console.ForegroundColor = ConsoleColor.Green;
+ ReplaceConsoleText(Console.Out, checkingForUpgrade.Length, "No available upgrade");
+ Console.WriteLine();
+ }
+ else
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ ReplaceConsoleText(Console.Out, checkingForUpgrade.Length, $"Upgrade Available: v{upgradeProperties.LatestRelease.ToString(3)}");
+ Console.WriteLine();
+ Console.WriteLine();
+ Console.WriteLine(upgradeProperties.ZipUrl);
+ Console.WriteLine();
+ Console.WriteLine("Release Notes");
+ Console.WriteLine("=============");
+ Console.WriteLine(upgradeProperties.Notes);
+ }
+ }
+ catch
+ {
+ Console.Error.WriteLine("ERROR CHECKING FOR UPGRADE");
+ }
+ finally
+ {
+ Console.ForegroundColor = origColor;
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Source/LibationCli/Options/_OptionsBase.cs b/Source/LibationCli/Options/_OptionsBase.cs
index 40578b75..cce3f0ee 100644
--- a/Source/LibationCli/Options/_OptionsBase.cs
+++ b/Source/LibationCli/Options/_OptionsBase.cs
@@ -1,8 +1,8 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
+using CommandLine;
+using System;
+using System.IO;
+using System.Reflection;
using System.Threading.Tasks;
-using CommandLine;
namespace LibationCli
{
@@ -17,15 +17,34 @@ namespace LibationCli
catch (Exception ex)
{
Environment.ExitCode = (int)ExitCode.RunTimeError;
-
- Console.Error.WriteLine("ERROR");
- Console.Error.WriteLine("=====");
- Console.Error.WriteLine(ex.Message);
- Console.Error.WriteLine();
- Console.Error.WriteLine(ex.StackTrace);
+ PrintVerbUsage(new string[]
+ {
+ "ERROR",
+ "=====",
+ ex.Message,
+ "",
+ ex.StackTrace
+ });
}
}
+ protected void PrintVerbUsage(params string[] linesBeforeUsage)
+ {
+ var verb = GetType().GetCustomAttribute().Name;
+ var helpText = new HelpVerb { HelpType = verb }.GetHelpText();
+ helpText.AddPreOptionsLines(linesBeforeUsage);
+ helpText.AddPreOptionsLine("");
+ helpText.AddPreOptionsLine($"{verb} Usage:");
+ Console.Error.WriteLine(helpText);
+ }
+
+ protected static void ReplaceConsoleText(TextWriter writer, int previousLength, string newText)
+ {
+ writer.Write(new string('\b', previousLength));
+ writer.Write(newText);
+ writer.Write(new string(' ', int.Max(0, previousLength - newText.Length)));
+ }
+
protected abstract Task ProcessAsync();
}
}
diff --git a/Source/LibationCli/Options/_ProcessableOptionsBase.cs b/Source/LibationCli/Options/_ProcessableOptionsBase.cs
index 664499c1..6ad35f7e 100644
--- a/Source/LibationCli/Options/_ProcessableOptionsBase.cs
+++ b/Source/LibationCli/Options/_ProcessableOptionsBase.cs
@@ -1,23 +1,34 @@
-using System;
+using ApplicationServices;
+using CommandLine;
+using DataLayer;
+using Dinah.Core;
+using FileLiberator;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
-using ApplicationServices;
-using CommandLine;
-using DataLayer;
-using FileLiberator;
namespace LibationCli
{
public abstract class ProcessableOptionsBase : OptionsBase
{
+
+ [Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books to process.")]
+ public IEnumerable Asins { get; set; }
+
protected static TProcessable CreateProcessable(EventHandler completedAction = null)
where TProcessable : Processable, new()
{
+ var progressBar = new ConsoleProgressBar(Console.Out);
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 += (o, e) =>
+ {
+ progressBar.Clear();
+ Console.WriteLine($"{typeof(TProcessable).Name} Completed: {e}");
+ };
strProc.Completed += (s, e) =>
{
@@ -32,13 +43,28 @@ namespace LibationCli
}
};
+ strProc.StreamingTimeRemaining += (_, e) => progressBar.RemainingTime = e;
+ strProc.StreamingProgressChanged += (_, e) => progressBar.Progress = e.ProgressPercentage;
+
return strProc;
}
- protected static async Task RunAsync(Processable Processable)
+ protected async Task RunAsync(Processable Processable)
{
- foreach (var libraryBook in Processable.GetValidLibraryBooks(DbContexts.GetLibrary_Flat_NoTracking()))
- await ProcessOneAsync(Processable, libraryBook, false);
+ var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
+
+ if (Asins.Any())
+ {
+ var asinsLower = Asins.Select(a => a.TrimStart('[').TrimEnd(']').ToLower()).ToArray();
+
+ foreach (var lb in libraryBooks.Where(lb => lb.Book.AudibleProductId.ToLower().In(asinsLower)))
+ await ProcessOneAsync(Processable, lb, true);
+ }
+ else
+ {
+ foreach (var lb in Processable.GetValidLibraryBooks(libraryBooks))
+ await ProcessOneAsync(Processable, lb, false);
+ }
var done = "Done. All books have been processed";
Console.WriteLine(done);
diff --git a/Source/LibationCli/Program.cs b/Source/LibationCli/Program.cs
index 3bdce3da..a6880c7d 100644
--- a/Source/LibationCli/Program.cs
+++ b/Source/LibationCli/Program.cs
@@ -1,12 +1,9 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using CommandLine;
+using CommandLine;
using CommandLine.Text;
using Dinah.Core;
-using Dinah.Core.Collections;
-using Dinah.Core.Collections.Generic;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
namespace LibationCli
{
@@ -19,47 +16,63 @@ namespace LibationCli
}
class Program
{
- static async Task Main(string[] args)
+ public readonly static Type[] VerbTypes = Setup.LoadVerbs();
+ static async Task Main(string[] args)
{
- //***********************************************//
- // //
- // do not use Configuration before this line //
- // //
- //***********************************************//
- Setup.Initialize();
-
- var types = Setup.LoadVerbs();
#if DEBUG
- string input = null;
+ string input = "";
+ //input = " set-status -n --force B017V4IM1G";
+ //input = " liberate B017V4IM1G";
+ //input = " convert B017V4IM1G";
+ //input = " search \"-liberated\"";
//input = " export --help";
+ //input = " version --check";
//input = " scan rmcrackan";
+ //input = " help set-status";
//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);
+ var result = new Parser(ConfigureParser).ParseArguments(args, VerbTypes);
- // if successfully parsed
- // async: run parsed options
- await result.WithParsedAsync(opt => opt.Run());
+ if (result.Value is HelpVerb helper)
+ Console.Error.WriteLine(helper.GetHelpText());
+ else if (result.TypeInfo.Current == typeof(HelpVerb))
+ {
+ //Error parsing the command, but the verb type was identified as HelpVerb
+ //Print LibationCli usage
+ var helpText = HelpVerb.CreateHelpText();
+ helpText.AddVerbs(VerbTypes);
+ Console.Error.WriteLine(helpText);
+ }
+ else if (result.Errors.Any())
+ HandleErrors(result);
+ else
+ {
+ //Everything parsed correctly, so execute the command
- // if not successfully parsed
- // sync: handle parse errors
- result.WithNotParsed(errors => HandleErrors(result, errors));
+ //***********************************************//
+ // //
+ // do not use Configuration before this line //
+ // //
+ //***********************************************//
+ Setup.Initialize();
- return Environment.ExitCode;
+ // if successfully parsed
+ // async: run parsed options
+ await result.WithParsedAsync(opt => opt.Run());
+ }
}
- private static void HandleErrors(ParserResult