Toolbox & CLI: Redo console output, Add configure subcommand

This commit is contained in:
Genteure 2021-07-15 19:56:58 +08:00
parent e2d5a3fd47
commit 9c33d64734
18 changed files with 428 additions and 92 deletions

View File

@ -0,0 +1,202 @@
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
using Spectre.Console;
namespace BililiveRecorder.Cli.Configure
{
public class ConfigureCommand : Command
{
public ConfigureCommand() : base("configure", "Interactively configure config.json")
{
this.AddArgument(new Argument("path") { Description = "Path to work directory or config.json" });
this.Handler = CommandHandler.Create<string>(Run);
}
private static int Run(string path)
{
if (!FindConfig(path, out var config, out var fullPath))
return 1;
ShowRootMenu(config, fullPath);
return 0;
}
private static void ShowRootMenu(ConfigV2 config, string fullPath)
{
while (true)
{
var selection = PromptEnumSelection<RootMenuSelection>();
AnsiConsole.Clear();
switch (selection)
{
case RootMenuSelection.ListRooms:
ListRooms(config);
break;
case RootMenuSelection.AddRoom:
AddRoom(config);
break;
case RootMenuSelection.DeleteRoom:
DeleteRoom(config);
break;
case RootMenuSelection.SetRoomConfig:
// TODO
AnsiConsole.MarkupLine("[bold red]Not Implemented Yet[/]");
break;
case RootMenuSelection.SetGlobalConfig:
// TODO
AnsiConsole.MarkupLine("[bold red]Not Implemented Yet[/]");
break;
case RootMenuSelection.SetJsonSchema:
SetJsonSchema(config);
break;
case RootMenuSelection.Exit:
return;
case RootMenuSelection.SaveAndExit:
if (SaveConfig(config, fullPath))
return;
else
break;
default:
break;
}
}
}
private static void ListRooms(ConfigV2 config)
{
var table = new Table()
.AddColumns("Roomid", "AutoRecord")
.Border(TableBorder.Rounded);
foreach (var room in config.Rooms)
{
table.AddRow(room.RoomId.ToString(), room.AutoRecord ? "[green]Enabled[/]" : "[red]Disabled[/]");
}
AnsiConsole.Render(table);
}
private static void AddRoom(ConfigV2 config)
{
while (true)
{
var roomid = AnsiConsole.Prompt(new TextPrompt<int>("[grey](type 0 to cancel)[/] [green]Roomid[/]:").Validate(x => x switch
{
< 0 => ValidationResult.Error("[red]Roomid can't be negative[/]"),
_ => ValidationResult.Success(),
}));
if (roomid == 0)
break;
if (config.Rooms.Any(x => x.RoomId == roomid))
{
AnsiConsole.MarkupLine("[red]Room already exist[/]");
continue;
}
var autoRecord = AnsiConsole.Confirm("Enable auto record?");
config.Rooms.Add(new RoomConfig { RoomId = roomid, AutoRecord = autoRecord });
AnsiConsole.MarkupLine("[green]Room {0} added to config[/]", roomid);
}
}
private static void DeleteRoom(ConfigV2 config)
{
var toBeDeleted = AnsiConsole.Prompt(new MultiSelectionPrompt<RoomConfig>()
.Title("Delete rooms")
.NotRequired()
.UseConverter(r => r.RoomId.ToString())
.PageSize(15)
.MoreChoicesText("[grey](Move up and down to reveal more rooms)[/]")
.InstructionsText("[grey](Press [blue]<space>[/] to toggle selection, [green]<enter>[/] to delete)[/]")
.AddChoices(config.Rooms));
for (var i = 0; i < toBeDeleted.Count; i++)
config.Rooms.Remove(toBeDeleted[i]);
AnsiConsole.MarkupLine("[green]{0} rooms deleted[/]", toBeDeleted.Count);
}
private static void SetJsonSchema(ConfigV2 config)
{
var selection = PromptEnumSelection<JsonSchemaSelection>();
switch (selection)
{
case JsonSchemaSelection.Default:
config.DollarSignSchema = "https://raw.githubusercontent.com/Bililive/BililiveRecorder/dev-1.3/BililiveRecorder.Core/Config/V2/config.schema.json";
break;
case JsonSchemaSelection.Custom:
config.DollarSignSchema = AnsiConsole.Prompt(new TextPrompt<string>("[green]JSON Schema[/]:").AllowEmpty());
break;
default:
break;
}
}
private static bool SaveConfig(ConfigV2 config, string fullPath)
{
try
{
var json = ConfigParser.SaveJson(config);
using var file = new StreamWriter(File.Open(fullPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None));
file.Write(json);
return true;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine("[red]Write config failed[/]");
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenPaths | ExceptionFormats.ShowLinks);
return false;
}
}
private static bool FindConfig(string path, [NotNullWhen(true)] out ConfigV2? config, out string fullPath)
{
if (File.Exists(path))
{
fullPath = Path.GetFullPath(path);
goto readFile;
}
else if (Directory.Exists(path))
{
fullPath = Path.GetFullPath(Path.Combine(path, ConfigParser.CONFIG_FILE_NAME));
goto readFile;
}
else
{
AnsiConsole.MarkupLine("[red]Path does not exist.[/]");
config = null;
fullPath = string.Empty;
return false;
}
readFile:
config = ConfigParser.LoadJson(File.ReadAllText(fullPath, Encoding.UTF8));
var result = config != null;
if (!result)
AnsiConsole.MarkupLine("[red]Load failed.\nBroken or corrupted file, or no permission.[/]");
return result;
}
private static string EnumToDescriptionConverter<T>(T value) where T : struct, Enum
{
var type = typeof(T);
var attrs = type.GetMember(Enum.GetName(type, value)!)[0].GetCustomAttributes(typeof(DescriptionAttribute), false);
return (attrs.Length > 0) ? ((DescriptionAttribute)attrs[0]).Description : string.Empty;
}
private static T PromptEnumSelection<T>() where T : struct, Enum => AnsiConsole.Prompt(new SelectionPrompt<T>().AddChoices(Enum.GetValues<T>()).UseConverter(EnumToDescriptionConverter));
}
}

View File

@ -0,0 +1,13 @@
using System.ComponentModel;
namespace BililiveRecorder.Cli.Configure
{
public enum JsonSchemaSelection
{
[Description("https://raw.githubusercontent.com/.../config.schema.json")]
Default,
[Description("Custom")]
Custom
}
}

View File

@ -0,0 +1,31 @@
using System.ComponentModel;
namespace BililiveRecorder.Cli.Configure
{
public enum RootMenuSelection
{
[Description("List rooms")]
ListRooms,
[Description("Add room")]
AddRoom,
[Description("Delete room")]
DeleteRoom,
[Description("Update room config")]
SetRoomConfig,
[Description("Update global config")]
SetGlobalConfig,
[Description("Update JSON Schema")]
SetJsonSchema,
[Description("Exit and discard all changes")]
Exit,
[Description("Save and Exit")]
SaveAndExit,
}
}

View File

@ -5,6 +5,7 @@ using System.CommandLine.Invocation;
using System.IO;
using System.Linq;
using System.Threading;
using BililiveRecorder.Cli.Configure;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
@ -50,6 +51,7 @@ namespace BililiveRecorder.Cli
{
cmd_run,
cmd_portable,
new ConfigureCommand(),
new ToolCommand()
};

View File

@ -10,7 +10,7 @@ namespace BililiveRecorder.Core.Config
{
public class ConfigParser
{
private const string CONFIG_FILE_NAME = "config.json";
public const string CONFIG_FILE_NAME = "config.json";
private static readonly ILogger logger = Log.ForContext<ConfigParser>();
private static readonly JsonSerializerSettings settings = new JsonSerializerSettings()
{

View File

@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.1.2" />
<PackageReference Include="Spectre.Console" Version="0.40.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta1.21216.1" />
</ItemGroup>

View File

@ -4,12 +4,12 @@ using Newtonsoft.Json.Converters;
namespace BililiveRecorder.ToolBox
{
public class CommandResponse<TResponse> where TResponse : class
public class CommandResponse<TResponseData> where TResponseData : IResponseData
{
[JsonConverter(typeof(StringEnumConverter))]
public ResponseStatus Status { get; set; }
public TResponse? Result { get; set; }
public TResponseData? Data { get; set; }
public string? ErrorMessage { get; set; }

View File

@ -1,12 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
namespace BililiveRecorder.ToolBox
{
public interface ICommandHandler<TRequest, TResponse>
where TRequest : ICommandRequest<TResponse>
where TResponse : class
where TResponse : IResponseData
{
Task<CommandResponse<TResponse>> Handle(TRequest request);
void PrintResponse(TResponse response);
string Name { get; }
Task<CommandResponse<TResponse>> Handle(TRequest request, CancellationToken cancellationToken, ProgressCallback? progress);
}
}

View File

@ -1,6 +1,6 @@
namespace BililiveRecorder.ToolBox
{
public interface ICommandRequest<TResponse>
where TResponse : class
where TResponse : IResponseData
{ }
}

View File

@ -0,0 +1,7 @@
namespace BililiveRecorder.ToolBox
{
public interface IResponseData
{
void PrintToConsole();
}
}

View File

@ -0,0 +1,6 @@
using System.Threading.Tasks;
namespace BililiveRecorder.ToolBox
{
public delegate Task ProgressCallback(double progress);
}

View File

@ -24,9 +24,9 @@ namespace BililiveRecorder.ToolBox.Tool.Analyze
{
private static readonly ILogger logger = Log.ForContext<AnalyzeHandler>();
public Task<CommandResponse<AnalyzeResponse>> Handle(AnalyzeRequest request) => this.Handle(request, default, null);
public string Name => "Analyze";
public async Task<CommandResponse<AnalyzeResponse>> Handle(AnalyzeRequest request, CancellationToken cancellationToken, Func<double, Task>? progress)
public async Task<CommandResponse<AnalyzeResponse>> Handle(AnalyzeRequest request, CancellationToken cancellationToken, ProgressCallback? progress)
{
FileStream? flvFileStream = null;
try
@ -152,7 +152,7 @@ namespace BililiveRecorder.ToolBox.Tool.Analyze
return new CommandResponse<AnalyzeResponse>
{
Status = ResponseStatus.OK,
Result = response
Data = response
};
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
@ -192,31 +192,6 @@ namespace BililiveRecorder.ToolBox.Tool.Analyze
}
}
public void PrintResponse(AnalyzeResponse response)
{
Console.Write("Input: ");
Console.WriteLine(response.InputPath);
Console.WriteLine(response.NeedFix ? "File needs repair" : "File doesn't need repair");
if (response.Unrepairable)
Console.WriteLine("File contains error(s) that are unrepairable (yet), please send sample to the author of this program.");
Console.WriteLine("Will output {0} file(s) if repaired", response.OutputFileCount);
Console.WriteLine("Types of error:");
Console.Write("Other: ");
Console.WriteLine(response.IssueTypeOther);
Console.Write("Unrepairable: ");
Console.WriteLine(response.IssueTypeUnrepairable);
Console.Write("TimestampJump: ");
Console.WriteLine(response.IssueTypeTimestampJump);
Console.Write("DecodingHeader: ");
Console.WriteLine(response.IssueTypeDecodingHeader);
Console.Write("RepeatingData: ");
Console.WriteLine(response.IssueTypeRepeatingData);
}
private class AnalyzeMockFlvTagWriter : IFlvTagWriter
{
public long FileSize => 0;

View File

@ -1,8 +1,9 @@
using BililiveRecorder.ToolBox.ProcessingRules;
using BililiveRecorder.ToolBox.ProcessingRules;
using Spectre.Console;
namespace BililiveRecorder.ToolBox.Tool.Analyze
{
public class AnalyzeResponse
public class AnalyzeResponse : IResponseData
{
public string InputPath { get; set; } = string.Empty;
@ -20,5 +21,44 @@ namespace BililiveRecorder.ToolBox.Tool.Analyze
public int IssueTypeTimestampOffset { get; set; }
public int IssueTypeDecodingHeader { get; set; }
public int IssueTypeRepeatingData { get; set; }
public void PrintToConsole()
{
if (this.NeedFix)
AnsiConsole.Render(new FigletText("Need Fix").Color(Color.Red));
else
AnsiConsole.Render(new FigletText("All Good").Color(Color.Green));
if (this.Unrepairable)
{
AnsiConsole.Render(new Panel("This file contains error(s) that are identified as unrepairable (yet).\n" +
"Please check if you're using the newest version.\n" +
"Please consider send a sample file to the developer.")
{
Header = new PanelHeader("Important Note"),
Border = BoxBorder.Rounded,
BorderStyle = new Style(foreground: Color.Red)
});
}
AnsiConsole.Render(new Panel(this.InputPath.EscapeMarkup())
{
Header = new PanelHeader("Input"),
Border = BoxBorder.Rounded
});
AnsiConsole.MarkupLine("Will output [lime]{0}[/] file(s) if repaired", this.OutputFileCount);
AnsiConsole.Render(new Table()
.Border(TableBorder.Rounded)
.AddColumns("Category", "Count")
.AddRow("Unrepairable", this.IssueTypeUnrepairable.ToString())
.AddRow("Other", this.IssueTypeOther.ToString())
.AddRow("TimestampJump", this.IssueTypeTimestampJump.ToString())
.AddRow("TimestampOffset", this.IssueTypeTimestampOffset.ToString())
.AddRow("DecodingHeader", this.IssueTypeDecodingHeader.ToString())
.AddRow("RepeatingData", this.IssueTypeRepeatingData.ToString())
);
}
}
}

View File

@ -15,10 +15,10 @@ namespace BililiveRecorder.ToolBox.Tool.Export
public class ExportHandler : ICommandHandler<ExportRequest, ExportResponse>
{
private static readonly ILogger logger = Log.ForContext<ExportHandler>();
public string Name => "Export";
public Task<CommandResponse<ExportResponse>> Handle(ExportRequest request) => this.Handle(request, default, null);
public async Task<CommandResponse<ExportResponse>> Handle(ExportRequest request, CancellationToken cancellationToken, Func<double, Task>? progress)
public async Task<CommandResponse<ExportResponse>> Handle(ExportRequest request, CancellationToken cancellationToken, ProgressCallback? progress)
{
FileStream? inputStream = null, outputStream = null;
try
@ -92,7 +92,7 @@ namespace BililiveRecorder.ToolBox.Tool.Export
});
});
return new CommandResponse<ExportResponse> { Status = ResponseStatus.OK, Result = new ExportResponse() };
return new CommandResponse<ExportResponse> { Status = ResponseStatus.OK, Data = new ExportResponse() };
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
@ -131,7 +131,5 @@ namespace BililiveRecorder.ToolBox.Tool.Export
outputStream?.Dispose();
}
}
public void PrintResponse(ExportResponse response) => Console.WriteLine("OK");
}
}

View File

@ -1,6 +1,7 @@
namespace BililiveRecorder.ToolBox.Tool.Export
namespace BililiveRecorder.ToolBox.Tool.Export
{
public class ExportResponse
public class ExportResponse : IResponseData
{
public void PrintToConsole() { }
}
}

View File

@ -23,9 +23,9 @@ namespace BililiveRecorder.ToolBox.Tool.Fix
{
private static readonly ILogger logger = Log.ForContext<FixHandler>();
public Task<CommandResponse<FixResponse>> Handle(FixRequest request) => this.Handle(request, default, null);
public string Name => "Fix";
public async Task<CommandResponse<FixResponse>> Handle(FixRequest request, CancellationToken cancellationToken, Func<double, Task>? progress)
public async Task<CommandResponse<FixResponse>> Handle(FixRequest request, CancellationToken cancellationToken, ProgressCallback? progress)
{
FileStream? flvFileStream = null;
try
@ -194,7 +194,7 @@ namespace BililiveRecorder.ToolBox.Tool.Fix
};
});
return new CommandResponse<FixResponse> { Status = ResponseStatus.OK, Result = response };
return new CommandResponse<FixResponse> { Status = ResponseStatus.OK, Data = response };
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
@ -233,37 +233,6 @@ namespace BililiveRecorder.ToolBox.Tool.Fix
}
}
public void PrintResponse(FixResponse response)
{
Console.Write("Input: ");
Console.WriteLine(response.InputPath);
Console.WriteLine(response.NeedFix ? "File needs repair" : "File doesn't need repair");
if (response.Unrepairable)
Console.WriteLine("File contains error(s) that are unrepairable (yet), please send sample to the author of this program.");
Console.WriteLine("{0} file(s) written", response.OutputFileCount);
foreach (var path in response.OutputPaths)
{
Console.Write(" ");
Console.WriteLine(path);
}
Console.WriteLine("Types of error:");
Console.Write("Other: ");
Console.WriteLine(response.IssueTypeOther);
Console.Write("Unrepairable: ");
Console.WriteLine(response.IssueTypeUnrepairable);
Console.Write("TimestampJump: ");
Console.WriteLine(response.IssueTypeTimestampJump);
Console.Write("DecodingHeader: ");
Console.WriteLine(response.IssueTypeDecodingHeader);
Console.Write("RepeatingData: ");
Console.WriteLine(response.IssueTypeRepeatingData);
}
private class AutoFixFlvWriterTargetProvider : IFlvWriterTargetProvider
{
private readonly string pathTemplate;

View File

@ -1,9 +1,10 @@
using System;
using System;
using BililiveRecorder.ToolBox.ProcessingRules;
using Spectre.Console;
namespace BililiveRecorder.ToolBox.Tool.Fix
{
public class FixResponse
public class FixResponse : IResponseData
{
public string InputPath { get; set; } = string.Empty;
@ -23,5 +24,48 @@ namespace BililiveRecorder.ToolBox.Tool.Fix
public int IssueTypeTimestampOffset { get; set; }
public int IssueTypeDecodingHeader { get; set; }
public int IssueTypeRepeatingData { get; set; }
public void PrintToConsole()
{
AnsiConsole.Render(new FigletText("Done").Color(Color.Green));
if (this.Unrepairable)
{
AnsiConsole.Render(new Panel("This file contains error(s) that are identified as unrepairable (yet).\n" +
"Please check if you're using the newest version.\n" +
"Please consider send a sample file to the developer.")
{
Header = new PanelHeader("Important Note"),
Border = BoxBorder.Rounded,
BorderStyle = new Style(foreground: Color.Red)
});
}
AnsiConsole.Render(new Panel(this.InputPath.EscapeMarkup())
{
Header = new PanelHeader("Input"),
Border = BoxBorder.Rounded
});
var table_output = new Table()
.Border(TableBorder.Rounded)
.AddColumns("Output");
for (var i = 0; i < this.OutputPaths.Length; i++)
table_output.AddRow(this.OutputPaths[i]);
AnsiConsole.Render(table_output);
AnsiConsole.Render(new Table()
.Border(TableBorder.Rounded)
.AddColumns("Category", "Count")
.AddRow("Unrepairable", this.IssueTypeUnrepairable.ToString())
.AddRow("Other", this.IssueTypeOther.ToString())
.AddRow("TimestampJump", this.IssueTypeTimestampJump.ToString())
.AddRow("TimestampOffset", this.IssueTypeTimestampOffset.ToString())
.AddRow("DecodingHeader", this.IssueTypeDecodingHeader.ToString())
.AddRow("RepeatingData", this.IssueTypeRepeatingData.ToString())
);
}
}
}

View File

@ -6,6 +6,7 @@ using BililiveRecorder.ToolBox.Tool.Analyze;
using BililiveRecorder.ToolBox.Tool.Export;
using BililiveRecorder.ToolBox.Tool.Fix;
using Newtonsoft.Json;
using Spectre.Console;
namespace BililiveRecorder.ToolBox
{
@ -34,7 +35,7 @@ namespace BililiveRecorder.ToolBox
private void RegisterCommand<THandler, TRequest, TResponse>(string name, string? description, Action<Command> configure)
where THandler : ICommandHandler<TRequest, TResponse>
where TRequest : ICommandRequest<TResponse>
where TResponse : class
where TResponse : IResponseData
{
var cmd = new Command(name, description)
{
@ -49,32 +50,77 @@ namespace BililiveRecorder.ToolBox
private static async Task<int> RunSubCommand<THandler, TRequest, TResponse>(TRequest request, bool json, bool jsonIndented)
where THandler : ICommandHandler<TRequest, TResponse>
where TRequest : ICommandRequest<TResponse>
where TResponse : class
where TResponse : IResponseData
{
var isInteractive = !(json || jsonIndented);
var handler = Activator.CreateInstance<THandler>();
var response = await handler.Handle(request).ConfigureAwait(false);
if (json || jsonIndented)
CommandResponse<TResponse>? response;
if (isInteractive)
{
var json_str = JsonConvert.SerializeObject(response, jsonIndented ? Formatting.Indented : Formatting.None);
Console.WriteLine(json_str);
response = await AnsiConsole
.Progress()
.Columns(new ProgressColumn[]
{
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new PercentageColumn(),
new SpinnerColumn(Spinner.Known.Dots10),
})
.StartAsync(async ctx =>
{
var t = ctx.AddTask(handler.Name);
t.MaxValue = 1d;
var r = await handler.Handle(request, default, async p => t.Value = p).ConfigureAwait(false);
t.Value = 1d;
return r;
})
.ConfigureAwait(false);
}
else
{
response = await handler.Handle(request, default, null).ConfigureAwait(false);
}
if (isInteractive)
{
if (response.Status == ResponseStatus.OK)
{
handler.PrintResponse(response.Result!);
response.Data?.PrintToConsole();
return 0;
}
else
{
Console.Write("Error: ");
Console.WriteLine(response.Status);
Console.WriteLine(response.ErrorMessage);
AnsiConsole.Render(new FigletText("Error").Color(Color.Red));
var errorInfo = new Table
{
Border = TableBorder.Rounded
};
errorInfo.AddColumn(new TableColumn("Error Code").Centered());
errorInfo.AddColumn(new TableColumn("Error Message").Centered());
errorInfo.AddRow("[red]" + response.Status.ToString().EscapeMarkup() + "[/]", "[red]" + (response.ErrorMessage ?? string.Empty) + "[/]");
AnsiConsole.Render(errorInfo);
if (response.Exception is not null)
AnsiConsole.Render(new Panel(response.Exception.GetRenderable(ExceptionFormats.ShortenPaths | ExceptionFormats.ShowLinks))
{
Header = new PanelHeader("Exception Info"),
Border = BoxBorder.Rounded
});
return 1;
}
}
else
{
var json_str = JsonConvert.SerializeObject(response, jsonIndented ? Formatting.Indented : Formatting.None);
Console.WriteLine(json_str);
return 0;
return response.Status == ResponseStatus.OK ? 0 : 1;
}
}
}
}