Summarize local PDF, Word, and plain text files via a new DocumentService (PdfPig + DocumentFormat.OpenXml), and add a Custom summary mode that uses the user's own instructions as the system prompt. Update the console UI, transcript saving, and README to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
352 lines
14 KiB
C#
352 lines
14 KiB
C#
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<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();
|
|
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
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
/// <summary>
|
|
/// Fetches a YouTube video's metadata + transcript, then hands off to the
|
|
/// shared summarize/save/display pipeline.
|
|
/// </summary>
|
|
static async Task ProcessVideoAsync(
|
|
string videoId,
|
|
IServiceProvider sp,
|
|
SummarizerSettings summarizerSettings,
|
|
bool saveTranscript,
|
|
SummaryMode summaryMode,
|
|
string? customPrompt,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var youtubeService = sp.GetRequiredService<YouTubeService>();
|
|
|
|
// ── 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a local document's text, wraps it as video-shaped metadata/transcript
|
|
/// (see <see cref="DocumentService"/>), then hands off to the shared
|
|
/// summarize/save/display pipeline.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
static async Task RunPipelineAsync(
|
|
VideoMetadata metadata,
|
|
VideoTranscript transcript,
|
|
IServiceProvider sp,
|
|
SummarizerSettings summarizerSettings,
|
|
bool saveTranscript,
|
|
SummaryMode summaryMode,
|
|
string? customPrompt,
|
|
CancellationToken ct)
|
|
{
|
|
var summarizerService = sp.GetRequiredService<SummarizerService>();
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prompts for summary mode, and — only when the user picks Custom — for the
|
|
/// instructions themselves, re-prompting until non-empty.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Strips quotes some terminals add around a drag-and-dropped path and
|
|
/// expands a leading "~" to the user's home directory.
|
|
/// </summary>
|
|
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<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)
|
|
{
|
|
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);
|
|
}
|
|
}
|