Compare commits

..

No commits in common. "e5b6ee1d948531237ce4c0cec68d062fb25c323a" and "eac3249f8401b4ad48787b19d5b9191eb3281c6f" have entirely different histories.

4 changed files with 156 additions and 127 deletions

View file

@ -1,4 +1,3 @@
using Spectre.Console;
using YoutubeSummarizer.Models; using YoutubeSummarizer.Models;
namespace YoutubeSummarizer.Services; namespace YoutubeSummarizer.Services;
@ -8,88 +7,75 @@ namespace YoutubeSummarizer.Services;
/// Keeping display logic separate from business logic makes it easy to /// Keeping display logic separate from business logic makes it easy to
/// later add output modes (JSON, Markdown file, HTML report) without /// later add output modes (JSON, Markdown file, HTML report) without
/// touching the service layer. /// touching the service layer.
///
/// Rendering is delegated to Spectre.Console, which handles colour,
/// word-wrap to the current terminal width, and graceful degradation
/// when output is redirected to a file or a non-ANSI terminal.
/// </summary> /// </summary>
public static class ConsoleRenderer public static class ConsoleRenderer
{ {
// ANSI color codes. These render correctly in most Linux terminals.
// If you pipe output to a file, the escape codes will appear as-is —
// run with --no-color if that's a concern (not implemented here, left
// as an exercise).
private const string Reset = "\x1b[0m";
private const string Bold = "\x1b[1m";
private const string Cyan = "\x1b[36m";
private const string Yellow = "\x1b[33m";
private const string Green = "\x1b[32m";
private const string Red = "\x1b[31m";
private const string Dim = "\x1b[2m";
/// <summary>Prints the application banner on startup.</summary> /// <summary>Prints the application banner on startup.</summary>
public static void PrintBanner() public static void PrintBanner()
{ {
AnsiConsole.WriteLine(); Console.WriteLine();
AnsiConsole.Write(new FigletText("YT Summary").Centered().Color(Color.Red)); Console.WriteLine($"{Bold}{Cyan}╔════════════════════════════════════════╗{Reset}");
AnsiConsole.Write( Console.WriteLine($"{Bold}{Cyan}║ YouTube Video Summarizer ║{Reset}");
new Rule("[grey]YouTube Video Summarizer[/]") Console.WriteLine($"{Bold}{Cyan}╚════════════════════════════════════════╝{Reset}");
.RuleStyle("grey") Console.WriteLine();
.Centered());
AnsiConsole.WriteLine();
} }
/// <summary>
/// True when the terminal can drive Spectre's interactive prompts. When output
/// is redirected or piped we fall back to plain reads / default answers so the
/// app still works in scripts and CI.
/// </summary>
private static bool Interactive => AnsiConsole.Profile.Capabilities.Interactive;
/// <summary>Prompts the user for a URL and reads input.</summary> /// <summary>Prompts the user for a URL and reads input.</summary>
public static string PromptForUrl() public static string PromptForUrl()
{ {
if (!Interactive) Console.Write($"{Bold}Enter YouTube URL (or 'q' to quit):{Reset} ");
{
AnsiConsole.Markup("[bold]Enter YouTube URL[/] [grey](or 'q' to quit)[/]: ");
return Console.ReadLine()?.Trim() ?? string.Empty; return Console.ReadLine()?.Trim() ?? string.Empty;
} }
var input = AnsiConsole.Prompt(
new TextPrompt<string>("[bold]Enter YouTube URL[/] [grey](or 'q' to quit)[/]:")
.AllowEmpty());
return input.Trim();
}
/// <summary> /// <summary>
/// Asks the user whether they want to save the transcript to a text file. /// Asks the user whether they want to save the transcript to a text file.
/// Defaults to <c>true</c> — pressing Enter accepts. /// Returns true if the user answers yes.
/// </summary> /// </summary>
public static bool PromptSaveTranscript() public static bool PromptSaveTranscript()
{ {
if (!Interactive) return true; Console.Write($"{Bold}Save transcript to file? (y/n):{Reset} ");
return AnsiConsole.Confirm("[bold]Save transcript to file?[/]", defaultValue: true); var answer = Console.ReadLine()?.Trim() ?? string.Empty;
return answer.Equals("y", StringComparison.OrdinalIgnoreCase)
|| answer.Equals("yes", StringComparison.OrdinalIgnoreCase);
} }
/// <summary>Prints a success message with the saved file path.</summary> /// <summary>Prints a success message with the saved file path.</summary>
public static void PrintFileSaved(string filePath) public static void PrintFileSaved(string filePath)
{ {
AnsiConsole.MarkupLine($"[green]✓[/] Transcript saved to: [blue]{Markup.Escape(filePath)}[/]"); Console.WriteLine($" {Green}✓ Transcript saved to:{Reset} {filePath}");
AnsiConsole.WriteLine(); Console.WriteLine();
} }
/// <summary> /// <summary>
/// Prompts the user to choose a summary mode via an arrow-key selection list. /// Prompts the user to choose a summary mode.
/// <see cref="SummaryMode.Standard"/> is listed first so it is the highlighted /// Returns the selected <see cref="SummaryMode"/>.
/// default — pressing Enter accepts it.
/// </summary> /// </summary>
public static SummaryMode PromptSummaryMode() public static SummaryMode PromptSummaryMode()
{ {
if (!Interactive) return SummaryMode.Standard; Console.WriteLine($" {Dim}Summary modes:{Reset}");
return AnsiConsole.Prompt( Console.WriteLine($" {Bold}1{Reset} Standard (detailed bullet-point summary)");
new SelectionPrompt<SummaryMode>() Console.WriteLine($" {Bold}2{Reset} Personal Filter (relevance verdict: ACT / MONITOR / IGNORE)");
.Title("[bold]Choose summary mode:[/]") Console.Write($"{Bold}Choose summary mode [1]:{Reset} ");
.AddChoices(SummaryMode.Standard, SummaryMode.PersonalFilter) var choice = Console.ReadLine()?.Trim() ?? string.Empty;
.UseConverter(m => m switch return choice == "2" ? SummaryMode.PersonalFilter : SummaryMode.Standard;
{
SummaryMode.Standard => "Standard detailed bullet-point summary",
SummaryMode.PersonalFilter => "Personal Filter relevance verdict (ACT / MONITOR / IGNORE)",
_ => m.ToString()
}));
} }
/// <summary>Prints a dim "working" indicator before an async step.</summary> /// <summary>Displays a spinner-style "working" indicator while async work runs.</summary>
public static void PrintWorking(string message) public static void PrintWorking(string message)
{ {
AnsiConsole.MarkupLine($"[grey]→ {Markup.Escape(message)}...[/]"); Console.WriteLine($" {Dim}→ {message}...{Reset}");
} }
/// <summary> /// <summary>
@ -99,74 +85,113 @@ public static class ConsoleRenderer
/// </summary> /// </summary>
public static void PrintSummary(VideoSummary summary, bool showTranscriptSource) public static void PrintSummary(VideoSummary summary, bool showTranscriptSource)
{ {
AnsiConsole.WriteLine(); Console.WriteLine();
PrintDivider();
// ── Metadata header ────────────────────────────────────────────────── // ── Metadata header ──────────────────────────────────────────────────
var grid = new Grid(); Console.WriteLine($"{Bold}{Green} {summary.Metadata.Title}{Reset}");
grid.AddColumn(new GridColumn().PadRight(2)); Console.WriteLine($" {Dim}Channel:{Reset} {summary.Metadata.ChannelTitle}");
grid.AddColumn(); Console.WriteLine($" {Dim}Published:{Reset} {summary.Metadata.PublishedAt:MMMM d, yyyy}");
Console.WriteLine($" {Dim}Duration:{Reset} {summary.Metadata.FormattedDuration}");
grid.AddRow("[bold green]Title[/]", $"[bold]{Markup.Escape(summary.Metadata.Title)}[/]"); Console.WriteLine($" {Dim}URL:{Reset} https://youtu.be/{summary.Metadata.VideoId}");
grid.AddRow("[grey]Channel[/]", Markup.Escape(summary.Metadata.ChannelTitle));
grid.AddRow("[grey]Published[/]", Markup.Escape(summary.Metadata.PublishedAt.ToString("MMMM d, yyyy")));
grid.AddRow("[grey]Duration[/]", Markup.Escape(summary.Metadata.FormattedDuration));
grid.AddRow("[grey]URL[/]", $"https://youtu.be/{Markup.Escape(summary.Metadata.VideoId)}");
// ── Transcript source badge ──────────────────────────────────────────
if (showTranscriptSource) if (showTranscriptSource)
{ {
var (badge, color) = summary.TranscriptSource switch var (badge, color) = summary.TranscriptSource switch
{ {
TranscriptSource.OwnerPublished => ("✓ Owner-published captions", "green"), TranscriptSource.OwnerPublished => ("✓ Owner-published captions", Green),
TranscriptSource.CommunityContributed => ("✓ Community captions", "green"), TranscriptSource.CommunityContributed=> ("✓ Community captions", Green),
TranscriptSource.AutoGenerated => ("~ Auto-generated (ASR)", "yellow"), TranscriptSource.AutoGenerated => ("~ Auto-generated (ASR)", Yellow),
TranscriptSource.MetadataOnly => ("✗ Metadata only", "red"), TranscriptSource.MetadataOnly => ("✗ Metadata only", Red),
_ => ("? Unknown", "grey") _ => ("? Unknown", Dim)
}; };
grid.AddRow("[grey]Transcript[/]", $"[{color}]{Markup.Escape(badge)}[/]"); Console.WriteLine($" {Dim}Transcript:{Reset} {color}{badge}{Reset}");
} }
grid.AddRow("[grey]Model[/]", Markup.Escape(summary.ModelUsed)); Console.WriteLine($" {Dim}Model:{Reset} {summary.ModelUsed}");
grid.AddRow("[grey]Generated[/]", Markup.Escape(summary.GeneratedAt.ToString("yyyy-MM-dd HH:mm")) + " UTC"); Console.WriteLine($" {Dim}Generated:{Reset} {summary.GeneratedAt:yyyy-MM-dd HH:mm} UTC");
AnsiConsole.Write(grid); PrintDivider();
AnsiConsole.WriteLine();
// ── Quality warning ────────────────────────────────────────────────── // ── Quality warning ──────────────────────────────────────────────────
if (summary.QualityWarning is not null) if (summary.QualityWarning is not null)
{ {
AnsiConsole.Write( Console.WriteLine();
new Panel($"[yellow]{Markup.Escape(summary.QualityWarning)}[/]") Console.WriteLine($" {Yellow}{summary.QualityWarning}{Reset}");
{
Header = new PanelHeader("Quality warning"),
Border = BoxBorder.Rounded,
BorderStyle = new Style(Color.Yellow)
});
AnsiConsole.WriteLine();
} }
// ── Summary body ───────────────────────────────────────────────────── // ── Summary body ─────────────────────────────────────────────────────
AnsiConsole.Write( Console.WriteLine();
new Panel(Markup.Escape(summary.SummaryText)) Console.WriteLine($"{Bold} SUMMARY{Reset}");
{ Console.WriteLine();
Header = new PanelHeader("Summary"),
Border = BoxBorder.Rounded,
Padding = new Padding(2, 1, 2, 1)
}.Expand());
AnsiConsole.WriteLine(); // Word-wrap the summary body at 80 characters so it's readable in
// standard terminal widths without horizontal scrolling.
foreach (var line in WordWrap(summary.SummaryText, maxWidth: 78))
{
Console.WriteLine($" {line}");
}
Console.WriteLine();
PrintDivider();
Console.WriteLine();
} }
/// <summary>Prints a styled error message.</summary> /// <summary>Prints a styled error message.</summary>
public static void PrintError(string message) public static void PrintError(string message)
{ {
AnsiConsole.WriteLine(); Console.WriteLine();
AnsiConsole.MarkupLine($"[red]✗ Error:[/] {Markup.Escape(message)}"); Console.WriteLine($" {Red}✗ Error: {message}{Reset}");
AnsiConsole.WriteLine(); Console.WriteLine();
} }
/// <summary>Prints a styled warning (non-fatal).</summary> /// <summary>Prints a styled warning (non-fatal).</summary>
public static void PrintWarning(string message) public static void PrintWarning(string message)
{ {
AnsiConsole.MarkupLine($"[yellow]⚠ {Markup.Escape(message)}[/]"); Console.WriteLine($" {Yellow}⚠ {message}{Reset}");
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private static void PrintDivider()
{
Console.WriteLine($" {Dim}{"".PadRight(74, '─')}{Reset}");
}
/// <summary>
/// Splits text into lines no wider than <paramref name="maxWidth"/> characters,
/// breaking only at word boundaries. Respects existing newlines in the input.
/// </summary>
private static IEnumerable<string> WordWrap(string text, int maxWidth)
{
foreach (var paragraph in text.Split('\n'))
{
if (string.IsNullOrWhiteSpace(paragraph))
{
yield return string.Empty;
continue;
}
var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var current = new System.Text.StringBuilder();
foreach (var word in words)
{
if (current.Length + word.Length + 1 > maxWidth)
{
yield return current.ToString();
current.Clear();
}
if (current.Length > 0) current.Append(' ');
current.Append(word);
}
if (current.Length > 0)
yield return current.ToString();
}
} }
} }

View file

@ -1,6 +1,5 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
using YoutubeSummarizer.Configuration; using YoutubeSummarizer.Configuration;
using YoutubeSummarizer.Models; using YoutubeSummarizer.Models;
using YoutubeSummarizer.Services; using YoutubeSummarizer.Services;
@ -52,6 +51,7 @@ var serviceProvider = services.BuildServiceProvider();
// ═════════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════════
// Main loop // Main loop
// ═════════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════════
ConsoleRenderer.PrintBanner(); ConsoleRenderer.PrintBanner();
// Handle Ctrl+C gracefully so any in-progress API call can finish or cancel. // Handle Ctrl+C gracefully so any in-progress API call can finish or cancel.
@ -60,7 +60,7 @@ Console.CancelKeyPress += (_, e) =>
{ {
e.Cancel = true; // prevent immediate termination e.Cancel = true; // prevent immediate termination
cts.Cancel(); cts.Cancel();
AnsiConsole.MarkupLine("\n[yellow]Cancellation requested. Finishing current operation...[/]"); Console.WriteLine("\n Cancellation requested. Finishing current operation...");
}; };
while (!cts.Token.IsCancellationRequested) while (!cts.Token.IsCancellationRequested)
@ -88,7 +88,7 @@ while (!cts.Token.IsCancellationRequested)
await ProcessVideoAsync(videoId, serviceProvider, appSettings.Summarizer, saveTranscript, summaryMode, cts.Token); await ProcessVideoAsync(videoId, serviceProvider, appSettings.Summarizer, saveTranscript, summaryMode, cts.Token);
} }
AnsiConsole.MarkupLine("[grey]Goodbye![/]"); Console.WriteLine(" Goodbye!");
// ═════════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════════
// Video processing pipeline // Video processing pipeline
@ -125,7 +125,7 @@ static async Task ProcessVideoAsync(
return; return;
} }
AnsiConsole.MarkupLine($" [bold]{Markup.Escape(metadata.Title)}[/]"); Console.WriteLine($" {metadata.Title}");
// ── Step 2: Transcript ──────────────────────────────────────────── // ── Step 2: Transcript ────────────────────────────────────────────
ConsoleRenderer.PrintWorking("Fetching transcript"); ConsoleRenderer.PrintWorking("Fetching transcript");
@ -134,15 +134,15 @@ static async Task ProcessVideoAsync(
// Optionally show raw transcript for debugging / inspection // Optionally show raw transcript for debugging / inspection
if (summarizerSettings.ShowTranscript) if (summarizerSettings.ShowTranscript)
{ {
AnsiConsole.WriteLine(); Console.WriteLine();
AnsiConsole.Write(new Rule("RAW TRANSCRIPT").RuleStyle("grey")); Console.WriteLine(" ─── RAW TRANSCRIPT ───");
AnsiConsole.WriteLine(transcript.Text); Console.WriteLine(transcript.Text);
AnsiConsole.Write(new Rule("END TRANSCRIPT").RuleStyle("grey")); Console.WriteLine(" ─── END TRANSCRIPT ───");
AnsiConsole.WriteLine(); Console.WriteLine();
} }
AnsiConsole.MarkupLine( Console.WriteLine(
$" [grey]Transcript:[/] {transcript.Source} | {transcript.WordCount:N0} words"); $" Transcript: {transcript.Source} | {transcript.WordCount:N0} words");
// ── Step 2.5: Save transcript to file (if requested) ───────────── // ── Step 2.5: Save transcript to file (if requested) ─────────────
// (moved after summarization so we can include the summary) // (moved after summarization so we can include the summary)
@ -190,10 +190,9 @@ static async Task ProcessVideoAsync(
{ {
ConsoleRenderer.PrintError(ex.Message); ConsoleRenderer.PrintError(ex.Message);
// Print the stack trace for debugging without overwhelming normal users // Print the stack trace in dim text for debugging without overwhelming
// who will rarely see this path. // normal users who will rarely see this path.
AnsiConsole.WriteException(ex, Console.WriteLine($"\x1b[2m{ex}\x1b[0m");
ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes);
} }
} }
@ -218,9 +217,11 @@ static void ValidateSettings(AppSettings settings)
if (errors.Count > 0) if (errors.Count > 0)
{ {
AnsiConsole.MarkupLine("\n[red]Configuration errors:[/]"); Console.ForegroundColor = ConsoleColor.Red;
errors.ForEach(e => AnsiConsole.MarkupLine($" [red]✗[/] {Markup.Escape(e)}")); Console.WriteLine("\nConfiguration errors:");
AnsiConsole.MarkupLine("\n[grey]Copy appsettings.example.json → appsettings.json and fill in your keys.[/]\n"); 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); Environment.Exit(1);
} }
} }

View file

@ -1,6 +1,5 @@
using OpenAI; using OpenAI;
using OpenAI.Chat; using OpenAI.Chat;
using Spectre.Console;
using YoutubeSummarizer.Configuration; using YoutubeSummarizer.Configuration;
using YoutubeSummarizer.Models; using YoutubeSummarizer.Models;
@ -190,14 +189,14 @@ public sealed class SummarizerService
var words = transcriptText.Split(' ', StringSplitOptions.RemoveEmptyEntries); var words = transcriptText.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var chunks = SplitIntoChunks(words, _summarizerSettings.ChunkWordLimit, overlapWords: 200); var chunks = SplitIntoChunks(words, _summarizerSettings.ChunkWordLimit, overlapWords: 200);
AnsiConsole.MarkupLine($"[grey]Transcript split into {chunks.Count} chunks for processing…[/]"); Console.WriteLine($"\n [Chunking] Transcript split into {chunks.Count} chunks for processing...");
// Map phase: summarize each chunk in sequence // Map phase: summarize each chunk in sequence
// (Parallel would be faster but could hit rate limits — sequential is safer) // (Parallel would be faster but could hit rate limits — sequential is safer)
var chunkSummaries = new List<string>(chunks.Count); var chunkSummaries = new List<string>(chunks.Count);
for (int i = 0; i < chunks.Count; i++) for (int i = 0; i < chunks.Count; i++)
{ {
AnsiConsole.MarkupLine($"[grey][[Chunk {i + 1}/{chunks.Count}]] summarizing…[/]"); Console.Write($" [Chunk {i + 1}/{chunks.Count}] Summarizing");
var chunkText = string.Join(" ", chunks[i]); var chunkText = string.Join(" ", chunks[i]);
var prompt = $"This is segment {i + 1} of {chunks.Count} from the video \"{metadata.Title}\":\n\n{chunkText}"; var prompt = $"This is segment {i + 1} of {chunks.Count} from the video \"{metadata.Title}\":\n\n{chunkText}";
var summary = await CallChatCompletionAsync(chunkSystemPrompt, prompt, ct); var summary = await CallChatCompletionAsync(chunkSystemPrompt, prompt, ct);
@ -205,7 +204,7 @@ public sealed class SummarizerService
} }
// Reduce phase: combine all chunk summaries into one coherent summary // Reduce phase: combine all chunk summaries into one coherent summary
AnsiConsole.MarkupLine("[grey][[Combine]] merging chunk summaries into final summary…[/]"); Console.Write(" [Combine] Merging chunk summaries into final summary");
var combinedInput = string.Join("\n\n---\n\n", var combinedInput = string.Join("\n\n---\n\n",
chunkSummaries.Select((s, i) => $"Segment {i + 1} summary:\n{s}")); chunkSummaries.Select((s, i) => $"Segment {i + 1} summary:\n{s}"));
@ -243,10 +242,6 @@ public sealed class SummarizerService
var fullContent = new System.Text.StringBuilder(); var fullContent = new System.Text.StringBuilder();
try try
{
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Waiting for model…", async ctx =>
{ {
var streamingUpdates = _chatClient.CompleteChatStreamingAsync(messages, options, ct); var streamingUpdates = _chatClient.CompleteChatStreamingAsync(messages, options, ct);
@ -254,19 +249,28 @@ public sealed class SummarizerService
{ {
foreach (var part in update.ContentUpdate) foreach (var part in update.ContentUpdate)
{ {
if (string.IsNullOrEmpty(part.Text)) continue; if (!string.IsNullOrEmpty(part.Text))
{
if (fullContent.Length == 0)
{
// First token received!
Console.Write(" (working)");
}
fullContent.Append(part.Text); fullContent.Append(part.Text);
ctx.Status(
$"Generating… {fullContent.Length:N0} chars ({sw.Elapsed.TotalSeconds:F0}s)"); // Show progress: print a dot every ~50 characters of output
// or just periodically. For now, let's just do a dot every update
// to show it's alive.
if (fullContent.Length % 20 == 0) Console.Write(".");
}
} }
} }
});
} }
finally finally
{ {
sw.Stop(); sw.Stop();
AnsiConsole.MarkupLine($"[green]✓[/] Done in {sw.Elapsed.TotalSeconds:F1}s"); Console.WriteLine($" Done! ({sw.Elapsed.TotalSeconds:F1}s)");
} }
return fullContent.ToString(); return fullContent.ToString();

View file

@ -31,7 +31,6 @@
<!-- Polly for resilient HTTP retry logic --> <!-- Polly for resilient HTTP retry logic -->
<PackageReference Include="Polly" Version="8.4.1" /> <PackageReference Include="Polly" Version="8.4.1" />
<PackageReference Include="Spectre.Console" Version="0.57.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>