Compare commits
2 commits
eac3249f84
...
e5b6ee1d94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b6ee1d94 | ||
|
|
7623ece180 |
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Spectre.Console;
|
||||||
using YoutubeSummarizer.Models;
|
using YoutubeSummarizer.Models;
|
||||||
|
|
||||||
namespace YoutubeSummarizer.Services;
|
namespace YoutubeSummarizer.Services;
|
||||||
|
|
@ -7,75 +8,88 @@ 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()
|
||||||
{
|
{
|
||||||
Console.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
Console.WriteLine($"{Bold}{Cyan}╔════════════════════════════════════════╗{Reset}");
|
AnsiConsole.Write(new FigletText("YT Summary").Centered().Color(Color.Red));
|
||||||
Console.WriteLine($"{Bold}{Cyan}║ YouTube Video Summarizer ║{Reset}");
|
AnsiConsole.Write(
|
||||||
Console.WriteLine($"{Bold}{Cyan}╚════════════════════════════════════════╝{Reset}");
|
new Rule("[grey]YouTube Video Summarizer[/]")
|
||||||
Console.WriteLine();
|
.RuleStyle("grey")
|
||||||
|
.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()
|
||||||
{
|
{
|
||||||
Console.Write($"{Bold}Enter YouTube URL (or 'q' to quit):{Reset} ");
|
if (!Interactive)
|
||||||
|
{
|
||||||
|
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.
|
||||||
/// Returns true if the user answers yes.
|
/// Defaults to <c>true</c> — pressing Enter accepts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool PromptSaveTranscript()
|
public static bool PromptSaveTranscript()
|
||||||
{
|
{
|
||||||
Console.Write($"{Bold}Save transcript to file? (y/n):{Reset} ");
|
if (!Interactive) return true;
|
||||||
var answer = Console.ReadLine()?.Trim() ?? string.Empty;
|
return AnsiConsole.Confirm("[bold]Save transcript to file?[/]", defaultValue: true);
|
||||||
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)
|
||||||
{
|
{
|
||||||
Console.WriteLine($" {Green}✓ Transcript saved to:{Reset} {filePath}");
|
AnsiConsole.MarkupLine($"[green]✓[/] Transcript saved to: [blue]{Markup.Escape(filePath)}[/]");
|
||||||
Console.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Prompts the user to choose a summary mode.
|
/// Prompts the user to choose a summary mode via an arrow-key selection list.
|
||||||
/// Returns the selected <see cref="SummaryMode"/>.
|
/// <see cref="SummaryMode.Standard"/> is listed first so it is the highlighted
|
||||||
|
/// default — pressing Enter accepts it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static SummaryMode PromptSummaryMode()
|
public static SummaryMode PromptSummaryMode()
|
||||||
{
|
{
|
||||||
Console.WriteLine($" {Dim}Summary modes:{Reset}");
|
if (!Interactive) return SummaryMode.Standard;
|
||||||
Console.WriteLine($" {Bold}1{Reset} – Standard (detailed bullet-point summary)");
|
return AnsiConsole.Prompt(
|
||||||
Console.WriteLine($" {Bold}2{Reset} – Personal Filter (relevance verdict: ACT / MONITOR / IGNORE)");
|
new SelectionPrompt<SummaryMode>()
|
||||||
Console.Write($"{Bold}Choose summary mode [1]:{Reset} ");
|
.Title("[bold]Choose summary mode:[/]")
|
||||||
var choice = Console.ReadLine()?.Trim() ?? string.Empty;
|
.AddChoices(SummaryMode.Standard, SummaryMode.PersonalFilter)
|
||||||
return choice == "2" ? SummaryMode.PersonalFilter : SummaryMode.Standard;
|
.UseConverter(m => m switch
|
||||||
|
{
|
||||||
|
SummaryMode.Standard => "Standard – detailed bullet-point summary",
|
||||||
|
SummaryMode.PersonalFilter => "Personal Filter – relevance verdict (ACT / MONITOR / IGNORE)",
|
||||||
|
_ => m.ToString()
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Displays a spinner-style "working" indicator while async work runs.</summary>
|
/// <summary>Prints a dim "working" indicator before an async step.</summary>
|
||||||
public static void PrintWorking(string message)
|
public static void PrintWorking(string message)
|
||||||
{
|
{
|
||||||
Console.WriteLine($" {Dim}→ {message}...{Reset}");
|
AnsiConsole.MarkupLine($"[grey]→ {Markup.Escape(message)}...[/]");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -85,113 +99,74 @@ public static class ConsoleRenderer
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void PrintSummary(VideoSummary summary, bool showTranscriptSource)
|
public static void PrintSummary(VideoSummary summary, bool showTranscriptSource)
|
||||||
{
|
{
|
||||||
Console.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
PrintDivider();
|
|
||||||
|
|
||||||
// ── Metadata header ──────────────────────────────────────────────────
|
// ── Metadata header ──────────────────────────────────────────────────
|
||||||
Console.WriteLine($"{Bold}{Green} {summary.Metadata.Title}{Reset}");
|
var grid = new Grid();
|
||||||
Console.WriteLine($" {Dim}Channel:{Reset} {summary.Metadata.ChannelTitle}");
|
grid.AddColumn(new GridColumn().PadRight(2));
|
||||||
Console.WriteLine($" {Dim}Published:{Reset} {summary.Metadata.PublishedAt:MMMM d, yyyy}");
|
grid.AddColumn();
|
||||||
Console.WriteLine($" {Dim}Duration:{Reset} {summary.Metadata.FormattedDuration}");
|
|
||||||
Console.WriteLine($" {Dim}URL:{Reset} https://youtu.be/{summary.Metadata.VideoId}");
|
grid.AddRow("[bold green]Title[/]", $"[bold]{Markup.Escape(summary.Metadata.Title)}[/]");
|
||||||
|
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", Dim)
|
_ => ("? Unknown", "grey")
|
||||||
};
|
};
|
||||||
Console.WriteLine($" {Dim}Transcript:{Reset} {color}{badge}{Reset}");
|
grid.AddRow("[grey]Transcript[/]", $"[{color}]{Markup.Escape(badge)}[/]");
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($" {Dim}Model:{Reset} {summary.ModelUsed}");
|
grid.AddRow("[grey]Model[/]", Markup.Escape(summary.ModelUsed));
|
||||||
Console.WriteLine($" {Dim}Generated:{Reset} {summary.GeneratedAt:yyyy-MM-dd HH:mm} UTC");
|
grid.AddRow("[grey]Generated[/]", Markup.Escape(summary.GeneratedAt.ToString("yyyy-MM-dd HH:mm")) + " UTC");
|
||||||
|
|
||||||
PrintDivider();
|
AnsiConsole.Write(grid);
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
// ── Quality warning ──────────────────────────────────────────────────
|
// ── Quality warning ──────────────────────────────────────────────────
|
||||||
if (summary.QualityWarning is not null)
|
if (summary.QualityWarning is not null)
|
||||||
{
|
{
|
||||||
Console.WriteLine();
|
AnsiConsole.Write(
|
||||||
Console.WriteLine($" {Yellow}{summary.QualityWarning}{Reset}");
|
new Panel($"[yellow]{Markup.Escape(summary.QualityWarning)}[/]")
|
||||||
|
{
|
||||||
|
Header = new PanelHeader("Quality warning"),
|
||||||
|
Border = BoxBorder.Rounded,
|
||||||
|
BorderStyle = new Style(Color.Yellow)
|
||||||
|
});
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Summary body ─────────────────────────────────────────────────────
|
// ── Summary body ─────────────────────────────────────────────────────
|
||||||
Console.WriteLine();
|
AnsiConsole.Write(
|
||||||
Console.WriteLine($"{Bold} SUMMARY{Reset}");
|
new Panel(Markup.Escape(summary.SummaryText))
|
||||||
Console.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}");
|
Header = new PanelHeader("Summary"),
|
||||||
}
|
Border = BoxBorder.Rounded,
|
||||||
|
Padding = new Padding(2, 1, 2, 1)
|
||||||
|
}.Expand());
|
||||||
|
|
||||||
Console.WriteLine();
|
AnsiConsole.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)
|
||||||
{
|
{
|
||||||
Console.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
Console.WriteLine($" {Red}✗ Error: {message}{Reset}");
|
AnsiConsole.MarkupLine($"[red]✗ Error:[/] {Markup.Escape(message)}");
|
||||||
Console.WriteLine();
|
AnsiConsole.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)
|
||||||
{
|
{
|
||||||
Console.WriteLine($" {Yellow}⚠ {message}{Reset}");
|
AnsiConsole.MarkupLine($"[yellow]⚠ {Markup.Escape(message)}[/]");
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
Program.cs
37
Program.cs
|
|
@ -1,5 +1,6 @@
|
||||||
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;
|
||||||
|
|
@ -51,7 +52,6 @@ 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();
|
||||||
Console.WriteLine("\n Cancellation requested. Finishing current operation...");
|
AnsiConsole.MarkupLine("\n[yellow]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);
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine(" Goodbye!");
|
AnsiConsole.MarkupLine("[grey]Goodbye![/]");
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
// Video processing pipeline
|
// Video processing pipeline
|
||||||
|
|
@ -125,7 +125,7 @@ static async Task ProcessVideoAsync(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($" {metadata.Title}");
|
AnsiConsole.MarkupLine($" [bold]{Markup.Escape(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)
|
||||||
{
|
{
|
||||||
Console.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
Console.WriteLine(" ─── RAW TRANSCRIPT ───");
|
AnsiConsole.Write(new Rule("RAW TRANSCRIPT").RuleStyle("grey"));
|
||||||
Console.WriteLine(transcript.Text);
|
AnsiConsole.WriteLine(transcript.Text);
|
||||||
Console.WriteLine(" ─── END TRANSCRIPT ───");
|
AnsiConsole.Write(new Rule("END TRANSCRIPT").RuleStyle("grey"));
|
||||||
Console.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine(
|
AnsiConsole.MarkupLine(
|
||||||
$" Transcript: {transcript.Source} | {transcript.WordCount:N0} words");
|
$" [grey]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,9 +190,10 @@ static async Task ProcessVideoAsync(
|
||||||
{
|
{
|
||||||
ConsoleRenderer.PrintError(ex.Message);
|
ConsoleRenderer.PrintError(ex.Message);
|
||||||
|
|
||||||
// Print the stack trace in dim text for debugging without overwhelming
|
// Print the stack trace for debugging without overwhelming normal users
|
||||||
// normal users who will rarely see this path.
|
// who will rarely see this path.
|
||||||
Console.WriteLine($"\x1b[2m{ex}\x1b[0m");
|
AnsiConsole.WriteException(ex,
|
||||||
|
ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,11 +218,9 @@ static void ValidateSettings(AppSettings settings)
|
||||||
|
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
{
|
{
|
||||||
Console.ForegroundColor = ConsoleColor.Red;
|
AnsiConsole.MarkupLine("\n[red]Configuration errors:[/]");
|
||||||
Console.WriteLine("\nConfiguration errors:");
|
errors.ForEach(e => AnsiConsole.MarkupLine($" [red]✗[/] {Markup.Escape(e)}"));
|
||||||
errors.ForEach(e => Console.WriteLine($" ✗ {e}"));
|
AnsiConsole.MarkupLine("\n[grey]Copy appsettings.example.json → appsettings.json and fill in your keys.[/]\n");
|
||||||
Console.ResetColor();
|
|
||||||
Console.WriteLine("\nCopy appsettings.example.json → appsettings.json and fill in your keys.\n");
|
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -189,14 +190,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);
|
||||||
|
|
||||||
Console.WriteLine($"\n [Chunking] Transcript split into {chunks.Count} chunks for processing...");
|
AnsiConsole.MarkupLine($"[grey]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++)
|
||||||
{
|
{
|
||||||
Console.Write($" [Chunk {i + 1}/{chunks.Count}] Summarizing");
|
AnsiConsole.MarkupLine($"[grey][[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);
|
||||||
|
|
@ -204,7 +205,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
|
||||||
Console.Write(" [Combine] Merging chunk summaries into final summary");
|
AnsiConsole.MarkupLine("[grey][[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}"));
|
||||||
|
|
||||||
|
|
@ -242,6 +243,10 @@ 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);
|
||||||
|
|
||||||
|
|
@ -249,28 +254,19 @@ public sealed class SummarizerService
|
||||||
{
|
{
|
||||||
foreach (var part in update.ContentUpdate)
|
foreach (var part in update.ContentUpdate)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(part.Text))
|
if (string.IsNullOrEmpty(part.Text)) continue;
|
||||||
{
|
|
||||||
if (fullContent.Length == 0)
|
|
||||||
{
|
|
||||||
// First token received!
|
|
||||||
Console.Write(" (working)");
|
|
||||||
}
|
|
||||||
|
|
||||||
fullContent.Append(part.Text);
|
fullContent.Append(part.Text);
|
||||||
|
ctx.Status(
|
||||||
// Show progress: print a dot every ~50 characters of output
|
$"Generating… {fullContent.Length:N0} chars ({sw.Elapsed.TotalSeconds:F0}s)");
|
||||||
// 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();
|
||||||
Console.WriteLine($" Done! ({sw.Elapsed.TotalSeconds:F1}s)");
|
AnsiConsole.MarkupLine($"[green]✓[/] Done in {sw.Elapsed.TotalSeconds:F1}s");
|
||||||
}
|
}
|
||||||
|
|
||||||
return fullContent.ToString();
|
return fullContent.ToString();
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
|
|
||||||
<!-- 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>
|
||||||
Loading…
Reference in a new issue