diff --git a/.editorconfig b/.editorconfig index 398cb36..0adf1e7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,6 +23,10 @@ insert_final_newline = false #### .NET Coding Conventions #### [*.{cs,vb}] +# Diagnostic severity preferences +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0100.severity = none + # Organize usings dotnet_separate_import_directive_groups = true dotnet_sort_system_directives_first = true @@ -59,7 +63,7 @@ dotnet_style_prefer_auto_properties = true:suggestion dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion dotnet_style_prefer_compound_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:silent dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_inferred_tuple_names = true:suggestion diff --git a/src/Directory.packages.props b/src/Directory.packages.props index 5ce8323..0f718b5 100644 --- a/src/Directory.packages.props +++ b/src/Directory.packages.props @@ -6,7 +6,15 @@ + + + + + + + + \ No newline at end of file diff --git a/src/StreamShorts.Console/.editorconfig b/src/StreamShorts.Console/.editorconfig index 79a8d3d..2dc531e 100644 --- a/src/StreamShorts.Console/.editorconfig +++ b/src/StreamShorts.Console/.editorconfig @@ -1,2 +1,2 @@ [*.cs] -dotnet_diagnostic.CA2007.severity = none \ No newline at end of file +dotnet_diagnostic.CA2007.severity = none diff --git a/src/StreamShorts.Console/Commands/DefaultCommand.cs b/src/StreamShorts.Console/Commands/DefaultCommand.cs new file mode 100644 index 0000000..60ae4fe --- /dev/null +++ b/src/StreamShorts.Console/Commands/DefaultCommand.cs @@ -0,0 +1,45 @@ +namespace StreamShorts.Console.Commands; + +internal class DefaultCommand( + IFileSystem fileSystem, + IAnsiConsole console +) : AsyncCommand +{ + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console)); + + internal class Settings : CommandSettings + { + [CommandArgument(0, "[Stream]")] + [Description("The path to the stream")] + public string Stream { get; init; } = string.Empty; + } + + public override ValidationResult Validate(CommandContext context, Settings settings) + { + if (string.IsNullOrWhiteSpace(settings.Stream)) + { + return ValidationResult.Error("Stream path must be provided."); + } + + if (_fileSystem.File.Exists(settings.Stream) is false) + { + return ValidationResult.Error($"The specified stream file '{settings.Stream}' does not exist."); + } + + var fileExtension = _fileSystem.Path.GetExtension(settings.Stream).ToUpperInvariant(); + + if (fileExtension != ".MP4") + { + return ValidationResult.Error("The specified stream file must be an .mp4 file."); + } + + return base.Validate(context, settings); + } + + public override Task ExecuteAsync(CommandContext context, Settings settings) + { + _console.Write($"[green]Processing stream:[/] {settings.Stream}"); + return Task.FromResult(0); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Hosting/HostBuilderExtensions.cs b/src/StreamShorts.Console/Hosting/HostBuilderExtensions.cs new file mode 100644 index 0000000..4b4bdd6 --- /dev/null +++ b/src/StreamShorts.Console/Hosting/HostBuilderExtensions.cs @@ -0,0 +1,21 @@ +namespace StreamShorts.Console.Hosting; + +internal static class HostBuilderExtensions +{ + public static CommandApp BuildApp(this IHostBuilder builder) + { + var registrar = new TypeRegistrar(builder); + var app = new CommandApp(registrar); + + app.Configure(static c => + c.SetExceptionHandler(static (ex, resolver) => + { + var console = resolver?.Resolve(typeof(IAnsiConsole)) as IAnsiConsole; + console?.WriteLine($"[red]An error occurred while executing the command:[/]"); + console?.WriteException(ex, ExceptionFormats.ShortenEverything); + }) + ); + + return app; + } +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Hosting/TypeRegistrar.cs b/src/StreamShorts.Console/Hosting/TypeRegistrar.cs new file mode 100644 index 0000000..50ab983 --- /dev/null +++ b/src/StreamShorts.Console/Hosting/TypeRegistrar.cs @@ -0,0 +1,28 @@ +namespace StreamShorts.Console.Hosting; + +internal class TypeRegistrar(IHostBuilder builder) : ITypeRegistrar +{ + private readonly IHostBuilder _builder = builder; + + public ITypeResolver Build() + { + return new TypeResolver(_builder.Build()); + } + + public void Register(Type service, Type implementation) + { + _builder.ConfigureServices((_, services) => services.AddSingleton(service, implementation)); + } + + public void RegisterInstance(Type service, object implementation) + { + _builder.ConfigureServices((_, services) => services.AddSingleton(service, implementation)); + } + + public void RegisterLazy(Type service, Func func) + { + ArgumentNullException.ThrowIfNull(func); + + _builder.ConfigureServices((_, services) => services.AddSingleton(service, _ => func())); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Hosting/TypeResolver.cs b/src/StreamShorts.Console/Hosting/TypeResolver.cs new file mode 100644 index 0000000..afbb21b --- /dev/null +++ b/src/StreamShorts.Console/Hosting/TypeResolver.cs @@ -0,0 +1,16 @@ +namespace StreamShorts.Console.Hosting; + +internal class TypeResolver(IHost provider) : ITypeResolver, IDisposable +{ + private readonly IHost _host = provider ?? throw new ArgumentNullException(nameof(provider)); + + public object? Resolve(Type? type) + { + return type is not null ? _host.Services.GetService(type) : null; + } + + public void Dispose() + { + _host.Dispose(); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Program.cs b/src/StreamShorts.Console/Program.cs index 941144b..53065ed 100644 --- a/src/StreamShorts.Console/Program.cs +++ b/src/StreamShorts.Console/Program.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using System.Globalization; +using System.Globalization; using System.Resources; using System.Text; using System.Text.Json; @@ -9,28 +8,47 @@ using FFMpegCore; using FFMpegCore.Enums; using FFMpegCore.Pipes; -using Microsoft.Extensions.Configuration; - using NAudio.Wave; using Whisper.net; using Whisper.net.Ggml; -var config = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) - .Build(); +Log.Logger = new LoggerConfiguration() + .WriteTo.File( + formatter: new CompactJsonFormatter(), + path: Path.Combine(AppContext.BaseDirectory, "logs", "log.jsonl"), + rollingInterval: RollingInterval.Day + ) + .Enrich.FromLogContext() + .MinimumLevel.Verbose() + .MinimumLevel.Override("Microsoft", LogEventLevel.Fatal) + .CreateLogger(); -var stopwatch = new Stopwatch(); -stopwatch.Start(); -var resourceManager = new ResourceManager("StreamShorts.Console.Resources.Resources", typeof(Program).Assembly); - -Console.WriteLine(resourceManager.GetString("WelcomeMessage", CultureInfo.CurrentCulture)); - -// Step 1: Retrieve stream -if (args.Length is 0) +try { - Console.WriteLine(resourceManager.GetString("StreamNotProvided", CultureInfo.CurrentCulture)); - return; + var appName = Assembly.GetExecutingAssembly().GetName().Name; + Log.Information("Starting {AppName}", appName); + + await Host.CreateDefaultBuilder(args) + .ConfigureLogging(static l => l.ClearProviders()) + .ConfigureServices(static (_, services) => + { + services.AddSingleton(AnsiConsole.Console); + services.AddSingleton(); + }) + .BuildApp() + .RunAsync(args); + + Log.Information("{AppName} has completed successfully.", appName); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occurred during execution."); + throw; +} +finally +{ + await Log.CloseAndFlushAsync(); } using var mp3Stream = new MemoryStream(); @@ -44,12 +62,6 @@ var wasExtracted = await FFMpegArguments ) .ProcessAsynchronously(); -if (wasExtracted is false) -{ - Console.WriteLine(resourceManager.GetString("Mp3ExtractionFailed", CultureInfo.CurrentCulture)); - return; -} - // Step 2: Convert MP3 stream to 16khz wave format mp3Stream.Position = 0; using var reader = new Mp3FileReader(mp3Stream); @@ -141,8 +153,6 @@ Here is the transcript of my YouTube live stream: {{completeTranscription}} """; -var apiKey = config["GeminiApiKey"]; - if (string.IsNullOrWhiteSpace(apiKey)) { throw new InvalidOperationException("GeminiApiKey is not configured in appsettings.json."); @@ -210,10 +220,6 @@ foreach (var result in analysis) ); } - -stopwatch.Stop(); -Console.WriteLine(stopwatch.Elapsed.Minutes); - // Step 6: Use analysis to generate a short video record LLMAnalysis( diff --git a/src/StreamShorts.Console/Resources/Resources.resx b/src/StreamShorts.Console/Resources/Resources.resx deleted file mode 100644 index c866a39..0000000 --- a/src/StreamShorts.Console/Resources/Resources.resx +++ /dev/null @@ -1,12 +0,0 @@ - - - - Welcome to StreamShorts! - - - No stream provided. Please specify a stream to process. - - - Failed to extract MP3 from the provided stream. - - \ No newline at end of file diff --git a/src/StreamShorts.Console/StreamShorts.Console.csproj b/src/StreamShorts.Console/StreamShorts.Console.csproj index 9e98856..bd4e7f4 100644 --- a/src/StreamShorts.Console/StreamShorts.Console.csproj +++ b/src/StreamShorts.Console/StreamShorts.Console.csproj @@ -8,7 +8,15 @@ + + + + + + + + diff --git a/src/StreamShorts.Console/Usings.cs b/src/StreamShorts.Console/Usings.cs new file mode 100644 index 0000000..7f594c9 --- /dev/null +++ b/src/StreamShorts.Console/Usings.cs @@ -0,0 +1,18 @@ +global using System.ComponentModel; +global using System.IO.Abstractions; +global using System.Reflection; + +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; + +global using Serilog; +global using Serilog.Events; +global using Serilog.Formatting.Compact; + +global using Spectre.Console; +global using Spectre.Console.Cli; + +global using StreamShorts.Console.Commands; +global using StreamShorts.Console.Hosting; \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs b/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/StreamShorts.Library/Media/Video/IShortsCreator.cs b/src/StreamShorts.Library/Media/Video/IShortsCreator.cs new file mode 100644 index 0000000..e69de29 diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..79bfd7f --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +dotnet_diagnostic.CA1707.severity = none diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props index 4192456..cfd8c30 100644 --- a/tests/Directory.Packages.props +++ b/tests/Directory.Packages.props @@ -1,13 +1,13 @@ - - - true - - - - - - - - + + true + + + + + + + + + \ No newline at end of file diff --git a/tests/StreamShorts.Console.Tests/StreamShorts.Console.Tests.csproj b/tests/StreamShorts.Console.Tests/StreamShorts.Console.Tests.csproj index e42006f..5378526 100644 --- a/tests/StreamShorts.Console.Tests/StreamShorts.Console.Tests.csproj +++ b/tests/StreamShorts.Console.Tests/StreamShorts.Console.Tests.csproj @@ -1,7 +1,10 @@ + + + diff --git a/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeRegistrarTests.cs b/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeRegistrarTests.cs new file mode 100644 index 0000000..1b76528 --- /dev/null +++ b/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeRegistrarTests.cs @@ -0,0 +1,88 @@ +namespace StreamShorts.Console.Tests.Unit.Hosting; + +public class TypeRegistrarTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldNotThrowShould() + { + var mockBuilder = new Mock(); + + var action = () => new TypeRegistrar(mockBuilder.Object); + + action.Should().NotThrow(); + } + + [Fact] + public void Build_WhenCalled_ItShouldReturnResolverAndBuildHost() + { + var mockHost = new Mock(); + var mockBuilder = new Mock(); + + mockBuilder + .Setup(static b => b.Build()) + .Returns(mockHost.Object); + + var registrar = new TypeRegistrar(mockBuilder.Object); + + var resolver = registrar.Build(); + + resolver.Should().BeOfType(); + mockBuilder.Verify(static b => b.Build(), Times.Once); + } + + [Fact] + public void Register_WhenCalledWithType_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + registrar.Register(typeof(IService), typeof(ServiceImplementation)); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.Should().NotBeNull(); + service.Should().BeOfType(); + } + + [Fact] + public void RegisterInstance_WhenCalledWithInstance_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + var instance = new ServiceImplementation(); + registrar.RegisterInstance(typeof(IService), instance); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.Should().BeSameAs(instance); + } + + [Fact] + public void RegisterLazy_WhenCalledWithFunc_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + registrar.RegisterLazy(typeof(IService), static () => new ServiceImplementation()); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.Should().NotBeNull(); + service.Should().BeOfType(); + } + + [Fact] + public void RegisterLazy_WhenFuncIsNull_ItShouldThrow() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + + var action = () => registrar.RegisterLazy(typeof(IService), null!); + + action.Should().Throw(); + } + + private interface IService { } + private sealed class ServiceImplementation : IService { } +} \ No newline at end of file diff --git a/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeResolverTests.cs b/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeResolverTests.cs new file mode 100644 index 0000000..8cfaf73 --- /dev/null +++ b/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeResolverTests.cs @@ -0,0 +1,73 @@ +namespace StreamShorts.Console.Tests.Unit.Hosting; + +public class TypeResolverTests +{ + [Fact] + public void Constructor_WhenCalledWithNullHost_ItShouldThrowArgumentNullException() + { + var action = static () => new TypeResolver(null!); + + action.Should().Throw(); + } + + [Fact] + public void Resolve_WhenTypeIsNull_ItShouldReturnNull() + { + var mockHost = new Mock(); + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(null); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenCalledWithRegisteredType_ItShouldReturnAnInstance() + { + var services = new ServiceCollection(); + services.AddSingleton(new TestService()); + + var mockHost = new Mock(); + + mockHost + .Setup(static h => h.Services) + .Returns(services.BuildServiceProvider()); + + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(typeof(TestService)); + + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Fact] + public void Resolve_WhenCalledWithUnregisteredType_ItShouldReturnNull() + { + var services = new ServiceCollection(); + var mockHost = new Mock(); + + mockHost + .Setup(static h => h.Services) + .Returns(services.BuildServiceProvider()); + + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(typeof(TestService)); + + result.Should().BeNull(); + } + + [Fact] + public void Dispose_WhenCalled_ItShouldAlsoDisposeHost() + { + var mockHost = new Mock(); + var resolver = new TypeResolver(mockHost.Object); + + resolver.Dispose(); + + mockHost.Verify(static h => h.Dispose(), Times.Once); + } + + private sealed class TestService { } +} \ No newline at end of file diff --git a/tests/StreamShorts.Console.Tests/UnitTest1.cs b/tests/StreamShorts.Console.Tests/UnitTest1.cs deleted file mode 100644 index 09cad2e..0000000 --- a/tests/StreamShorts.Console.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace StreamShorts.Console.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - Assert.True(true); - } -} diff --git a/tests/StreamShorts.Console.Tests/Usings.cs b/tests/StreamShorts.Console.Tests/Usings.cs new file mode 100644 index 0000000..7c8e815 --- /dev/null +++ b/tests/StreamShorts.Console.Tests/Usings.cs @@ -0,0 +1,8 @@ +global using AwesomeAssertions; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; + +global using Moq; + +global using StreamShorts.Console.Hosting;