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(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(); 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 // ═════════════════════════════════════════════════════════════════════════════ /// /// 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) /// static async Task ProcessVideoAsync( string videoId, IServiceProvider sp, SummarizerSettings summarizerSettings, bool saveTranscript, SummaryMode summaryMode, CancellationToken ct) { try { // Resolve scoped services var youtubeService = sp.GetRequiredService(); var summarizerService = sp.GetRequiredService(); // ── 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(); 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); } }