diff --git a/.editorconfig b/.editorconfig index 398cb36..b4b5c8a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,6 +23,11 @@ insert_final_newline = false #### .NET Coding Conventions #### [*.{cs,vb}] +# Diagnostic severity preferences +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0100.severity = none +dotnet_diagnostic.CA1848.severity = none + # Organize usings dotnet_separate_import_directive_groups = true dotnet_sort_system_directives_first = true @@ -59,7 +64,7 @@ dotnet_style_prefer_auto_properties = true:suggestion dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion dotnet_style_prefer_compound_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:silent dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_inferred_tuple_names = true:suggestion diff --git a/.github/workflows/publish_console.yml b/.github/workflows/publish_console.yml new file mode 100644 index 0000000..1d52733 --- /dev/null +++ b/.github/workflows/publish_console.yml @@ -0,0 +1,102 @@ +name: Publish Console +on: + workflow_dispatch: + push: + paths: + - src/StreamShorts.Console/** + - src/StreamShorts.Console.Tests/** + - src/StreamShorts.Library/** + - src/StreamShorts.Library.Tests/** + - StreamShorts.sln + - Directory.Build.props + branches: + - main +jobs: + version: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.ACTIONS_PAT }} + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + - name: Install versionize + run: dotnet tool install --global Versionize + - name: Setup git + run: | + git config --local user.email "65925598+StevanFreeborn@users.noreply.github.com" + git config --local user.name "Stevan Freeborn" + - name: Run versionize + id: versionize + run: versionize -i --exit-insignificant-commits --workingDir ./src/StreamShorts.Console --commit-suffix "[skip ci]" + continue-on-error: true + - name: Upload changelog + if: steps.versionize.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: change-log + path: src/StreamShorts.Console/CHANGELOG.md + - name: Push changes to GitHub + if: steps.versionize.outcome == 'success' + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.ACTIONS_PAT }} + branch: ${{ github.ref }} + tags: true + outputs: + is_new_version: ${{ steps.versionize.outcome == 'success' }} + publish: + needs: [version] + if: needs.version.outputs.is_new_version == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + token: ${{ secrets.ACTIONS_PAT }} + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + - name: Build + run: dotnet build src + - name: Publish for mac-os + run: dotnet publish src/StreamShorts.Console/StreamsShorts.Console.csproj -c Release -r osx-x64 -o dist/mac-os + - name: Rename mac-os file + run: mv dist/mac-os/StreamShorts.Console dist/mac-os/StreamShorts.Console_macos + - name: Publish for linux-os + run: dotnet publish src/StreamShorts.Console/StreamShorts.Console.csproj -c Release -r linux-x64 -o dist/linux-os + - name: Rename linux-os file + run: mv dist/linux-os/StreamShorts.Console dist/linux-os/StreamShorts.Console_linux + - name: Publish for windows-os + run: dotnet publish src/StreamShorts.Console/StreamShorts.Console.csproj -c Release -r win-x64 -o dist/windows-os + - name: Rename windows-os file + run: mv dist/windows-os/StreamShorts.Console.exe dist/windows-os/StreamShorts.Console_windows.exe + - name: Get project version + uses: kzrnm/get-net-sdk-project-versions-action@v1 + id: get-version + with: + proj-path: src/StreamShorts.Console/StreamShorts.Console.csproj + - name: Download changlog + uses: actions/download-artifact@v4 + with: + name: change-log + path: src/StreamShorts.Console + - name: Create release + uses: softprops/action-gh-release@v1 + with: + token: ${{ secrets.ACTIONS_PAT }} + name: StreamShorts.Console v${{ steps.get-version.outputs.version }} + tag_name: v${{ steps.get-version.outputs.version }} + draft: false + body_path: src/StreamShorts.Console/CHANGELOG.md + files: | + dist/mac-os/StreamShorts.Console_macos + dist/linux-os/StreamShorts.Console_linux + dist/windows-os/StreamShorts.Console_windows.exe \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 890c17d..fdd0136 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { "cSpell.words": [ + "Ggml", + "resampler", "resx" ] } \ No newline at end of file diff --git a/src/Directory.packages.props b/src/Directory.packages.props index d727d6b..c18e98a 100644 --- a/src/Directory.packages.props +++ b/src/Directory.packages.props @@ -1,7 +1,22 @@ - true - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/StreamShorts.Console/.editorconfig b/src/StreamShorts.Console/.editorconfig new file mode 100644 index 0000000..2dc531e --- /dev/null +++ b/src/StreamShorts.Console/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +dotnet_diagnostic.CA2007.severity = none diff --git a/src/StreamShorts.Console/Commands/DefaultCommand.cs b/src/StreamShorts.Console/Commands/DefaultCommand.cs new file mode 100644 index 0000000..e469c7c --- /dev/null +++ b/src/StreamShorts.Console/Commands/DefaultCommand.cs @@ -0,0 +1,162 @@ +namespace StreamShorts.Console.Commands; + +/// +/// The default command for processing video streams to create short clips. +/// +internal sealed class DefaultCommand( + IFileSystem fileSystem, + IAnsiConsole console, + IAudioExtractor audioExtractor, + ITranscriber transcriber, + ITranscriptAnalyzer transcriptAnalyzer, + IShortsCreator shortsCreator, + TimeProvider timeProvider +) : AsyncCommand +{ + private readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console)); + private readonly IAudioExtractor _audioExtractor = audioExtractor ?? throw new ArgumentNullException(nameof(audioExtractor)); + private readonly ITranscriber _transcriber = transcriber ?? throw new ArgumentNullException(nameof(transcriber)); + private readonly ITranscriptAnalyzer _transcriptAnalyzer = transcriptAnalyzer ?? throw new ArgumentNullException(nameof(transcriptAnalyzer)); + private readonly IShortsCreator _shortsCreator = shortsCreator ?? throw new ArgumentNullException(nameof(shortsCreator)); + private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + + /// + /// Represents the settings for the default command. + /// + internal class Settings : CommandSettings + { + /// + /// Gets or sets the path to the stream. + /// + [CommandArgument(0, "[Stream]")] + [Description("The path to the stream")] + public string Stream { get; init; } = string.Empty; + } + + public override ValidationResult Validate(CommandContext context, Settings settings) + { + if (string.IsNullOrWhiteSpace(settings.Stream)) + { + return ValidationResult.Error("Stream path must be provided."); + } + + if (_fileSystem.File.Exists(settings.Stream) is false) + { + return ValidationResult.Error($"The specified stream file '{settings.Stream}' does not exist."); + } + + var fileExtension = _fileSystem.Path.GetExtension(settings.Stream).ToUpperInvariant(); + + if (fileExtension != ".MP4") + { + return ValidationResult.Error("The specified stream file must be an .mp4 file."); + } + + return base.Validate(context, settings); + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + _console.MarkupLine($"[blue]Processing stream:[/] {settings.Stream}"); + var videoStream = _fileSystem.File.OpenRead(settings.Stream); + + Stream? audioStream = null; + + await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Extracting audio...", async _ => + { + audioStream = await _audioExtractor.ExtractMp3FromMp4Async(videoStream); + }); + + if (audioStream is null) + { + _console.MarkupLine("[red]Failed[/] to extract audio from the stream."); + return (int)ExitCode.FailedToExtractAudio; + } + + _console.MarkupLine($"[blue]Audio extracted[/] [green]successfully![/]"); + + List transcriptionSegments = []; + + await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Transcribing audio...", async ctx => + { + await foreach (var segment in _transcriber.TranscribeAsync(audioStream)) + { + transcriptionSegments.Add(segment); + var segmentTimeText = $@"[{segment.StartTime:hh\:mm\:ss} - {segment.EndTime:hh\:mm\:ss}]"; + ctx.Status($"Transcribed segment {segmentTimeText.EscapeMarkup()}"); + } + }); + + _console.MarkupLine($"[blue]Transcription completed[/] [green]successfully![/]"); + + var now = _timeProvider.GetUtcNow(); + var inputFileName = _fileSystem.Path.GetFileNameWithoutExtension(settings.Stream); + var outputDirectoryPath = _fileSystem.Path.Combine( + AppContext.BaseDirectory, + $"{now:yyyy_MM_dd_HH_mm_ss}_{inputFileName}" + ); + + _fileSystem.Directory.CreateDirectory(outputDirectoryPath); + + await _fileSystem.File.WriteAllTextAsync( + _fileSystem.Path.Combine(outputDirectoryPath, "transcription.txt"), + string.Join(Environment.NewLine, transcriptionSegments) + ); + + TranscriptAnalysis? analysis = null; + + await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Analyzing transcript...", async _ => + { + analysis = await _transcriptAnalyzer.AnalyzeAsync(transcriptionSegments); + }); + + if (analysis is null) + { + _console.MarkupLine("[red]Failed[/] to analyze transcript."); + return (int)ExitCode.FailedToAnalyzeTranscript; + } + + _console.MarkupLine($"[blue]Transcript analysis completed[/] [green]successfully![/]"); + + await _fileSystem.File.WriteAllTextAsync( + _fileSystem.Path.Combine(outputDirectoryPath, "analysis.json"), + JsonSerializer.Serialize(analysis, _jsonSerializerOptions) + ); + + await _console.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Creating shorts...", async ctx => + { + foreach (var candidate in analysis.Candidates) + { + ctx.Status($"Creating short: {candidate.Title.EscapeMarkup()}"); + var safeFileName = string.Concat(candidate.Title.Split(_fileSystem.Path.GetInvalidFileNameChars())); + var candidatePath = _fileSystem.Path.Combine(outputDirectoryPath, $"{safeFileName}.mp4"); + await _shortsCreator.CreateShortAsync(settings.Stream, candidate, candidatePath); + } + }); + + _console.MarkupLine($"[blue]Shorts created[/] [green]successfully![/]"); + + return 0; + } + + private enum ExitCode + { + FailedToExtractAudio, + FailedToAnalyzeTranscript, + } +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Hosting/HostBuilderExtensions.cs b/src/StreamShorts.Console/Hosting/HostBuilderExtensions.cs new file mode 100644 index 0000000..28c1516 --- /dev/null +++ b/src/StreamShorts.Console/Hosting/HostBuilderExtensions.cs @@ -0,0 +1,29 @@ +namespace StreamShorts.Console.Hosting; + +/// +/// Provides extension methods for building command applications from host builders. +/// +internal static class HostBuilderExtensions +{ + /// + /// Builds a command application from the host builder. + /// + /// The host builder. + /// A configured command application. + public static CommandApp BuildApp(this IHostBuilder builder) + { + var registrar = new TypeRegistrar(builder); + var app = new CommandApp(registrar); + + app.Configure(static c => + c.SetExceptionHandler(static (ex, resolver) => + { + var console = resolver?.Resolve(typeof(IAnsiConsole)) as IAnsiConsole; + console?.MarkupLine($"[red]An error occurred while executing the command:[/]"); + console?.WriteException(ex, ExceptionFormats.ShortenEverything); + }) + ); + + return app; + } +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Hosting/TypeRegistrar.cs b/src/StreamShorts.Console/Hosting/TypeRegistrar.cs new file mode 100644 index 0000000..bf236e0 --- /dev/null +++ b/src/StreamShorts.Console/Hosting/TypeRegistrar.cs @@ -0,0 +1,32 @@ +namespace StreamShorts.Console.Hosting; + +/// +/// Provides type registration services for the dependency injection container. +/// +/// +internal sealed class TypeRegistrar(IHostBuilder builder) : ITypeRegistrar +{ + private readonly IHostBuilder _builder = builder; + + public ITypeResolver Build() + { + return new TypeResolver(_builder.Build()); + } + + public void Register(Type service, Type implementation) + { + _builder.ConfigureServices((_, services) => services.AddSingleton(service, implementation)); + } + + public void RegisterInstance(Type service, object implementation) + { + _builder.ConfigureServices((_, services) => services.AddSingleton(service, implementation)); + } + + public void RegisterLazy(Type service, Func func) + { + ArgumentNullException.ThrowIfNull(func); + + _builder.ConfigureServices((_, services) => services.AddSingleton(service, _ => func())); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Hosting/TypeResolver.cs b/src/StreamShorts.Console/Hosting/TypeResolver.cs new file mode 100644 index 0000000..8814c1a --- /dev/null +++ b/src/StreamShorts.Console/Hosting/TypeResolver.cs @@ -0,0 +1,20 @@ +namespace StreamShorts.Console.Hosting; + +/// +/// Provides type resolution services using the dependency injection container. +/// +/// +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(); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Program.cs b/src/StreamShorts.Console/Program.cs index 82d8e79..3da4dab 100644 --- a/src/StreamShorts.Console/Program.cs +++ b/src/StreamShorts.Console/Program.cs @@ -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(); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + const string modelOptionName = "Model"; + const string keyOptionName = "ApiKey"; + var config = sp.GetRequiredService(); + 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(); + + return new GeminiAnalyzer(clientFactory, key, model); + }); + services.AddSingleton(); + }) + .BuildApp() + .RunAsync(args); + + Log.Information("{AppName} has completed successfully.", appName); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occurred during execution."); + throw; +} +finally +{ + await Log.CloseAndFlushAsync(); +} \ No newline at end of file diff --git a/src/StreamShorts.Console/Resources/Resources.resx b/src/StreamShorts.Console/Resources/Resources.resx deleted file mode 100644 index 5a85d0b..0000000 --- a/src/StreamShorts.Console/Resources/Resources.resx +++ /dev/null @@ -1,6 +0,0 @@ - - - - Welcome to StreamShorts! - - \ No newline at end of file diff --git a/src/StreamShorts.Console/StreamShorts.Console.csproj b/src/StreamShorts.Console/StreamShorts.Console.csproj index 6a70a1a..4b2629c 100644 --- a/src/StreamShorts.Console/StreamShorts.Console.csproj +++ b/src/StreamShorts.Console/StreamShorts.Console.csproj @@ -1,7 +1,37 @@  + StreamShorts.Console + StreamShorts.Console + A command-line interface for StreamShorts + 0.0.0 + Stevan Freeborn Exe + true + true + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + diff --git a/src/StreamShorts.Console/Usings.cs b/src/StreamShorts.Console/Usings.cs new file mode 100644 index 0000000..c076a35 --- /dev/null +++ b/src/StreamShorts.Console/Usings.cs @@ -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; \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/FailedTranscriptAnalysisException.cs b/src/StreamShorts.Library/Analysis/FailedTranscriptAnalysisException.cs new file mode 100644 index 0000000..eae15a9 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/FailedTranscriptAnalysisException.cs @@ -0,0 +1,31 @@ +namespace StreamShorts.Library.Analysis; + +/// +/// Represents an error that occurs during transcript analysis. +/// +public sealed class FailedTranscriptAnalysisException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public FailedTranscriptAnalysisException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// /// + /// The error message that explains the reason for the exception. + public FailedTranscriptAnalysisException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public FailedTranscriptAnalysisException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Gemini/Content.cs b/src/StreamShorts.Library/Analysis/Gemini/Content.cs new file mode 100644 index 0000000..3810ab1 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Gemini/Content.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +/// +/// Represents content with role and parts for Gemini API. +/// +/// The role of the content (e.g., "user", "assistant"). +/// The array of content parts. +internal record Content( + [property: JsonPropertyName("role")] + string Role, + [property: JsonPropertyName("parts")] + Part[] Parts +); + +/// +/// Represents a part of content for Gemini API. +/// +/// The text content of the part. +internal record Part( + [property: JsonPropertyName("text")] + string Text +); \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Gemini/GeminiAnalyzer.cs b/src/StreamShorts.Library/Analysis/Gemini/GeminiAnalyzer.cs new file mode 100644 index 0000000..1d7b8bc --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Gemini/GeminiAnalyzer.cs @@ -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; + +/// +/// Represents an analyzer that uses Gemini to analyze transcript segments and generate short clips. +/// +/// +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 AnalyzeAsync(IEnumerable 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(responseContent); + var candidatesText = responseJson? + .Candidates? + .FirstOrDefault()? + .Content + .Parts?.FirstOrDefault()? + .Text; + + var clips = JsonSerializer.Deserialize>(candidatesText ?? string.Empty); + return new TranscriptAnalysis(clips ?? []); + } + catch (Exception e) + { + throw new FailedTranscriptAnalysisException("Failed to analyze transcript segments using Gemini.", e); + } + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Gemini/GenerateContentRequest.cs b/src/StreamShorts.Library/Analysis/Gemini/GenerateContentRequest.cs new file mode 100644 index 0000000..e6a2989 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Gemini/GenerateContentRequest.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace StreamShorts.Library.Analysis.Gemini; + +/// +/// Represents a request to generate content using Gemini. +/// +/// The content array for the request. +/// The generation configuration. +internal record GenerateContentRequest( + [property: JsonPropertyName("contents")] + Content[] Contents, + [property: JsonPropertyName("generationConfig")] + GenerationConfig GenerationConfig +); + +/// +/// Represents configuration for content generation. +/// +/// The MIME type for the response. +internal record GenerationConfig( + [property: JsonPropertyName("responseMimeType")] + string ResponseMimeType +); \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Gemini/GenerateContentResponse.cs b/src/StreamShorts.Library/Analysis/Gemini/GenerateContentResponse.cs new file mode 100644 index 0000000..7a87edd --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Gemini/GenerateContentResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace StreamShorts.Library.Analysis.Gemini; + +/// +/// Represents a response from the Gemini content generation API. +/// +/// The array of candidate responses. +internal record GenerateContentResponse( + [property: JsonPropertyName("candidates")] + Candidate[] Candidates +); + +/// +/// Represents a candidate response from content generation. +/// +/// The generated content. +internal record Candidate( + [property: JsonPropertyName("content")] + Content Content +); \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/ITranscriptAnalyzer.cs b/src/StreamShorts.Library/Analysis/ITranscriptAnalyzer.cs new file mode 100644 index 0000000..9713ee2 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/ITranscriptAnalyzer.cs @@ -0,0 +1,17 @@ +using StreamShorts.Library.Transcription; + +namespace StreamShorts.Library.Analysis; + +/// +/// Defines the contract for transcript analyzers that process segments of a transcript and produce an analysis result containing short clips. +/// +public interface ITranscriptAnalyzer +{ + /// + /// Analyzes the provided transcript segments and generates a transcript analysis result. + /// + /// The transcript segments to analyze. + /// A task that represents the asynchronous operation. The task result contains the containing the short clips derived from the transcript. + /// Thrown when the analysis fails due to an error. + Task AnalyzeAsync(IEnumerable segments); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Prompts/DefaultAnalysisPrompt.cs b/src/StreamShorts.Library/Analysis/Prompts/DefaultAnalysisPrompt.cs new file mode 100644 index 0000000..3d6190b --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Prompts/DefaultAnalysisPrompt.cs @@ -0,0 +1,50 @@ +using System.Globalization; +using System.Text; + +using StreamShorts.Library.Transcription; + +namespace StreamShorts.Library.Analysis.Prompts; + +/// +/// Default implementation of the analysis prompt for generating YouTube Shorts. +/// +/// +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 segments) + { + var transcript = string.Join(Environment.NewLine, segments); + return string.Format(CultureInfo.InvariantCulture, Prompt, transcript); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/Prompts/IAnalysisPrompt.cs b/src/StreamShorts.Library/Analysis/Prompts/IAnalysisPrompt.cs new file mode 100644 index 0000000..46fdcf1 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/Prompts/IAnalysisPrompt.cs @@ -0,0 +1,16 @@ +using StreamShorts.Library.Transcription; + +namespace StreamShorts.Library.Analysis.Prompts; + +/// +/// Defines the contract for analysis prompts used in transcript analysis. +/// +public interface IAnalysisPrompt +{ + /// + /// Generates a prompt based on the provided transcript segments. + /// + /// The transcript segments to analyze. + /// A formatted prompt string for analysis. + string GetPrompt(IEnumerable transcript); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/ShortCandidate.cs b/src/StreamShorts.Library/Analysis/ShortCandidate.cs new file mode 100644 index 0000000..2828185 --- /dev/null +++ b/src/StreamShorts.Library/Analysis/ShortCandidate.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace StreamShorts.Library.Analysis; + +/// +/// Represents a short candidate derived from a transcript. +/// +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 +); \ No newline at end of file diff --git a/src/StreamShorts.Library/Analysis/TranscriptAnalysis.cs b/src/StreamShorts.Library/Analysis/TranscriptAnalysis.cs new file mode 100644 index 0000000..5900bdd --- /dev/null +++ b/src/StreamShorts.Library/Analysis/TranscriptAnalysis.cs @@ -0,0 +1,12 @@ +namespace StreamShorts.Library.Analysis; + +/// +/// Represents the analysis of a transcript, containing short clips derived from the transcript. +/// +public sealed class TranscriptAnalysis(IEnumerable candidates) +{ + /// + /// Gets the short clips derived from the transcript. + /// + public IEnumerable Candidates { get; init; } = candidates; +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Class1.cs b/src/StreamShorts.Library/Class1.cs deleted file mode 100644 index f6e19c1..0000000 --- a/src/StreamShorts.Library/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StreamShorts.Library; - -public class Class1 -{ - -} diff --git a/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs b/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs new file mode 100644 index 0000000..6e0a3f0 --- /dev/null +++ b/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs @@ -0,0 +1,74 @@ +namespace StreamShorts.Library.Media.Audio; + +/// +/// Extracts audio from video files. +/// +/// +public sealed class AudioExtractor : IAudioExtractor +{ + private readonly IVideoService _videoService = new FFMpegService(); + + /// + /// Initializes a new instance of the class. + /// + public AudioExtractor() + { + } + + /// + /// Initializes a new instance of the class with a specified . + /// + /// The video service to use for audio extraction. + /// Thrown when the video service is null. + internal AudioExtractor(IVideoService videoService) + { + _videoService = videoService ?? throw new ArgumentNullException(nameof(videoService), $"{nameof(videoService)} cannot be null"); + } + + public async Task 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; + } + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/Audio/FailedAudioExtractionException.cs b/src/StreamShorts.Library/Media/Audio/FailedAudioExtractionException.cs new file mode 100644 index 0000000..cd302bd --- /dev/null +++ b/src/StreamShorts.Library/Media/Audio/FailedAudioExtractionException.cs @@ -0,0 +1,31 @@ +namespace StreamShorts.Library.Media.Audio; + +/// +/// Represents an error that occurs when audio extraction fails. +/// +public sealed class FailedAudioExtractionException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public FailedAudioExtractionException() : base() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public FailedAudioExtractionException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public FailedAudioExtractionException(string message, Exception innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs b/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs new file mode 100644 index 0000000..9996292 --- /dev/null +++ b/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs @@ -0,0 +1,19 @@ +namespace StreamShorts.Library.Media.Audio; + +/// +/// Represents an interface for extracting audio from video streams. +/// +public interface IAudioExtractor +{ + /// + /// Extracts MP3 audio from an MP4 video stream. + /// + /// The input video stream. + /// A stream containing the extracted MP3 audio. + /// Thrown when the video stream is null. + /// Thrown when the video stream is not readable. + /// Thrown when the video stream is not seekable. + /// Thrown when the audio extraction fails. + /// The method will preserve the passed video stream's data and position. + Task ExtractMp3FromMp4Async(Stream video); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/FFMpegService.cs b/src/StreamShorts.Library/Media/FFMpegService.cs new file mode 100644 index 0000000..8ab00fd --- /dev/null +++ b/src/StreamShorts.Library/Media/FFMpegService.cs @@ -0,0 +1,39 @@ +using FFMpegCore; +using FFMpegCore.Enums; +using FFMpegCore.Pipes; + +namespace StreamShorts.Library.Media; + +/// +/// Represents a service for processing video files using FFMpeg. +/// +/// +internal sealed class FFMpegService : IVideoService +{ + public async Task 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); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/IAudioService.cs b/src/StreamShorts.Library/Media/IAudioService.cs new file mode 100644 index 0000000..a41d427 --- /dev/null +++ b/src/StreamShorts.Library/Media/IAudioService.cs @@ -0,0 +1,40 @@ +namespace StreamShorts.Library.Media; + +/// +/// Represents a service for processing audio files. +/// +internal interface IAudioService +{ + /// + /// Converts an MP3 stream to a WAV stream with a 16 kHz sample rate. + /// + /// The input MP3 stream. + /// The output WAV stream. + /// Thrown when the MP3 stream is null. + /// Thrown when the MP3 stream is not readable or seekable. + /// The method will preserve the passed MP3 stream's data and position. + /// Gets the number of segments in a WAV stream based on the specified segment duration. + /// + /// The input WAV stream. + /// The duration of each segment. + /// The number of segments. + /// Thrown when the WAV stream is null. + /// Thrown when the WAV stream is not readable or seekable. + /// The method will preserve the passed WAV stream's data and position. + int GetNumberOfWavSegments(Stream wavStream, TimeSpan segmentDuration); + + /// + /// Gets a segment of a WAV stream based on the specified segment number and duration. + /// + /// The input WAV stream. + /// The segment number to retrieve. + /// The duration of each segment. + /// The segment stream. + /// Thrown when the WAV stream is null. + /// Thrown when the WAV stream is not readable or seekable. + /// The method will preserve the passed WAV stream's data and position. + Stream GetWavSegment(Stream wavStream, int segmentNumber, TimeSpan segmentDuration); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/IVideoService.cs b/src/StreamShorts.Library/Media/IVideoService.cs new file mode 100644 index 0000000..2c5bef1 --- /dev/null +++ b/src/StreamShorts.Library/Media/IVideoService.cs @@ -0,0 +1,32 @@ +namespace StreamShorts.Library.Media; + +/// +/// Represents a service for processing video files. +/// +internal interface IVideoService +{ + /// + /// Extracts audio from a video stream and writes it to an audio stream. + /// + /// The input video stream. + /// The output audio stream. + /// A task that represents the asynchronous operation. The task result indicates whether the extraction was successful. + Task ExtractAudioFromVideoAsync(Stream video, Stream audio); + + /// + /// Creates a clip from a video file based on the specified start and end times. + /// + /// The path to the source video file. + /// The path where the created clip will be saved. + /// The start time of the clip. + /// The end time of the clip. + /// An optional buffer time to include before and after the clip segment. + /// A task that represents the asynchronous operation. + Task CreateClipFromVideoAsync( + string sourcePath, + string destinationPath, + TimeSpan startTime, + TimeSpan endTime, + TimeSpan? buffer = null + ); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/NAudioService.cs b/src/StreamShorts.Library/Media/NAudioService.cs new file mode 100644 index 0000000..89aa733 --- /dev/null +++ b/src/StreamShorts.Library/Media/NAudioService.cs @@ -0,0 +1,87 @@ + +using NAudio.Wave; + +namespace StreamShorts.Library.Media; + +/// +/// Represents a service for processing audio files using NAudio. +/// +/// +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(Stream stream, Func 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)); + } + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/Video/IShortsCreator.cs b/src/StreamShorts.Library/Media/Video/IShortsCreator.cs new file mode 100644 index 0000000..011f80a --- /dev/null +++ b/src/StreamShorts.Library/Media/Video/IShortsCreator.cs @@ -0,0 +1,21 @@ +using StreamShorts.Library.Analysis; + +namespace StreamShorts.Library.Media.Video; + +/// +/// Represents a service that creates video shorts. +/// +public interface IShortsCreator +{ + /// + /// Creates a video short from the specified source video file based on the provided candidate details. + /// + /// The path to the source video file. + /// The details of the short candidate. + /// The path where the created short will be saved. + /// An optional buffer time to include before and after the short segment. + /// A task that represents the asynchronous operation. + /// Thrown when or is null or whitespace. + /// Thrown when is null. + public Task CreateShortAsync(string sourcePath, ShortCandidate candidate, string destinationPath, TimeSpan? buffer = null); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/Video/ShortsCreator.cs b/src/StreamShorts.Library/Media/Video/ShortsCreator.cs new file mode 100644 index 0000000..f314f0f --- /dev/null +++ b/src/StreamShorts.Library/Media/Video/ShortsCreator.cs @@ -0,0 +1,50 @@ +using StreamShorts.Library.Analysis; + +namespace StreamShorts.Library.Media.Video; + +/// +/// Represents a service that creates video shorts. +/// +/// +public sealed class ShortsCreator : IShortsCreator +{ + private readonly IVideoService _videoService = new FFMpegService(); + + /// + /// Initializes a new instance of the class. + /// + public ShortsCreator() + { + } + + /// + /// Initializes a new instance of the class with a specified . + /// + /// The video service. + /// Thrown when the video service is null. + 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); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/StreamShorts.Library.csproj b/src/StreamShorts.Library/StreamShorts.Library.csproj index c632161..0f0e35d 100644 --- a/src/StreamShorts.Library/StreamShorts.Library.csproj +++ b/src/StreamShorts.Library/StreamShorts.Library.csproj @@ -1,3 +1,8 @@  - + + + + + + diff --git a/src/StreamShorts.Library/Transcription/ITranscriber.cs b/src/StreamShorts.Library/Transcription/ITranscriber.cs new file mode 100644 index 0000000..7c4df20 --- /dev/null +++ b/src/StreamShorts.Library/Transcription/ITranscriber.cs @@ -0,0 +1,15 @@ +namespace StreamShorts.Library.Transcription; + +/// +/// Represents a transcriber interface for audio transcription. +/// +public interface ITranscriber +{ + /// + /// Transcribes the audio stream into text segments. + /// + /// The audio stream to transcribe. + /// A cancellation token to cancel the operation. + /// An where T is . + IAsyncEnumerable TranscribeAsync(Stream audio, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Transcription/TranscriptionSegment.cs b/src/StreamShorts.Library/Transcription/TranscriptionSegment.cs new file mode 100644 index 0000000..cce9d32 --- /dev/null +++ b/src/StreamShorts.Library/Transcription/TranscriptionSegment.cs @@ -0,0 +1,19 @@ +namespace StreamShorts.Library.Transcription; + +/// +/// Represents a segment of transcribed audio. +/// +/// The start time of the segment. +/// The end time of the segment. +/// The transcribed text of the segment. +public sealed record TranscriptionSegment( + TimeSpan StartTime, + TimeSpan EndTime, + string Text +) +{ + public override string ToString() + { + return $"[{StartTime:hh\\:mm\\:ss} - {EndTime:hh\\:mm\\:ss}]: {Text}"; + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Transcription/WhisperTranscriber.cs b/src/StreamShorts.Library/Transcription/WhisperTranscriber.cs new file mode 100644 index 0000000..577f0af --- /dev/null +++ b/src/StreamShorts.Library/Transcription/WhisperTranscriber.cs @@ -0,0 +1,85 @@ +using System.Runtime.CompilerServices; + +using StreamShorts.Library.Media; + +using Whisper.net; +using Whisper.net.Ggml; + +namespace StreamShorts.Library.Transcription; + +/// +/// Represents a transcriber that uses Whisper for audio transcription. +/// +/// +public sealed class WhisperTranscriber : ITranscriber, IDisposable +{ + private readonly IAudioService _audioService = new NAudioService(); + private WhisperProcessor? _whisperProcessor; + + /// + /// Initializes a new instance of the class. + /// + public WhisperTranscriber() + { + } + + /// + /// Initializes a new instance of the class with a specified audio service. + /// + /// The audio service to use for audio processing. + /// Thrown when the audio service is null. 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 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; + } +} \ No newline at end of file diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..83d34a6 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] +dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA2201.severity = none diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props index 4192456..565f5b3 100644 --- a/tests/Directory.Packages.props +++ b/tests/Directory.Packages.props @@ -1,13 +1,14 @@ - - - true - - - - - - - - + + true + + + + + + + + + + \ No newline at end of file diff --git a/tests/StreamShorts.Console.Tests/StreamShorts.Console.Tests.csproj b/tests/StreamShorts.Console.Tests/StreamShorts.Console.Tests.csproj index e42006f..5bb53ea 100644 --- a/tests/StreamShorts.Console.Tests/StreamShorts.Console.Tests.csproj +++ b/tests/StreamShorts.Console.Tests/StreamShorts.Console.Tests.csproj @@ -1,11 +1,19 @@ + + + + + + + + diff --git a/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeRegistrarTests.cs b/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeRegistrarTests.cs new file mode 100644 index 0000000..30b61b7 --- /dev/null +++ b/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeRegistrarTests.cs @@ -0,0 +1,88 @@ +namespace StreamShorts.Console.Tests.Unit.Hosting; + +internal class TypeRegistrarTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldNotThrowShould() + { + var mockBuilder = new Mock(); + + var action = () => new TypeRegistrar(mockBuilder.Object); + + action.Should().NotThrow(); + } + + [Fact] + public void Build_WhenCalled_ItShouldReturnResolverAndBuildHost() + { + var mockHost = new Mock(); + var mockBuilder = new Mock(); + + mockBuilder + .Setup(static b => b.Build()) + .Returns(mockHost.Object); + + var registrar = new TypeRegistrar(mockBuilder.Object); + + var resolver = registrar.Build(); + + resolver.Should().BeOfType(); + mockBuilder.Verify(static b => b.Build(), Times.Once); + } + + [Fact] + public void Register_WhenCalledWithType_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + registrar.Register(typeof(IService), typeof(ServiceImplementation)); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.Should().NotBeNull(); + service.Should().BeOfType(); + } + + [Fact] + public void RegisterInstance_WhenCalledWithInstance_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + var instance = new ServiceImplementation(); + registrar.RegisterInstance(typeof(IService), instance); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.Should().BeSameAs(instance); + } + + [Fact] + public void RegisterLazy_WhenCalledWithFunc_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + registrar.RegisterLazy(typeof(IService), static () => new ServiceImplementation()); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.Should().NotBeNull(); + service.Should().BeOfType(); + } + + [Fact] + public void RegisterLazy_WhenFuncIsNull_ItShouldThrow() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + + var action = () => registrar.RegisterLazy(typeof(IService), null!); + + action.Should().Throw(); + } + + private interface IService { } + private sealed class ServiceImplementation : IService { } +} \ No newline at end of file diff --git a/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeResolverTests.cs b/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeResolverTests.cs new file mode 100644 index 0000000..0533c80 --- /dev/null +++ b/tests/StreamShorts.Console.Tests/Unit/Hosting/TypeResolverTests.cs @@ -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(); + } + + [Fact] + public void Resolve_WhenTypeIsNull_ItShouldReturnNull() + { + var mockHost = new Mock(); + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(null); + + result.Should().BeNull(); + } + + [Fact] + public void Resolve_WhenCalledWithRegisteredType_ItShouldReturnAnInstance() + { + var services = new ServiceCollection(); + services.AddSingleton(new TestService()); + + var mockHost = new Mock(); + + mockHost + .Setup(static h => h.Services) + .Returns(services.BuildServiceProvider()); + + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(typeof(TestService)); + + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Fact] + public void Resolve_WhenCalledWithUnregisteredType_ItShouldReturnNull() + { + var services = new ServiceCollection(); + var mockHost = new Mock(); + + mockHost + .Setup(static h => h.Services) + .Returns(services.BuildServiceProvider()); + + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(typeof(TestService)); + + result.Should().BeNull(); + } + + [Fact] + public void Dispose_WhenCalled_ItShouldAlsoDisposeHost() + { + var mockHost = new Mock(); + var resolver = new TypeResolver(mockHost.Object); + + resolver.Dispose(); + + mockHost.Verify(static h => h.Dispose(), Times.Once); + } + + private sealed class TestService { } +} \ No newline at end of file diff --git a/tests/StreamShorts.Console.Tests/UnitTest1.cs b/tests/StreamShorts.Console.Tests/UnitTest1.cs deleted file mode 100644 index 09cad2e..0000000 --- a/tests/StreamShorts.Console.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace StreamShorts.Console.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - Assert.True(true); - } -} diff --git a/tests/StreamShorts.Console.Tests/Usings.cs b/tests/StreamShorts.Console.Tests/Usings.cs new file mode 100644 index 0000000..a3ca487 --- /dev/null +++ b/tests/StreamShorts.Console.Tests/Usings.cs @@ -0,0 +1,8 @@ +global using AwesomeAssertions; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; + +global using Moq; + +global using StreamShorts.Console.Hosting; \ No newline at end of file diff --git a/tests/StreamShorts.Library.Tests/Data/Files/extracted_audio.mp3 b/tests/StreamShorts.Library.Tests/Data/Files/extracted_audio.mp3 new file mode 100644 index 0000000..5c75315 Binary files /dev/null and b/tests/StreamShorts.Library.Tests/Data/Files/extracted_audio.mp3 differ diff --git a/tests/StreamShorts.Library.Tests/Data/Files/video.mp4 b/tests/StreamShorts.Library.Tests/Data/Files/video.mp4 new file mode 100644 index 0000000..19acee5 Binary files /dev/null and b/tests/StreamShorts.Library.Tests/Data/Files/video.mp4 differ diff --git a/tests/StreamShorts.Library.Tests/Data/TestData.cs b/tests/StreamShorts.Library.Tests/Data/TestData.cs new file mode 100644 index 0000000..d9635f1 --- /dev/null +++ b/tests/StreamShorts.Library.Tests/Data/TestData.cs @@ -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); + } +} \ No newline at end of file diff --git a/tests/StreamShorts.Library.Tests/Integration/Media/Audio/AudioExtractorTests.cs b/tests/StreamShorts.Library.Tests/Integration/Media/Audio/AudioExtractorTests.cs new file mode 100644 index 0000000..ab5bbba --- /dev/null +++ b/tests/StreamShorts.Library.Tests/Integration/Media/Audio/AudioExtractorTests.cs @@ -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 ConvertStreamToBytesAsync(Stream stream) + { + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + return ms.ToArray(); + } +} \ No newline at end of file diff --git a/tests/StreamShorts.Library.Tests/StreamShorts.Library.Tests.csproj b/tests/StreamShorts.Library.Tests/StreamShorts.Library.Tests.csproj index 0dfcb36..88a9f48 100644 --- a/tests/StreamShorts.Library.Tests/StreamShorts.Library.Tests.csproj +++ b/tests/StreamShorts.Library.Tests/StreamShorts.Library.Tests.csproj @@ -1,13 +1,26 @@ + + + + + + + + + + PreserveNewest + + + diff --git a/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs b/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs new file mode 100644 index 0000000..366bdd8 --- /dev/null +++ b/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs @@ -0,0 +1,144 @@ +using System.Text; + +using StreamShorts.Library.Media; + +namespace StreamShorts.Library.Tests.Unit.Media.Audio; + +internal class AudioExtractorTests +{ + private readonly Mock _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(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenVideoIsNull_ItShouldThrow() + { + var action = async () => await _sut.ExtractMp3FromMp4Async(null!); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenVideoIsNotReadable_ItShouldThrow() + { + var mockStream = new Mock(); + mockStream.Setup(s => s.CanRead).Returns(false); + + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenVideoIsNotSeekable_ItShouldThrow() + { + var mockStream = new Mock(); + 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(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceThrows_ItShouldThrow() + { + var mockStream = new Mock(); + mockStream.Setup(static s => s.CanRead).Returns(true); + mockStream.Setup(static s => s.CanSeek).Returns(true); + + _mockFfmpegService. + Setup( + static m => m.ExtractAudioFromVideoAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ThrowsAsync(new Exception()); + + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceFailsExtraction_ItShouldThrow() + { + var mockStream = new Mock(); + mockStream.Setup(static s => s.CanRead).Returns(true); + mockStream.Setup(static s => s.CanSeek).Returns(true); + + _mockFfmpegService. + Setup( + static m => m.ExtractAudioFromVideoAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(false); + + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceSucceedsAtExtractingAudio_ItShouldReturnStream() + { + var mockStream = new Mock(); + mockStream.Setup(static s => s.CanRead).Returns(true); + mockStream.Setup(static s => s.CanSeek).Returns(true); + + _mockFfmpegService. + Setup( + static m => m.ExtractAudioFromVideoAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(true); + + var result = await _sut.ExtractMp3FromMp4Async(mockStream.Object); + + result.Should().BeAssignableTo(); + result.Should().BeOfType(); + } + + [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(), + It.IsAny() + ) + ) + .ReturnsAsync(true); + + await _sut.ExtractMp3FromMp4Async(stream); + + stream.Position.Should().Be(positionToRead); + } +} \ No newline at end of file diff --git a/tests/StreamShorts.Library.Tests/UnitTest1.cs b/tests/StreamShorts.Library.Tests/UnitTest1.cs deleted file mode 100644 index 7cf24e6..0000000 --- a/tests/StreamShorts.Library.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace StreamShorts.Library.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - Assert.True(true); - } -} diff --git a/tests/StreamShorts.Library.Tests/Usings.cs b/tests/StreamShorts.Library.Tests/Usings.cs new file mode 100644 index 0000000..ea34be8 --- /dev/null +++ b/tests/StreamShorts.Library.Tests/Usings.cs @@ -0,0 +1,6 @@ +global using AwesomeAssertions; + +global using Moq; + +global using StreamShorts.Library.Media.Audio; +global using StreamShorts.Library.Tests.Data; \ No newline at end of file