Merge pull request #1 from StevanFreeborn/stevanfreeborn/feat/initial-console-poc

feat: initial console app
This commit is contained in:
Stevan Freeborn
2025-08-20 14:41:33 -05:00
committed by GitHub
52 changed files with 1702 additions and 53 deletions
+6 -1
View File
@@ -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
+102
View File
@@ -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
+2
View File
@@ -1,5 +1,7 @@
{
"cSpell.words": [
"Ggml",
"resampler",
"resx"
]
}
+17 -2
View File
@@ -1,7 +1,22 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FFMpegCore" Version="5.2.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.7" />
<PackageVersion Include="NAudio" Version="2.2.1" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.50.0" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.15" />
<PackageVersion Include="Whisper.net.AllRuntimes" Version="1.8.1" />
</ItemGroup>
</Project>
+2
View File
@@ -0,0 +1,2 @@
[*.cs]
dotnet_diagnostic.CA2007.severity = none
@@ -0,0 +1,162 @@
namespace StreamShorts.Console.Commands;
/// <summary>
/// The default command for processing video streams to create short clips.
/// </summary>
internal sealed class DefaultCommand(
IFileSystem fileSystem,
IAnsiConsole console,
IAudioExtractor audioExtractor,
ITranscriber transcriber,
ITranscriptAnalyzer transcriptAnalyzer,
IShortsCreator shortsCreator,
TimeProvider timeProvider
) : AsyncCommand<DefaultCommand.Settings>
{
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));
/// <summary>
/// Represents the settings for the default command.
/// </summary>
internal class Settings : CommandSettings
{
/// <summary>
/// Gets or sets the path to the stream.
/// </summary>
[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<int> 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<TranscriptionSegment> 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,
}
}
@@ -0,0 +1,29 @@
namespace StreamShorts.Console.Hosting;
/// <summary>
/// Provides extension methods for building command applications from host builders.
/// </summary>
internal static class HostBuilderExtensions
{
/// <summary>
/// Builds a command application from the host builder.
/// </summary>
/// <param name="builder">The host builder.</param>
/// <returns>A configured command application.</returns>
public static CommandApp<DefaultCommand> BuildApp(this IHostBuilder builder)
{
var registrar = new TypeRegistrar(builder);
var app = new CommandApp<DefaultCommand>(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;
}
}
@@ -0,0 +1,32 @@
namespace StreamShorts.Console.Hosting;
/// <summary>
/// Provides type registration services for the dependency injection container.
/// </summary>
/// <inheritdoc/>
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<object> func)
{
ArgumentNullException.ThrowIfNull(func);
_builder.ConfigureServices((_, services) => services.AddSingleton(service, _ => func()));
}
}
@@ -0,0 +1,20 @@
namespace StreamShorts.Console.Hosting;
/// <summary>
/// Provides type resolution services using the dependency injection container.
/// </summary>
/// <inheritdoc/>
internal sealed 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();
}
}
+59 -5
View File
@@ -1,7 +1,61 @@
// See https://aka.ms/new-console-template for more information
using System.Globalization;
using System.Resources;
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 resourceManager = new ResourceManager("StreamShorts.Console.Resources.Resources", typeof(Program).Assembly);
try
{
var appName = Assembly.GetExecutingAssembly().GetName().Name;
Log.Information("Starting {AppName}", appName);
Console.WriteLine(resourceManager.GetString("WelcomeMessage", CultureInfo.CurrentCulture));
await Host.CreateDefaultBuilder(args)
.ConfigureLogging(static l => l.ClearProviders())
.ConfigureHostConfiguration(static config => config.AddJsonFile("appsettings.json"))
.ConfigureServices(static (_, services) =>
{
services.AddHttpClient();
services.AddSingleton(AnsiConsole.Console);
services.AddSingleton<IFileSystem, FileSystem>();
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAudioExtractor, AudioExtractor>();
services.AddSingleton<ITranscriber, WhisperTranscriber>();
services.AddSingleton<ITranscriptAnalyzer, GeminiAnalyzer>(sp =>
{
const string modelOptionName = "Model";
const string keyOptionName = "ApiKey";
var config = sp.GetRequiredService<IConfiguration>();
var geminiSection = config.GetSection("Gemini");
var key = geminiSection[keyOptionName];
var model = geminiSection[modelOptionName];
if (string.IsNullOrWhiteSpace(key))
{
throw new InvalidOperationException($"{keyOptionName} is not configured in appsettings.json.");
}
var clientFactory = sp.GetRequiredService<IHttpClientFactory>();
return new GeminiAnalyzer(clientFactory, key, model);
});
services.AddSingleton<IShortsCreator, ShortsCreator>();
})
.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();
}
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<data name="WelcomeMessage" xml:space="preserve">
<value>Welcome to StreamShorts!</value>
</data>
</root>
@@ -1,7 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyTitle>StreamShorts.Console</AssemblyTitle>
<Product>StreamShorts.Console</Product>
<Description>A command-line interface for StreamShorts</Description>
<Version>0.0.0</Version>
<Authors>Stevan Freeborn</Authors>
<OutputType>Exe</OutputType>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="Spectre.Console.Cli" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
<PackageReference Include="Whisper.net.AllRuntimes" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StreamShorts.Library\StreamShorts.Library.csproj" />
</ItemGroup>
</Project>
+24
View File
@@ -0,0 +1,24 @@
global using System.ComponentModel;
global using System.IO.Abstractions;
global using System.Reflection;
global using System.Text.Json;
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;
global using StreamShorts.Library.Analysis;
global using StreamShorts.Library.Analysis.Gemini;
global using StreamShorts.Library.Media.Audio;
global using StreamShorts.Library.Media.Video;
global using StreamShorts.Library.Transcription;
@@ -0,0 +1,31 @@
namespace StreamShorts.Library.Analysis;
/// <summary>
/// Represents an error that occurs during transcript analysis.
/// </summary>
public sealed class FailedTranscriptAnalysisException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="FailedTranscriptAnalysisException"/> class.
/// </summary>
public FailedTranscriptAnalysisException()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FailedTranscriptAnalysisException"/> class with a specified error message.
/// /// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
public FailedTranscriptAnalysisException(string message) : base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FailedTranscriptAnalysisException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public FailedTranscriptAnalysisException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
/// <summary>
/// Represents content with role and parts for Gemini API.
/// </summary>
/// <param name="Role">The role of the content (e.g., "user", "assistant").</param>
/// <param name="Parts">The array of content parts.</param>
internal record Content(
[property: JsonPropertyName("role")]
string Role,
[property: JsonPropertyName("parts")]
Part[] Parts
);
/// <summary>
/// Represents a part of content for Gemini API.
/// </summary>
/// <param name="Text">The text content of the part.</param>
internal record Part(
[property: JsonPropertyName("text")]
string Text
);
@@ -0,0 +1,79 @@
using System.Text;
using System.Text.Json;
using StreamShorts.Library.Analysis.Prompts;
using StreamShorts.Library.Transcription;
namespace StreamShorts.Library.Analysis.Gemini;
/// <summary>
/// Represents an analyzer that uses Gemini to analyze transcript segments and generate short clips.
/// </summary>
/// <inheritdoc/>
public sealed class GeminiAnalyzer(
IHttpClientFactory httpClientFactory,
string apiKey,
string? model = null
) : ITranscriptAnalyzer
{
private readonly IHttpClientFactory _httpClientFactory =
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
private readonly string _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
private readonly string _model = model ?? "gemini-2.5-flash-lite";
private readonly IAnalysisPrompt _prompt = new DefaultAnalysisPrompt();
public GeminiAnalyzer(
IHttpClientFactory httpClientFactory,
string apiKey,
IAnalysisPrompt prompt,
string? model = null
) : this(httpClientFactory, apiKey, model)
{
_prompt = prompt ?? throw new ArgumentNullException(nameof(prompt));
}
public async Task<TranscriptAnalysis> AnalyzeAsync(IEnumerable<TranscriptionSegment> segments)
{
try
{
using var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromMinutes(5);
var requestUrl =
$"https://generativelanguage.googleapis.com/v1beta/models/{_model}:generateContent?key={_apiKey}";
var generateContentRequest = new GenerateContentRequest(
[
new Content(
Role: "user",
Parts: [new Part(Text: _prompt.GetPrompt(segments))]
)
],
new GenerationConfig(ResponseMimeType: "application/json")
);
using var requestContent = new StringContent(
JsonSerializer.Serialize(generateContentRequest),
Encoding.UTF8,
"application/json"
);
using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl) { Content = requestContent };
var response = await client.SendAsync(request).ConfigureAwait(false);
var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var responseJson = JsonSerializer.Deserialize<GenerateContentResponse>(responseContent);
var candidatesText = responseJson?
.Candidates?
.FirstOrDefault()?
.Content
.Parts?.FirstOrDefault()?
.Text;
var clips = JsonSerializer.Deserialize<List<ShortCandidate>>(candidatesText ?? string.Empty);
return new TranscriptAnalysis(clips ?? []);
}
catch (Exception e)
{
throw new FailedTranscriptAnalysisException("Failed to analyze transcript segments using Gemini.", e);
}
}
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace StreamShorts.Library.Analysis.Gemini;
/// <summary>
/// Represents a request to generate content using Gemini.
/// </summary>
/// <param name="Contents">The content array for the request.</param>
/// <param name="GenerationConfig">The generation configuration.</param>
internal record GenerateContentRequest(
[property: JsonPropertyName("contents")]
Content[] Contents,
[property: JsonPropertyName("generationConfig")]
GenerationConfig GenerationConfig
);
/// <summary>
/// Represents configuration for content generation.
/// </summary>
/// <param name="ResponseMimeType">The MIME type for the response.</param>
internal record GenerationConfig(
[property: JsonPropertyName("responseMimeType")]
string ResponseMimeType
);
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace StreamShorts.Library.Analysis.Gemini;
/// <summary>
/// Represents a response from the Gemini content generation API.
/// </summary>
/// <param name="Candidates">The array of candidate responses.</param>
internal record GenerateContentResponse(
[property: JsonPropertyName("candidates")]
Candidate[] Candidates
);
/// <summary>
/// Represents a candidate response from content generation.
/// </summary>
/// <param name="Content">The generated content.</param>
internal record Candidate(
[property: JsonPropertyName("content")]
Content Content
);
@@ -0,0 +1,17 @@
using StreamShorts.Library.Transcription;
namespace StreamShorts.Library.Analysis;
/// <summary>
/// Defines the contract for transcript analyzers that process segments of a transcript and produce an analysis result containing short clips.
/// </summary>
public interface ITranscriptAnalyzer
{
/// <summary>
/// Analyzes the provided transcript segments and generates a transcript analysis result.
/// </summary>
/// <param name="segments">The transcript segments to analyze.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="TranscriptAnalysis"/> containing the short clips derived from the transcript.</returns>
/// <exception cref="FailedTranscriptAnalysisException">Thrown when the analysis fails due to an error.</exception>
Task<TranscriptAnalysis> AnalyzeAsync(IEnumerable<TranscriptionSegment> segments);
}
@@ -0,0 +1,50 @@
using System.Globalization;
using System.Text;
using StreamShorts.Library.Transcription;
namespace StreamShorts.Library.Analysis.Prompts;
/// <summary>
/// Default implementation of the analysis prompt for generating YouTube Shorts.
/// </summary>
/// <inheritdoc/>
internal sealed class DefaultAnalysisPrompt : IAnalysisPrompt
{
private static readonly CompositeFormat Prompt = CompositeFormat.Parse(@"
I need your help to transform my YouTube live stream transcript into engaging YouTube Shorts. Act as my content editor and pinpoint **all potential candidate segments** that are perfect for short-form video. I'm looking for clips that are:
- **Funny:** Moments that will make viewers laugh.
- **Informative:** Sections packed with valuable information or tips.
- **Insightful:** Portions offering unique perspectives or 'aha!' moments.
For each suggested short, please provide:
- The **start time** of the initial segment and the **end time** of the final segment. The duration of each short should be no longer than 3 minutes, but **aim for durations between 15 seconds and 60 seconds**. However, the short **must be as long as necessary to capture the complete thought or idea**, even if it means exceeding the target range or extending slightly to capture all necessary dialogue.
- A concise **title** that grabs attention.
- A brief **description** highlighting the short's content and its appeal.
- An **explanation** of why this particular segment is suitable for a YouTube Short, focusing on its potential for discoverability and engagement.
Please format your response as a JSON array of objects with the following structure:
```json
{{
""title"": ""string"",
""start_time"": ""string"",
""end_time"": ""string"",
""description"": ""string"",
""explanation"": ""string""
}}
```
Here is the transcript of my YouTube live stream:
{0}
");
public string GetPrompt(IEnumerable<TranscriptionSegment> segments)
{
var transcript = string.Join(Environment.NewLine, segments);
return string.Format(CultureInfo.InvariantCulture, Prompt, transcript);
}
}
@@ -0,0 +1,16 @@
using StreamShorts.Library.Transcription;
namespace StreamShorts.Library.Analysis.Prompts;
/// <summary>
/// Defines the contract for analysis prompts used in transcript analysis.
/// </summary>
public interface IAnalysisPrompt
{
/// <summary>
/// Generates a prompt based on the provided transcript segments.
/// </summary>
/// <param name="transcript">The transcript segments to analyze.</param>
/// <returns>A formatted prompt string for analysis.</returns>
string GetPrompt(IEnumerable<TranscriptionSegment> transcript);
}
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace StreamShorts.Library.Analysis;
/// <summary>
/// Represents a short candidate derived from a transcript.
/// </summary>
public record ShortCandidate(
[property: JsonPropertyName("title")]
string Title,
[property: JsonPropertyName("description")]
string Description,
[property: JsonPropertyName("explanation")]
string Explanation,
[property: JsonPropertyName("start_time")]
TimeSpan StartTime,
[property: JsonPropertyName("end_time")]
TimeSpan EndTime
);
@@ -0,0 +1,12 @@
namespace StreamShorts.Library.Analysis;
/// <summary>
/// Represents the analysis of a transcript, containing short clips derived from the transcript.
/// </summary>
public sealed class TranscriptAnalysis(IEnumerable<ShortCandidate> candidates)
{
/// <summary>
/// Gets the short clips derived from the transcript.
/// </summary>
public IEnumerable<ShortCandidate> Candidates { get; init; } = candidates;
}
-6
View File
@@ -1,6 +0,0 @@
namespace StreamShorts.Library;
public class Class1
{
}
@@ -0,0 +1,74 @@
namespace StreamShorts.Library.Media.Audio;
/// <summary>
/// Extracts audio from video files.
/// </summary>
/// <inheritdoc/>
public sealed class AudioExtractor : IAudioExtractor
{
private readonly IVideoService _videoService = new FFMpegService();
/// <summary>
/// Initializes a new instance of the <see cref="AudioExtractor"/> class.
/// </summary>
public AudioExtractor()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AudioExtractor"/> class with a specified <see cref="IVideoService"/>.
/// </summary>
/// <param name="videoService">The video service to use for audio extraction.</param>
/// <exception cref="ArgumentNullException">Thrown when the video service is null.</exception>
internal AudioExtractor(IVideoService videoService)
{
_videoService = videoService ?? throw new ArgumentNullException(nameof(videoService), $"{nameof(videoService)} cannot be null");
}
public async Task<Stream> ExtractMp3FromMp4Async(Stream video)
{
if (video is null)
{
throw new ArgumentNullException(nameof(video), "Video stream cannot be null");
}
if (video.CanRead is false)
{
throw new ArgumentException("Video stream must be readable", nameof(video));
}
if (video.CanSeek is false)
{
throw new ArgumentException("Video stream must be seekable", nameof(video));
}
var originalPosition = video.Position;
try
{
var mp3Stream = new MemoryStream();
using var mp4Stream = new MemoryStream();
await video.CopyToAsync(mp4Stream).ConfigureAwait(false);
mp4Stream.Position = 0;
var wasExtracted = await _videoService.ExtractAudioFromVideoAsync(mp4Stream, mp3Stream).ConfigureAwait(false);
if (wasExtracted is false)
{
throw new FailedAudioExtractionException("Failed to extract audio from the video stream.");
}
mp3Stream.Position = 0;
return mp3Stream;
}
catch (Exception e) when (e is not FailedAudioExtractionException)
{
throw new FailedAudioExtractionException("Failed to extract audio", e);
}
finally
{
video.Position = originalPosition;
}
}
}
@@ -0,0 +1,31 @@
namespace StreamShorts.Library.Media.Audio;
/// <summary>
/// Represents an error that occurs when audio extraction fails.
/// </summary>
public sealed class FailedAudioExtractionException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="FailedAudioExtractionException"/> class.
/// </summary>
public FailedAudioExtractionException() : base()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FailedAudioExtractionException"/> class with a specified error message.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
public FailedAudioExtractionException(string message) : base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FailedAudioExtractionException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public FailedAudioExtractionException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,19 @@
namespace StreamShorts.Library.Media.Audio;
/// <summary>
/// Represents an interface for extracting audio from video streams.
/// </summary>
public interface IAudioExtractor
{
/// <summary>
/// Extracts MP3 audio from an MP4 video stream.
/// </summary>
/// <param name="video">The input video stream.</param>
/// <returns>A stream containing the extracted MP3 audio.</returns>
/// <exception cref="ArgumentNullException">Thrown when the video stream is null.</exception>
/// <exception cref="ArgumentException">Thrown when the video stream is not readable.</exception>
/// <exception cref="ArgumentException">Thrown when the video stream is not seekable.</exception>
/// <exception cref="FailedAudioExtractionException">Thrown when the audio extraction fails.</exception>
/// <remarks>The method will preserve the passed video stream's data and position.</remarks>
Task<Stream> ExtractMp3FromMp4Async(Stream video);
}
@@ -0,0 +1,39 @@
using FFMpegCore;
using FFMpegCore.Enums;
using FFMpegCore.Pipes;
namespace StreamShorts.Library.Media;
/// <summary>
/// Represents a service for processing video files using FFMpeg.
/// </summary>
/// <inheritdoc/>
internal sealed class FFMpegService : IVideoService
{
public async Task<bool> ExtractAudioFromVideoAsync(Stream video, Stream audio)
{
return await FFMpegArguments
.FromPipeInput(new StreamPipeSource(video))
.OutputToPipe(
new StreamPipeSink(audio),
static o => o.DisableChannel(Channel.Video).ForceFormat("mp3")
)
.ProcessAsynchronously()
.ConfigureAwait(false);
}
public async Task CreateClipFromVideoAsync(
string sourcePath,
string destinationPath,
TimeSpan startTime,
TimeSpan endTime,
TimeSpan? buffer = null
)
{
var start = startTime - (buffer ?? TimeSpan.Zero);
var end = endTime + (buffer ?? TimeSpan.Zero);
await FFMpeg.SubVideoAsync(sourcePath, destinationPath, start, end)
.ConfigureAwait(false);
}
}
@@ -0,0 +1,40 @@
namespace StreamShorts.Library.Media;
/// <summary>
/// Represents a service for processing audio files.
/// </summary>
internal interface IAudioService
{
/// <summary>
/// Converts an MP3 stream to a WAV stream with a 16 kHz sample rate.
/// </summary>
/// <param name="mp3">The input MP3 stream.</param>
/// <returns>The output WAV stream.</returns>
/// <exception cref="ArgumentNullException">Thrown when the MP3 stream is null.</exception>
/// <exception cref="ArgumentException">Thrown when the MP3 stream is not readable or seekable.</exception>
/// <remarks>The method will preserve the passed MP3 stream's data and position.</remarks
Stream ConvertMp3ToWav16(Stream mp3);
/// <summary>
/// Gets the number of segments in a WAV stream based on the specified segment duration.
/// </summary>
/// <param name="wavStream">The input WAV stream.</param>
/// <param name="segmentDuration">The duration of each segment.</param>
/// <returns>The number of segments.</returns>
/// <exception cref="ArgumentNullException">Thrown when the WAV stream is null.</exception>
/// <exception cref="ArgumentException">Thrown when the WAV stream is not readable or seekable.</exception>
/// <remarks>The method will preserve the passed WAV stream's data and position.</remarks>
int GetNumberOfWavSegments(Stream wavStream, TimeSpan segmentDuration);
/// <summary>
/// Gets a segment of a WAV stream based on the specified segment number and duration.
/// </summary>
/// <param name="wavStream">The input WAV stream.</param>
/// <param name="segmentNumber">The segment number to retrieve.</param>
/// <param name="segmentDuration">The duration of each segment.</param>
/// <returns>The segment stream.</returns>
/// <exception cref="ArgumentNullException">Thrown when the WAV stream is null.</exception>
/// <exception cref="ArgumentException">Thrown when the WAV stream is not readable or seekable.</exception>
/// <remarks>The method will preserve the passed WAV stream's data and position.</remarks>
Stream GetWavSegment(Stream wavStream, int segmentNumber, TimeSpan segmentDuration);
}
@@ -0,0 +1,32 @@
namespace StreamShorts.Library.Media;
/// <summary>
/// Represents a service for processing video files.
/// </summary>
internal interface IVideoService
{
/// <summary>
/// Extracts audio from a video stream and writes it to an audio stream.
/// </summary>
/// <param name="video">The input video stream.</param>
/// <param name="audio">The output audio stream.</param>
/// <returns>A task that represents the asynchronous operation. The task result indicates whether the extraction was successful.</returns>
Task<bool> ExtractAudioFromVideoAsync(Stream video, Stream audio);
/// <summary>
/// Creates a clip from a video file based on the specified start and end times.
/// </summary>
/// <param name="sourcePath">The path to the source video file.</param>
/// <param name="destinationPath">The path where the created clip will be saved.</param>
/// <param name="startTime">The start time of the clip.</param>
/// <param name="endTime">The end time of the clip.</param>
/// <param name="buffer">An optional buffer time to include before and after the clip segment.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task CreateClipFromVideoAsync(
string sourcePath,
string destinationPath,
TimeSpan startTime,
TimeSpan endTime,
TimeSpan? buffer = null
);
}
@@ -0,0 +1,87 @@
using NAudio.Wave;
namespace StreamShorts.Library.Media;
/// <summary>
/// Represents a service for processing audio files using NAudio.
/// </summary>
/// <inheritdoc/>
internal sealed class NAudioService : IAudioService
{
public Stream ConvertMp3ToWav16(Stream mp3)
{
return UseStream(mp3, stream =>
{
using var reader = new Mp3FileReader(mp3);
var outFormat = new WaveFormat(16000, reader.WaveFormat.Channels);
using var resampler = new MediaFoundationResampler(reader, outFormat);
var waveStream = new MemoryStream();
WaveFileWriter.WriteWavFileToStream(waveStream, resampler);
waveStream.Position = 0;
return waveStream;
});
}
public int GetNumberOfWavSegments(Stream wavStream, TimeSpan segmentDuration)
{
return UseStream(wavStream, stream =>
{
using var waveReader = new WaveFileReader(wavStream);
var totalDuration = waveReader.TotalTime;
var segmentCount = (int)Math.Ceiling(totalDuration.TotalMilliseconds / segmentDuration.TotalMilliseconds);
return segmentCount;
});
}
public Stream GetWavSegment(Stream wavStream, int segmentNumber, TimeSpan segmentDuration)
{
return UseStream(wavStream, stream =>
{
using var segmentWaveReader = new WaveFileReader(wavStream);
var segment = segmentWaveReader.ToSampleProvider()
.Skip(segmentNumber * segmentDuration)
.Take(segmentDuration);
var segmentProvider = segment.ToWaveProvider16();
var segmentStream = new MemoryStream();
WaveFileWriter.WriteWavFileToStream(segmentStream, segmentProvider);
segmentStream.Position = 0;
return segmentStream;
});
}
private static T UseStream<T>(Stream stream, Func<Stream, T> action)
{
ValidateStream(stream);
var originalPosition = stream.Position;
try
{
stream.Position = 0;
return action(stream);
}
finally
{
stream.Position = originalPosition;
}
}
private static void ValidateStream(Stream stream)
{
if (stream == null)
{
throw new ArgumentNullException(nameof(stream), $"{nameof(stream)} cannot be null");
}
if (stream.CanRead is false)
{
throw new ArgumentException($"{nameof(stream)} must be readable", nameof(stream));
}
if (stream.CanSeek is false)
{
throw new ArgumentException($"{nameof(stream)} must be seekable", nameof(stream));
}
}
}
@@ -0,0 +1,21 @@
using StreamShorts.Library.Analysis;
namespace StreamShorts.Library.Media.Video;
/// <summary>
/// Represents a service that creates video shorts.
/// </summary>
public interface IShortsCreator
{
/// <summary>
/// Creates a video short from the specified source video file based on the provided candidate details.
/// </summary>
/// <param name="sourcePath">The path to the source video file.</param>
/// <param name="candidate">The details of the short candidate.</param>
/// <param name="destinationPath">The path where the created short will be saved.</param>
/// <param name="buffer">An optional buffer time to include before and after the short segment.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="sourcePath"/> or <paramref name="destinationPath"/> is null or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="candidate"/> is null.</exception>
public Task CreateShortAsync(string sourcePath, ShortCandidate candidate, string destinationPath, TimeSpan? buffer = null);
}
@@ -0,0 +1,50 @@
using StreamShorts.Library.Analysis;
namespace StreamShorts.Library.Media.Video;
/// <summary>
/// Represents a service that creates video shorts.
/// </summary>
/// <inheritdoc/>
public sealed class ShortsCreator : IShortsCreator
{
private readonly IVideoService _videoService = new FFMpegService();
/// <summary>
/// Initializes a new instance of the <see cref="ShortsCreator"/> class.
/// </summary>
public ShortsCreator()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ShortsCreator"/> class with a specified <see cref="IVideoService"/>.
/// </summary>
/// <param name="videoService">The video service.</param>
/// <exception cref="ArgumentNullException">Thrown when the video service is null.</exception>
internal ShortsCreator(IVideoService videoService)
{
_videoService = videoService ?? throw new ArgumentNullException(nameof(videoService), $"{nameof(videoService)} cannot be null");
}
public async Task CreateShortAsync(string sourcePath, ShortCandidate candidate, string destinationPath, TimeSpan? buffer = null)
{
if (string.IsNullOrWhiteSpace(sourcePath))
{
throw new ArgumentNullException(nameof(sourcePath), $"{nameof(sourcePath)} cannot be null or whitespace");
}
if (candidate is null)
{
throw new ArgumentNullException(nameof(candidate), $"{nameof(candidate)} cannot be null");
}
if (string.IsNullOrWhiteSpace(destinationPath))
{
throw new ArgumentNullException(nameof(destinationPath), $"{nameof(destinationPath)} cannot be null or whitespace");
}
await _videoService.CreateClipFromVideoAsync(sourcePath, destinationPath, candidate.StartTime, candidate.EndTime, buffer)
.ConfigureAwait(false);
}
}
@@ -1,3 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="FFMpegCore" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="NAudio" />
<PackageReference Include="Whisper.net.AllRuntimes" />
</ItemGroup>
</Project>
@@ -0,0 +1,15 @@
namespace StreamShorts.Library.Transcription;
/// <summary>
/// Represents a transcriber interface for audio transcription.
/// </summary>
public interface ITranscriber
{
/// <summary>
/// Transcribes the audio stream into text segments.
/// </summary>
/// <param name="audio">The audio stream to transcribe.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>An <see cref="IAsyncEnumerable{T}"/> where T is <see cref="TranscriptionSegment"/>.</returns>
IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(Stream audio, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,19 @@
namespace StreamShorts.Library.Transcription;
/// <summary>
/// Represents a segment of transcribed audio.
/// </summary>
/// <param name="StartTime">The start time of the segment.</param>
/// <param name="EndTime">The end time of the segment.</param>
/// <param name="Text">The transcribed text of the segment.</param>
public sealed record TranscriptionSegment(
TimeSpan StartTime,
TimeSpan EndTime,
string Text
)
{
public override string ToString()
{
return $"[{StartTime:hh\\:mm\\:ss} - {EndTime:hh\\:mm\\:ss}]: {Text}";
}
}
@@ -0,0 +1,85 @@
using System.Runtime.CompilerServices;
using StreamShorts.Library.Media;
using Whisper.net;
using Whisper.net.Ggml;
namespace StreamShorts.Library.Transcription;
/// <summary>
/// Represents a transcriber that uses Whisper for audio transcription.
/// </summary>
/// <inheritdoc/>
public sealed class WhisperTranscriber : ITranscriber, IDisposable
{
private readonly IAudioService _audioService = new NAudioService();
private WhisperProcessor? _whisperProcessor;
/// <summary>
/// Initializes a new instance of the <see cref="WhisperTranscriber"/> class.
/// </summary>
public WhisperTranscriber()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="WhisperTranscriber"/> class with a specified audio service.
/// </summary>
/// <param name="audioService">The audio service to use for audio processing.</param>
/// <exception cref="ArgumentNullException">Thrown when the audio service is null.</exception
internal WhisperTranscriber(IAudioService audioService)
{
_audioService = audioService ?? throw new ArgumentNullException(nameof(audioService), $"{nameof(audioService)} cannot be null");
}
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(Stream audio, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var segmentDuration = TimeSpan.FromMinutes(2);
var wavStream = _audioService.ConvertMp3ToWav16(audio);
var numberOfSegments = _audioService.GetNumberOfWavSegments(wavStream, segmentDuration);
foreach (var segmentNumber in Enumerable.Range(0, numberOfSegments))
{
var segmentStream = _audioService.GetWavSegment(wavStream, segmentNumber, segmentDuration);
var durationOffset = TimeSpan.FromMilliseconds(segmentNumber * segmentDuration.TotalMilliseconds);
await foreach (var result in ProcessSegmentAsync(segmentStream, cancellationToken).ConfigureAwait(false))
{
yield return new TranscriptionSegment(
result.Start + durationOffset,
result.End + durationOffset,
result.Text
);
}
}
}
private async IAsyncEnumerable<SegmentData> ProcessSegmentAsync(
Stream segmentStream,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (_whisperProcessor is null)
{
using var modelMemoryStream = new MemoryStream();
var model = await WhisperGgmlDownloader.Default.GetGgmlModelAsync(GgmlType.TinyEn, cancellationToken: cancellationToken).ConfigureAwait(false);
await model.CopyToAsync(modelMemoryStream, cancellationToken).ConfigureAwait(false);
var whisperFactory = WhisperFactory.FromBuffer(modelMemoryStream.ToArray());
_whisperProcessor = whisperFactory.CreateBuilder()
.WithLanguage("en")
.Build();
}
await foreach (var result in _whisperProcessor.ProcessAsync(segmentStream, cancellationToken).ConfigureAwait(false))
{
yield return result;
}
}
public void Dispose()
{
_whisperProcessor?.Dispose();
_whisperProcessor = null;
}
}
+4
View File
@@ -0,0 +1,4 @@
[*.cs]
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA2007.severity = none
dotnet_diagnostic.CA2201.severity = none
+4 -3
View File
@@ -1,13 +1,14 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AwesomeAssertions" Version="9.1.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.14.2" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.50.0" />
<PackageVersion Include="xunit.v3" Version="3.0.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.2" />
</ItemGroup>
</Project>
@@ -1,11 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="Moq" />
<PackageReference Include="Spectre.Console.Testing" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<Target Name="GenerateHtmlCoverageReport" AfterTargets="VSTest">
<Exec Command="reportgenerator -reports:$(OutputPath)/TestResults/coverage.cobertura.xml -targetdir:$(OutputPath)/TestResults/Html -reporttypes:Html_Dark" />
</Target>
<ItemGroup>
<ProjectReference Include="..\..\src\StreamShorts.Console\StreamShorts.Console.csproj" />
</ItemGroup>
@@ -0,0 +1,88 @@
namespace StreamShorts.Console.Tests.Unit.Hosting;
internal class TypeRegistrarTests
{
[Fact]
public void Constructor_WhenCalled_ItShouldNotThrowShould()
{
var mockBuilder = new Mock<IHostBuilder>();
var action = () => new TypeRegistrar(mockBuilder.Object);
action.Should().NotThrow<Exception>();
}
[Fact]
public void Build_WhenCalled_ItShouldReturnResolverAndBuildHost()
{
var mockHost = new Mock<IHost>();
var mockBuilder = new Mock<IHostBuilder>();
mockBuilder
.Setup(static b => b.Build())
.Returns(mockHost.Object);
var registrar = new TypeRegistrar(mockBuilder.Object);
var resolver = registrar.Build();
resolver.Should().BeOfType<TypeResolver>();
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<IService>();
service.Should().NotBeNull();
service.Should().BeOfType<ServiceImplementation>();
}
[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<IService>();
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<IService>();
service.Should().NotBeNull();
service.Should().BeOfType<ServiceImplementation>();
}
[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<Exception>();
}
private interface IService { }
private sealed class ServiceImplementation : IService { }
}
@@ -0,0 +1,73 @@
namespace StreamShorts.Console.Tests.Unit.Hosting;
internal class TypeResolverTests
{
[Fact]
public void Constructor_WhenCalledWithNullHost_ItShouldThrowArgumentNullException()
{
var action = static () => new TypeResolver(null!);
action.Should().Throw<ArgumentNullException>();
}
[Fact]
public void Resolve_WhenTypeIsNull_ItShouldReturnNull()
{
var mockHost = new Mock<IHost>();
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<IHost>();
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<TestService>();
}
[Fact]
public void Resolve_WhenCalledWithUnregisteredType_ItShouldReturnNull()
{
var services = new ServiceCollection();
var mockHost = new Mock<IHost>();
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<IHost>();
var resolver = new TypeResolver(mockHost.Object);
resolver.Dispose();
mockHost.Verify(static h => h.Dispose(), Times.Once);
}
private sealed class TestService { }
}
@@ -1,10 +0,0 @@
namespace StreamShorts.Console.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
Assert.True(true);
}
}
@@ -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;
@@ -0,0 +1,24 @@
namespace StreamShorts.Library.Tests.Data;
internal static class TestData
{
private const string TestVideoFile = "video.mp4";
private const string ExtractedAudioFile = "extracted_audio.mp3";
public static FileStream GetTestVideo()
{
var filePath = GetTestFilePath(TestVideoFile);
return File.OpenRead(filePath);
}
public static FileStream GetExtractedAudio()
{
var filePath = GetTestFilePath(ExtractedAudioFile);
return File.OpenRead(filePath);
}
private static string GetTestFilePath(string fileName)
{
return Path.Combine(Directory.GetCurrentDirectory(), "Data", "Files", fileName);
}
}
@@ -0,0 +1,27 @@
namespace StreamShorts.Library.Tests.Integration.Media.Audio;
internal class AudioExtractorTests
{
private readonly AudioExtractor _sut = new();
[Fact]
public async Task ExtractMp3FromMp4Async_WhenCalled_ItShouldExtractAudio()
{
using var testVideo = TestData.GetTestVideo();
using var extractedAudio = TestData.GetExtractedAudio();
var result = await _sut.ExtractMp3FromMp4Async(testVideo);
var audioBytes = await ConvertStreamToBytesAsync(extractedAudio);
var resultBytes = await ConvertStreamToBytesAsync(result);
resultBytes.Should().Equal(audioBytes);
}
private static async Task<byte[]> ConvertStreamToBytesAsync(Stream stream)
{
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return ms.ToArray();
}
}
@@ -1,13 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<Target Name="GenerateHtmlCoverageReport" AfterTargets="VSTest">
<Exec Command="reportgenerator -reports:$(OutputPath)/TestResults/coverage.cobertura.xml -targetdir:$(OutputPath)/TestResults/Html -reporttypes:Html_Dark" />
</Target>
<ItemGroup>
<ProjectReference Include="..\..\src\StreamShorts.Library\StreamShorts.Library.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Data/Files/**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,144 @@
using System.Text;
using StreamShorts.Library.Media;
namespace StreamShorts.Library.Tests.Unit.Media.Audio;
internal class AudioExtractorTests
{
private readonly Mock<IVideoService> _mockFfmpegService = new();
private readonly AudioExtractor _sut;
public AudioExtractorTests()
{
_sut = new(_mockFfmpegService.Object);
}
[Fact]
public void Constructor_WhenCalledWithNullFfmpegService_ItShouldThrow()
{
var action = () => new AudioExtractor(null!);
action.Should().Throw<ArgumentNullException>();
}
[Fact]
public async Task ExtractMp3FromMp4Async_WhenVideoIsNull_ItShouldThrow()
{
var action = async () => await _sut.ExtractMp3FromMp4Async(null!);
await action.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task ExtractMp3FromMp4Async_WhenVideoIsNotReadable_ItShouldThrow()
{
var mockStream = new Mock<Stream>();
mockStream.Setup(s => s.CanRead).Returns(false);
var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object);
await action.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task ExtractMp3FromMp4Async_WhenVideoIsNotSeekable_ItShouldThrow()
{
var mockStream = new Mock<Stream>();
mockStream.Setup(s => s.CanRead).Returns(true);
mockStream.Setup(s => s.CanSeek).Returns(false);
var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object);
await action.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceThrows_ItShouldThrow()
{
var mockStream = new Mock<Stream>();
mockStream.Setup(static s => s.CanRead).Returns(true);
mockStream.Setup(static s => s.CanSeek).Returns(true);
_mockFfmpegService.
Setup(
static m => m.ExtractAudioFromVideoAsync(
It.IsAny<Stream>(),
It.IsAny<Stream>()
)
)
.ThrowsAsync(new Exception());
var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object);
await action.Should().ThrowAsync<FailedAudioExtractionException>();
}
[Fact]
public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceFailsExtraction_ItShouldThrow()
{
var mockStream = new Mock<Stream>();
mockStream.Setup(static s => s.CanRead).Returns(true);
mockStream.Setup(static s => s.CanSeek).Returns(true);
_mockFfmpegService.
Setup(
static m => m.ExtractAudioFromVideoAsync(
It.IsAny<Stream>(),
It.IsAny<Stream>()
)
)
.ReturnsAsync(false);
var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object);
await action.Should().ThrowAsync<FailedAudioExtractionException>();
}
[Fact]
public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceSucceedsAtExtractingAudio_ItShouldReturnStream()
{
var mockStream = new Mock<Stream>();
mockStream.Setup(static s => s.CanRead).Returns(true);
mockStream.Setup(static s => s.CanSeek).Returns(true);
_mockFfmpegService.
Setup(
static m => m.ExtractAudioFromVideoAsync(
It.IsAny<Stream>(),
It.IsAny<Stream>()
)
)
.ReturnsAsync(true);
var result = await _sut.ExtractMp3FromMp4Async(mockStream.Object);
result.Should().BeAssignableTo<Stream>();
result.Should().BeOfType<MemoryStream>();
}
[Fact]
public async Task ExtractMp3FromMp4Async_WhenCalled_ItShouldNotMutatePositionOfPassedStream()
{
var text = "hello world";
var textByte = Encoding.UTF8.GetBytes(text);
var stream = new MemoryStream(textByte);
var positionToRead = 5;
var buffer = new byte[5];
await stream.ReadAsync(buffer.AsMemory(0, positionToRead), TestContext.Current.CancellationToken);
_mockFfmpegService.
Setup(
static m => m.ExtractAudioFromVideoAsync(
It.IsAny<Stream>(),
It.IsAny<Stream>()
)
)
.ReturnsAsync(true);
await _sut.ExtractMp3FromMp4Async(stream);
stream.Position.Should().Be(positionToRead);
}
}
@@ -1,10 +0,0 @@
namespace StreamShorts.Library.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
Assert.True(true);
}
}
@@ -0,0 +1,6 @@
global using AwesomeAssertions;
global using Moq;
global using StreamShorts.Library.Media.Audio;
global using StreamShorts.Library.Tests.Data;