228 lines
10 KiB
C#
228 lines
10 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using YoutubeSummarizer.Configuration;
|
|
using YoutubeSummarizer.Models;
|
|
using YoutubeSummarizer.Services;
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// Bootstrap
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
// Build configuration from appsettings.json (required) with optional
|
|
// environment variable overrides (useful for CI or containerized deployment).
|
|
// Environment variables follow the pattern: YouTube__ApiKey, LLM__ApiKey, etc.
|
|
var config = new ConfigurationBuilder()
|
|
.SetBasePath(AppContext.BaseDirectory)
|
|
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
|
|
.AddEnvironmentVariables() // overrides appsettings values if set
|
|
.Build();
|
|
|
|
// Bind configuration sections to strongly-typed objects.
|
|
var appSettings = new AppSettings();
|
|
config.Bind(appSettings);
|
|
|
|
// Validate required keys up front — fail fast with a clear message rather
|
|
// than letting the first API call blow up with a cryptic 401.
|
|
ValidateSettings(appSettings);
|
|
|
|
// Wire up DI container.
|
|
// For a console app this is lightweight, but it mirrors the pattern used
|
|
// in the LIKA/IKA ASP.NET services so the code is easy to lift into a
|
|
// background service or API controller later.
|
|
var services = new ServiceCollection();
|
|
|
|
// Register HttpClient for the YouTube timedtext endpoint.
|
|
// Using IHttpClientFactory gives us connection pooling and the ability to
|
|
// attach Polly retry policies.
|
|
services.AddHttpClient<YouTubeService>(client =>
|
|
{
|
|
client.DefaultRequestHeaders.Add("User-Agent",
|
|
"Mozilla/5.0 (compatible; YoutubeSummarizer/1.0)");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
});
|
|
|
|
// Register services with their config dependencies.
|
|
services.AddSingleton(appSettings.LLM);
|
|
services.AddSingleton(appSettings.Summarizer);
|
|
services.AddTransient<SummarizerService>();
|
|
|
|
var serviceProvider = services.BuildServiceProvider();
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// Main loop
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
ConsoleRenderer.PrintBanner();
|
|
|
|
// Handle Ctrl+C gracefully so any in-progress API call can finish or cancel.
|
|
using var cts = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, e) =>
|
|
{
|
|
e.Cancel = true; // prevent immediate termination
|
|
cts.Cancel();
|
|
Console.WriteLine("\n Cancellation requested. Finishing current operation...");
|
|
};
|
|
|
|
while (!cts.Token.IsCancellationRequested)
|
|
{
|
|
var input = ConsoleRenderer.PromptForUrl();
|
|
|
|
if (string.IsNullOrWhiteSpace(input)) continue;
|
|
if (input.Equals("q", StringComparison.OrdinalIgnoreCase)) break;
|
|
|
|
// Parse the video ID from the URL
|
|
var videoId = YouTubeService.ExtractVideoId(input);
|
|
if (videoId is null)
|
|
{
|
|
ConsoleRenderer.PrintError("Could not extract a valid YouTube video ID from that URL.");
|
|
ConsoleRenderer.PrintWarning("Accepted formats: watch?v=..., youtu.be/..., /shorts/..., /embed/...");
|
|
continue;
|
|
}
|
|
|
|
// Ask whether to save transcript to file before processing
|
|
var saveTranscript = ConsoleRenderer.PromptSaveTranscript();
|
|
|
|
// Choose summary mode
|
|
var summaryMode = ConsoleRenderer.PromptSummaryMode();
|
|
|
|
await ProcessVideoAsync(videoId, serviceProvider, appSettings.Summarizer, saveTranscript, summaryMode, cts.Token);
|
|
}
|
|
|
|
Console.WriteLine(" Goodbye!");
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// Video processing pipeline
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Orchestrates the full pipeline for a single video:
|
|
/// 1. Fetch metadata (YouTube Data API)
|
|
/// 2. Fetch transcript (caption track or timedtext fallback)
|
|
/// 3. Summarize (LLM Chat Completions)
|
|
/// 4. Display (ConsoleRenderer)
|
|
/// </summary>
|
|
static async Task ProcessVideoAsync(
|
|
string videoId,
|
|
IServiceProvider sp,
|
|
SummarizerSettings summarizerSettings,
|
|
bool saveTranscript,
|
|
SummaryMode summaryMode,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
// Resolve scoped services
|
|
var youtubeService = sp.GetRequiredService<YouTubeService>();
|
|
var summarizerService = sp.GetRequiredService<SummarizerService>();
|
|
|
|
// ── Step 1: Metadata ──────────────────────────────────────────────
|
|
ConsoleRenderer.PrintWorking("Fetching video metadata");
|
|
var metadata = await youtubeService.GetVideoMetadataAsync(videoId, ct);
|
|
|
|
if (metadata is null)
|
|
{
|
|
ConsoleRenderer.PrintError($"Video not found or is private: {videoId}");
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine($" {metadata.Title}");
|
|
|
|
// ── Step 2: Transcript ────────────────────────────────────────────
|
|
ConsoleRenderer.PrintWorking("Fetching transcript");
|
|
var transcript = await youtubeService.GetTranscriptAsync(metadata, ct);
|
|
|
|
// Optionally show raw transcript for debugging / inspection
|
|
if (summarizerSettings.ShowTranscript)
|
|
{
|
|
Console.WriteLine();
|
|
Console.WriteLine(" ─── RAW TRANSCRIPT ───");
|
|
Console.WriteLine(transcript.Text);
|
|
Console.WriteLine(" ─── END TRANSCRIPT ───");
|
|
Console.WriteLine();
|
|
}
|
|
|
|
Console.WriteLine(
|
|
$" Transcript: {transcript.Source} | {transcript.WordCount:N0} words");
|
|
|
|
// ── Step 2.5: Save transcript to file (if requested) ─────────────
|
|
// (moved after summarization so we can include the summary)
|
|
|
|
// ── Step 3: Summarize ─────────────────────────────────────────────
|
|
// Always run the standard summary (used for file saving).
|
|
ConsoleRenderer.PrintWorking("Summarizing with LLM");
|
|
var standardSummary = await summarizerService.SummarizeAsync(
|
|
metadata, transcript, SummaryMode.Standard, ct);
|
|
|
|
// If the user chose Personal Filter, run a second pass for display.
|
|
VideoSummary displaySummary;
|
|
if (summaryMode == SummaryMode.PersonalFilter)
|
|
{
|
|
ConsoleRenderer.PrintWorking("Applying Personal Information Filter");
|
|
displaySummary = await summarizerService.SummarizeAsync(
|
|
metadata, transcript, SummaryMode.PersonalFilter, ct);
|
|
}
|
|
else
|
|
{
|
|
displaySummary = standardSummary;
|
|
}
|
|
|
|
// ── Step 3.5: Save transcript + standard summary to file ─────────
|
|
if (saveTranscript)
|
|
{
|
|
var transcriptsDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
"Downloads", "transcripts");
|
|
ConsoleRenderer.PrintWorking("Saving transcript to file");
|
|
var savedPath = await TranscriptFileService.SaveAsync(
|
|
metadata, transcript, summaryText: standardSummary.SummaryText,
|
|
outputDirectory: transcriptsDir, ct: ct);
|
|
ConsoleRenderer.PrintFileSaved(savedPath);
|
|
}
|
|
|
|
// ── Step 4: Display ───────────────────────────────────────────────
|
|
ConsoleRenderer.PrintSummary(displaySummary, showTranscriptSource: true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// User pressed Ctrl+C — nothing to report, the loop will exit
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ConsoleRenderer.PrintError(ex.Message);
|
|
|
|
// Print the stack trace in dim text for debugging without overwhelming
|
|
// normal users who will rarely see this path.
|
|
Console.WriteLine($"\x1b[2m{ex}\x1b[0m");
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// Configuration validation
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
static void ValidateSettings(AppSettings settings)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
if (string.IsNullOrWhiteSpace(settings.LLM.ApiKey) ||
|
|
settings.LLM.ApiKey == "YOUR_API_KEY_HERE")
|
|
{
|
|
// For local Ollama, we don't strictly need a real key, but it shouldn't be the placeholder.
|
|
// If they are using OpenAI, they definitely need a key.
|
|
if (settings.LLM.BaseUrl.Contains("openai.com", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
errors.Add("LLM:ApiKey is not set in appsettings.json (Required for OpenAI)");
|
|
}
|
|
}
|
|
|
|
if (errors.Count > 0)
|
|
{
|
|
Console.ForegroundColor = ConsoleColor.Red;
|
|
Console.WriteLine("\nConfiguration errors:");
|
|
errors.ForEach(e => Console.WriteLine($" ✗ {e}"));
|
|
Console.ResetColor();
|
|
Console.WriteLine("\nCopy appsettings.example.json → appsettings.json and fill in your keys.\n");
|
|
Environment.Exit(1);
|
|
}
|
|
}
|