diff --git a/.editorconfig b/.editorconfig
index 398cb36..b4b5c8a 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -23,6 +23,11 @@ insert_final_newline = false
#### .NET Coding Conventions ####
[*.{cs,vb}]
+# Diagnostic severity preferences
+dotnet_diagnostic.IDE0058.severity = none
+dotnet_diagnostic.IDE0100.severity = none
+dotnet_diagnostic.CA1848.severity = none
+
# Organize usings
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
@@ -59,7 +64,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/.github/workflows/publish_console.yml b/.github/workflows/publish_console.yml
new file mode 100644
index 0000000..1d52733
--- /dev/null
+++ b/.github/workflows/publish_console.yml
@@ -0,0 +1,102 @@
+name: Publish Console
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - src/StreamShorts.Console/**
+ - src/StreamShorts.Console.Tests/**
+ - src/StreamShorts.Library/**
+ - src/StreamShorts.Library.Tests/**
+ - StreamShorts.sln
+ - Directory.Build.props
+ branches:
+ - main
+jobs:
+ version:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.ACTIONS_PAT }}
+ - name: Setup .NET 9
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.x
+ - name: Install versionize
+ run: dotnet tool install --global Versionize
+ - name: Setup git
+ run: |
+ git config --local user.email "65925598+StevanFreeborn@users.noreply.github.com"
+ git config --local user.name "Stevan Freeborn"
+ - name: Run versionize
+ id: versionize
+ run: versionize -i --exit-insignificant-commits --workingDir ./src/StreamShorts.Console --commit-suffix "[skip ci]"
+ continue-on-error: true
+ - name: Upload changelog
+ if: steps.versionize.outcome == 'success'
+ uses: actions/upload-artifact@v4
+ with:
+ name: change-log
+ path: src/StreamShorts.Console/CHANGELOG.md
+ - name: Push changes to GitHub
+ if: steps.versionize.outcome == 'success'
+ uses: ad-m/github-push-action@master
+ with:
+ github_token: ${{ secrets.ACTIONS_PAT }}
+ branch: ${{ github.ref }}
+ tags: true
+ outputs:
+ is_new_version: ${{ steps.versionize.outcome == 'success' }}
+ publish:
+ needs: [version]
+ if: needs.version.outputs.is_new_version == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ ref: ${{ github.ref }}
+ token: ${{ secrets.ACTIONS_PAT }}
+ - name: Setup .NET 9
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.x
+ - name: Build
+ run: dotnet build src
+ - name: Publish for mac-os
+ run: dotnet publish src/StreamShorts.Console/StreamsShorts.Console.csproj -c Release -r osx-x64 -o dist/mac-os
+ - name: Rename mac-os file
+ run: mv dist/mac-os/StreamShorts.Console dist/mac-os/StreamShorts.Console_macos
+ - name: Publish for linux-os
+ run: dotnet publish src/StreamShorts.Console/StreamShorts.Console.csproj -c Release -r linux-x64 -o dist/linux-os
+ - name: Rename linux-os file
+ run: mv dist/linux-os/StreamShorts.Console dist/linux-os/StreamShorts.Console_linux
+ - name: Publish for windows-os
+ run: dotnet publish src/StreamShorts.Console/StreamShorts.Console.csproj -c Release -r win-x64 -o dist/windows-os
+ - name: Rename windows-os file
+ run: mv dist/windows-os/StreamShorts.Console.exe dist/windows-os/StreamShorts.Console_windows.exe
+ - name: Get project version
+ uses: kzrnm/get-net-sdk-project-versions-action@v1
+ id: get-version
+ with:
+ proj-path: src/StreamShorts.Console/StreamShorts.Console.csproj
+ - name: Download changlog
+ uses: actions/download-artifact@v4
+ with:
+ name: change-log
+ path: src/StreamShorts.Console
+ - name: Create release
+ uses: softprops/action-gh-release@v1
+ with:
+ token: ${{ secrets.ACTIONS_PAT }}
+ name: StreamShorts.Console v${{ steps.get-version.outputs.version }}
+ tag_name: v${{ steps.get-version.outputs.version }}
+ draft: false
+ body_path: src/StreamShorts.Console/CHANGELOG.md
+ files: |
+ dist/mac-os/StreamShorts.Console_macos
+ dist/linux-os/StreamShorts.Console_linux
+ dist/windows-os/StreamShorts.Console_windows.exe
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 890c17d..fdd0136 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,7 @@
{
"cSpell.words": [
+ "Ggml",
+ "resampler",
"resx"
]
}
\ No newline at end of file
diff --git a/src/Directory.packages.props b/src/Directory.packages.props
index d727d6b..c18e98a 100644
--- a/src/Directory.packages.props
+++ b/src/Directory.packages.props
@@ -1,7 +1,22 @@
-
true
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/StreamShorts.Console/.editorconfig b/src/StreamShorts.Console/.editorconfig
new file mode 100644
index 0000000..2dc531e
--- /dev/null
+++ b/src/StreamShorts.Console/.editorconfig
@@ -0,0 +1,2 @@
+[*.cs]
+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..e469c7c
--- /dev/null
+++ b/src/StreamShorts.Console/Commands/DefaultCommand.cs
@@ -0,0 +1,162 @@
+namespace StreamShorts.Console.Commands;
+
+///
+/// The default command for processing video streams to create short clips.
+///
+internal sealed class DefaultCommand(
+ IFileSystem fileSystem,
+ IAnsiConsole console,
+ IAudioExtractor audioExtractor,
+ ITranscriber transcriber,
+ ITranscriptAnalyzer transcriptAnalyzer,
+ IShortsCreator shortsCreator,
+ TimeProvider timeProvider
+) : AsyncCommand
+{
+ private readonly JsonSerializerOptions _jsonSerializerOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ };
+ private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+ private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console));
+ private readonly IAudioExtractor _audioExtractor = audioExtractor ?? throw new ArgumentNullException(nameof(audioExtractor));
+ private readonly ITranscriber _transcriber = transcriber ?? throw new ArgumentNullException(nameof(transcriber));
+ private readonly ITranscriptAnalyzer _transcriptAnalyzer = transcriptAnalyzer ?? throw new ArgumentNullException(nameof(transcriptAnalyzer));
+ private readonly IShortsCreator _shortsCreator = shortsCreator ?? throw new ArgumentNullException(nameof(shortsCreator));
+ private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+
+ ///
+ /// Represents the settings for the default command.
+ ///
+ internal class Settings : CommandSettings
+ {
+ ///
+ /// Gets or sets the path to the stream.
+ ///
+ [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 async Task ExecuteAsync(CommandContext context, Settings settings)
+ {
+ _console.MarkupLine($"[blue]Processing stream:[/] {settings.Stream}");
+ var videoStream = _fileSystem.File.OpenRead(settings.Stream);
+
+ Stream? audioStream = null;
+
+ await _console.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Extracting audio...", async _ =>
+ {
+ audioStream = await _audioExtractor.ExtractMp3FromMp4Async(videoStream);
+ });
+
+ if (audioStream is null)
+ {
+ _console.MarkupLine("[red]Failed[/] to extract audio from the stream.");
+ return (int)ExitCode.FailedToExtractAudio;
+ }
+
+ _console.MarkupLine($"[blue]Audio extracted[/] [green]successfully![/]");
+
+ List transcriptionSegments = [];
+
+ await _console.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Transcribing audio...", async ctx =>
+ {
+ await foreach (var segment in _transcriber.TranscribeAsync(audioStream))
+ {
+ transcriptionSegments.Add(segment);
+ var segmentTimeText = $@"[{segment.StartTime:hh\:mm\:ss} - {segment.EndTime:hh\:mm\:ss}]";
+ ctx.Status($"Transcribed segment {segmentTimeText.EscapeMarkup()}");
+ }
+ });
+
+ _console.MarkupLine($"[blue]Transcription completed[/] [green]successfully![/]");
+
+ var now = _timeProvider.GetUtcNow();
+ var inputFileName = _fileSystem.Path.GetFileNameWithoutExtension(settings.Stream);
+ var outputDirectoryPath = _fileSystem.Path.Combine(
+ AppContext.BaseDirectory,
+ $"{now:yyyy_MM_dd_HH_mm_ss}_{inputFileName}"
+ );
+
+ _fileSystem.Directory.CreateDirectory(outputDirectoryPath);
+
+ await _fileSystem.File.WriteAllTextAsync(
+ _fileSystem.Path.Combine(outputDirectoryPath, "transcription.txt"),
+ string.Join(Environment.NewLine, transcriptionSegments)
+ );
+
+ TranscriptAnalysis? analysis = null;
+
+ await _console.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Analyzing transcript...", async _ =>
+ {
+ analysis = await _transcriptAnalyzer.AnalyzeAsync(transcriptionSegments);
+ });
+
+ if (analysis is null)
+ {
+ _console.MarkupLine("[red]Failed[/] to analyze transcript.");
+ return (int)ExitCode.FailedToAnalyzeTranscript;
+ }
+
+ _console.MarkupLine($"[blue]Transcript analysis completed[/] [green]successfully![/]");
+
+ await _fileSystem.File.WriteAllTextAsync(
+ _fileSystem.Path.Combine(outputDirectoryPath, "analysis.json"),
+ JsonSerializer.Serialize(analysis, _jsonSerializerOptions)
+ );
+
+ await _console.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Creating shorts...", async ctx =>
+ {
+ foreach (var candidate in analysis.Candidates)
+ {
+ ctx.Status($"Creating short: {candidate.Title.EscapeMarkup()}");
+ var safeFileName = string.Concat(candidate.Title.Split(_fileSystem.Path.GetInvalidFileNameChars()));
+ var candidatePath = _fileSystem.Path.Combine(outputDirectoryPath, $"{safeFileName}.mp4");
+ await _shortsCreator.CreateShortAsync(settings.Stream, candidate, candidatePath);
+ }
+ });
+
+ _console.MarkupLine($"[blue]Shorts created[/] [green]successfully![/]");
+
+ return 0;
+ }
+
+ private enum ExitCode
+ {
+ FailedToExtractAudio,
+ FailedToAnalyzeTranscript,
+ }
+}
\ 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..28c1516
--- /dev/null
+++ b/src/StreamShorts.Console/Hosting/HostBuilderExtensions.cs
@@ -0,0 +1,29 @@
+namespace StreamShorts.Console.Hosting;
+
+///
+/// Provides extension methods for building command applications from host builders.
+///
+internal static class HostBuilderExtensions
+{
+ ///
+ /// Builds a command application from the host builder.
+ ///
+ /// The host builder.
+ /// A configured command application.
+ 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?.MarkupLine($"[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..bf236e0
--- /dev/null
+++ b/src/StreamShorts.Console/Hosting/TypeRegistrar.cs
@@ -0,0 +1,32 @@
+namespace StreamShorts.Console.Hosting;
+
+///
+/// Provides type registration services for the dependency injection container.
+///
+///
+internal sealed 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