Merge pull request #1 from StevanFreeborn/stevanfreeborn/feat/initial-console-poc
feat: initial console app
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
Vendored
+2
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"Ggml",
|
||||
"resampler",
|
||||
"resx"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[*.cs]
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
dotnet_diagnostic.CA2007.severity = none
|
||||
dotnet_diagnostic.CA2201.severity = none
|
||||
@@ -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;
|
||||
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
Reference in New Issue
Block a user