using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; 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(); AnsiConsole.MarkupLine("\n[yellow]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; // Auto-detect: an existing local file wins over YouTube URL parsing — // a real path on disk is an unambiguous signal, whereas a bare 11-char // YouTube ID could theoretically collide with a short filename. var candidatePath = NormalizeCandidatePath(input); if (File.Exists(candidatePath)) { if (!DocumentService.IsSupportedFile(candidatePath)) { ConsoleRenderer.PrintError($"Unsupported file type: {Path.GetExtension(candidatePath)}"); ConsoleRenderer.PrintWarning("Supported: .txt, .md, .csv, .pdf, .docx, .odt, .xlsx"); continue; } var saveTranscript = ConsoleRenderer.PromptSaveTranscript(); var (summaryMode, customPrompt) = PromptModeAndCustomPrompt(); await ProcessDocumentAsync( candidatePath, serviceProvider, appSettings.Summarizer, saveTranscript, summaryMode, customPrompt, cts.Token); continue; } // Parse the video ID from the URL var videoId = YouTubeService.ExtractVideoId(input); if (videoId is null) { ConsoleRenderer.PrintError("Could not extract a YouTube video ID, and no such file exists."); ConsoleRenderer.PrintWarning( "Accepted: watch?v=..., youtu.be/..., /shorts/..., /embed/..., or a path to an existing file."); continue; } var saveVideoTranscript = ConsoleRenderer.PromptSaveTranscript(); var (videoSummaryMode, videoCustomPrompt) = PromptModeAndCustomPrompt(); await ProcessVideoAsync( videoId, serviceProvider, appSettings.Summarizer, saveVideoTranscript, videoSummaryMode, videoCustomPrompt, cts.Token); } AnsiConsole.MarkupLine("[grey]Goodbye![/]"); // ═════════════════════════════════════════════════════════════════════════════ // Video processing pipeline // ═════════════════════════════════════════════════════════════════════════════ /// /// Fetches a YouTube video's metadata + transcript, then hands off to the /// shared summarize/save/display pipeline. /// static async Task ProcessVideoAsync( string videoId, IServiceProvider sp, SummarizerSettings summarizerSettings, bool saveTranscript, SummaryMode summaryMode, string? customPrompt, CancellationToken ct) { try { var youtubeService = 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; } ConsoleRenderer.SetTitle($"Summarize {metadata.Title}"); AnsiConsole.MarkupLine($" [bold]{Markup.Escape(metadata.Title)}[/]"); // ── Step 2: Transcript ──────────────────────────────────────────── ConsoleRenderer.PrintWorking("Fetching transcript"); var transcript = await youtubeService.GetTranscriptAsync(metadata, ct); await RunPipelineAsync( metadata, transcript, sp, summarizerSettings, saveTranscript, summaryMode, customPrompt, ct); } catch (OperationCanceledException) { // User pressed Ctrl+C — nothing to report, the loop will exit } catch (Exception ex) { ConsoleRenderer.PrintError(ex.Message); // Print the stack trace for debugging without overwhelming normal users // who will rarely see this path. AnsiConsole.WriteException(ex, ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes); } } /// /// Reads a local document's text, wraps it as video-shaped metadata/transcript /// (see ), then hands off to the shared /// summarize/save/display pipeline. /// static async Task ProcessDocumentAsync( string path, IServiceProvider sp, SummarizerSettings summarizerSettings, bool saveTranscript, SummaryMode summaryMode, string? customPrompt, CancellationToken ct) { try { var metadata = DocumentService.BuildMetadata(path); ConsoleRenderer.SetTitle($"Summarize {metadata.Title}"); AnsiConsole.MarkupLine($" [bold]{Markup.Escape(metadata.Title)}[/]"); ConsoleRenderer.PrintWorking("Reading document"); var transcript = await DocumentService.ExtractTextAsync(path, ct); await RunPipelineAsync( metadata, transcript, sp, summarizerSettings, saveTranscript, summaryMode, customPrompt, ct); } catch (OperationCanceledException) { // User pressed Ctrl+C — nothing to report, the loop will exit } catch (Exception ex) { ConsoleRenderer.PrintError(ex.Message); AnsiConsole.WriteException(ex, ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes); } } /// /// Shared tail of the pipeline once metadata + transcript text are in hand, /// regardless of whether they came from YouTube or a local document: /// 1. Summarize (LLM Chat Completions) /// 2. Save transcript + summary to file (if requested) /// 3. Display (ConsoleRenderer) /// static async Task RunPipelineAsync( VideoMetadata metadata, VideoTranscript transcript, IServiceProvider sp, SummarizerSettings summarizerSettings, bool saveTranscript, SummaryMode summaryMode, string? customPrompt, CancellationToken ct) { var summarizerService = sp.GetRequiredService(); // Optionally show raw transcript for debugging / inspection if (summarizerSettings.ShowTranscript) { AnsiConsole.WriteLine(); AnsiConsole.Write(new Rule("RAW TRANSCRIPT").RuleStyle("grey")); AnsiConsole.WriteLine(transcript.Text); AnsiConsole.Write(new Rule("END TRANSCRIPT").RuleStyle("grey")); AnsiConsole.WriteLine(); } AnsiConsole.MarkupLine( $" [grey]Transcript:[/] {transcript.Source} | {transcript.WordCount:N0} words"); // Always run the standard summary (used for file saving). ConsoleRenderer.PrintWorking("Summarizing with LLM"); var standardSummary = await summarizerService.SummarizeAsync( metadata, transcript, mode: SummaryMode.Standard, ct: ct); // If the user chose a different mode, run a second pass for display. VideoSummary displaySummary; if (summaryMode != SummaryMode.Standard) { ConsoleRenderer.PrintWorking(summaryMode switch { SummaryMode.PersonalFilter => "Applying Personal Information Filter", SummaryMode.Custom => "Applying custom instructions", _ => "Summarizing" }); displaySummary = await summarizerService.SummarizeAsync( metadata, transcript, mode: summaryMode, customPrompt: customPrompt, ct: ct); } else { displaySummary = standardSummary; } // 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); } // Display ConsoleRenderer.PrintSummary(displaySummary, showTranscriptSource: true); } /// /// Prompts for summary mode, and — only when the user picks Custom — for the /// instructions themselves, re-prompting until non-empty. /// static (SummaryMode Mode, string? CustomPrompt) PromptModeAndCustomPrompt() { var mode = ConsoleRenderer.PromptSummaryMode(); if (mode != SummaryMode.Custom) return (mode, null); string? customPrompt; do { customPrompt = ConsoleRenderer.PromptCustomInstructions(); if (string.IsNullOrWhiteSpace(customPrompt)) ConsoleRenderer.PrintWarning("Custom instructions can't be empty — try again."); } while (string.IsNullOrWhiteSpace(customPrompt)); return (mode, customPrompt); } /// /// Strips quotes some terminals add around a drag-and-dropped path and /// expands a leading "~" to the user's home directory. /// static string NormalizeCandidatePath(string input) { var trimmed = input.Trim(); if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') trimmed = trimmed[1..^1]; if (trimmed == "~" || trimmed.StartsWith("~/")) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); trimmed = trimmed == "~" ? home : Path.Combine(home, trimmed[2..]); } return trimmed; } // ═════════════════════════════════════════════════════════════════════════════ // 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) { AnsiConsole.MarkupLine("\n[red]Configuration errors:[/]"); errors.ForEach(e => AnsiConsole.MarkupLine($" [red]✗[/] {Markup.Escape(e)}")); AnsiConsole.MarkupLine("\n[grey]Copy appsettings.example.json → appsettings.json and fill in your keys.[/]\n"); Environment.Exit(1); } }