summarizer/Program.cs

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);
}
}