From 667c1c91a1b700df3540d0e12a478b6cfd4d68de Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Fri, 15 May 2026 21:18:55 -0300 Subject: [PATCH] fix: one app. --- .claude/settings.local.json | 3 +- VideoStudy.API/Services/AnalysisService.cs | 129 +++++++++++++++++- VideoStudy.App/AppShell.razor | 21 +++ VideoStudy.App/Program.cs | 48 +++++-- VideoStudy.App/VideoStudy.App.csproj | 12 +- .../Components/YouTubeProcessor.razor | 40 ++---- VideoStudy.UI/Pages/Home.razor | 47 +++++-- VideoStudy.UI/VideoStudy.UI.csproj | 6 +- 8 files changed, 230 insertions(+), 76 deletions(-) create mode 100644 VideoStudy.App/AppShell.razor diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cfbac33..f884f52 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(git add:*)", "Bash(git commit:*)", "Bash(xargs ls:*)", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e['Route']\\) for e in d.get\\('Endpoints',[]\\)[:20]]\")" + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e['Route']\\) for e in d.get\\('Endpoints',[]\\)[:20]]\")", + "Bash(python3:*)" ] } } diff --git a/VideoStudy.API/Services/AnalysisService.cs b/VideoStudy.API/Services/AnalysisService.cs index f686957..180f660 100644 --- a/VideoStudy.API/Services/AnalysisService.cs +++ b/VideoStudy.API/Services/AnalysisService.cs @@ -73,8 +73,9 @@ public class AnalysisService yield return new AnalysisEvent { ProgressPercentage = 15, Message = "Obtendo transcrição..." }; string? transcript = null; + string? transcriptReadable = null; try { - transcript = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir); + (transcript, transcriptReadable) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir); if (string.IsNullOrWhiteSpace(transcript)) errorMessage = "O vídeo não possui transcrição disponível."; } catch (Exception ex) { errorMessage = $"Erro na transcrição: {ex.Message}"; @@ -112,7 +113,7 @@ public class AnalysisService yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando documento PDF..." }; try { - var pdfBytes = GeneratePdf(docTitle!, summary!, request.VideoUrl, sections, category!); + var pdfBytes = GeneratePdf(docTitle!, summary!, request.VideoUrl, sections, category!, transcriptReadable); finalResult = new AnalysisResult { VideoTitle = videoInfo.Title, DocumentTitle = docTitle!, @@ -211,7 +212,7 @@ public class AnalysisService }; } - private async Task GetTranscriptViaYtDlpAsync(string url, string lang, string dir) + private async Task<(string flat, string readable)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir) { var ytdlp = GetYtDlpPath(); var cookies = GetCookiesArg(); @@ -228,10 +229,12 @@ public class AnalysisService using var p = Process.Start(psi)!; await p.WaitForExitAsync(); var file = Directory.GetFiles(dir, "*.vtt").FirstOrDefault(); - return file == null ? "" : ParseVttToText(await File.ReadAllTextAsync(file)); + if (file == null) return ("", ""); + var vtt = await File.ReadAllTextAsync(file); + return (ParseVttToFlat(vtt), ParseVttToReadable(vtt)); } - private string ParseVttToText(string vtt) + private string ParseVttToFlat(string vtt) { var lines = vtt.Split('\n') .Select(l => l.Trim()) @@ -239,6 +242,72 @@ public class AnalysisService return string.Join(" ", lines.Select(l => Regex.Replace(l, @"<[^>]*>", ""))).Replace(" ", " "); } + private string ParseVttToReadable(string vtt) + { + // Parse cues: timestamp line + text lines + var cues = new List<(TimeSpan time, string text)>(); + var lines = vtt.Split('\n').Select(l => l.Trim()).ToArray(); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (!line.Contains("-->")) continue; + + // Parse start time from "HH:MM:SS.mmm --> HH:MM:SS.mmm" + var timePart = line.Split("-->")[0].Trim().Replace(',', '.'); + if (!TimeSpan.TryParse(timePart, out var ts)) continue; + + // Collect text lines until blank + var textLines = new List(); + i++; + while (i < lines.Length && !string.IsNullOrEmpty(lines[i]) && !lines[i].Contains("-->")) + { + var t = Regex.Replace(lines[i], @"<[^>]*>", "").Trim(); + if (!string.IsNullOrEmpty(t)) textLines.Add(t); + i++; + } + i--; // step back, outer loop will increment + + if (textLines.Count > 0) + cues.Add((ts, string.Join(" ", textLines))); + } + + if (cues.Count == 0) return ParseVttToFlat(vtt); + + // Merge consecutive cues with same/similar text (VTT often duplicates lines) + var merged = new List<(TimeSpan time, string text)>(); + foreach (var cue in cues) + { + if (merged.Count > 0 && merged[^1].text == cue.text) continue; + merged.Add(cue); + } + + // Group into paragraphs every ~60 seconds + var sb = new System.Text.StringBuilder(); + TimeSpan? paraStart = null; + var paraWords = new List(); + + void FlushParagraph() + { + if (paraWords.Count == 0) return; + sb.AppendLine($"[{paraStart!.Value:hh\\:mm\\:ss}] {string.Join(" ", paraWords)}"); + sb.AppendLine(); + paraWords.Clear(); + paraStart = null; + } + + foreach (var (time, text) in merged) + { + if (paraStart == null) paraStart = time; + paraWords.Add(text); + if ((time - paraStart.Value).TotalSeconds >= 60) + FlushParagraph(); + } + FlushParagraph(); + + return sb.ToString().TrimEnd(); + } + private async Task<(List sections, string rawJson, string category, string docTitle, string summary)> GenerateTutorialContentAsync(string transcript, VideoInfo video, string inLang, string? outLang, string? userContext, CancellationToken ct) { @@ -322,11 +391,12 @@ Escreva tudo em {outName}."; return line?.Trim(); } - private byte[] GeneratePdf(string title, string summary, string url, List sections, string category) + private byte[] GeneratePdf(string title, string summary, string url, List sections, string category, string? transcriptReadable = null) { var color = category switch { "TUTORIAL" => Colors.Green.Medium, "LECTURE" => Colors.Orange.Medium, _ => Colors.Blue.Medium }; return Document.Create(container => { + // Main content page container.Page(page => { page.Margin(2, Unit.Centimetre); @@ -358,6 +428,53 @@ Escreva tudo em {outName}."; }); page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app — "); x.CurrentPageNumber(); }); }); + + // Transcript appendix page + if (!string.IsNullOrWhiteSpace(transcriptReadable)) + { + container.Page(page => + { + page.Margin(2, Unit.Centimetre); + page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Segoe UI")); + page.Header().Column(c => + { + c.Item().Row(r => + { + r.RelativeItem().Text("Apêndice — Transcrição").SemiBold().FontSize(18).FontColor(Colors.Grey.Darken2); + r.ConstantItem(80).AlignRight().Text(title).FontSize(8).FontColor(Colors.Grey.Medium).Italic(); + }); + c.Item().PaddingTop(5).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); + c.Item().PaddingTop(4).Text("Cada parágrafo representa aproximadamente 60 segundos. O timestamp indica o início do trecho.") + .FontSize(8).Italic().FontColor(Colors.Grey.Medium); + }); + page.Content().PaddingVertical(1, Unit.Centimetre).Column(col => + { + foreach (var paragraph in transcriptReadable.Split("\n\n", StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = paragraph.Trim(); + if (string.IsNullOrEmpty(trimmed)) continue; + + // Split timestamp from text: "[HH:MM:SS] rest of text" + var bracketEnd = trimmed.IndexOf(']'); + if (bracketEnd > 0 && trimmed.StartsWith('[')) + { + var timestamp = trimmed[..(bracketEnd + 1)]; + var text = trimmed[(bracketEnd + 1)..].Trim(); + col.Item().PaddingBottom(8).Column(p => + { + p.Item().Text(timestamp).Bold().FontSize(9).FontColor(Colors.Blue.Medium); + p.Item().PaddingTop(2).Text(text).LineHeight(1.5f); + }); + } + else + { + col.Item().PaddingBottom(8).Text(trimmed).LineHeight(1.5f); + } + } + }); + page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app — "); x.CurrentPageNumber(); }); + }); + } }).GeneratePdf(); } } diff --git a/VideoStudy.App/AppShell.razor b/VideoStudy.App/AppShell.razor new file mode 100644 index 0000000..0b745c3 --- /dev/null +++ b/VideoStudy.App/AppShell.razor @@ -0,0 +1,21 @@ +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using VideoStudy.UI + + + + + + + VideoStudy + + + + + + + + + + + diff --git a/VideoStudy.App/Program.cs b/VideoStudy.App/Program.cs index 77afb34..94e13e7 100644 --- a/VideoStudy.App/Program.cs +++ b/VideoStudy.App/Program.cs @@ -1,5 +1,7 @@ +using System.Threading; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using Photino.Blazor; +using Photino.NET; using VideoStudy.UI; namespace VideoStudy.App; @@ -9,26 +11,48 @@ class Program [STAThread] static void Main(string[] args) { - var builder = PhotinoBlazorAppBuilder.CreateDefault(args); + const string appUrl = "http://localhost:5002"; - builder.Services.AddVideoStudyUI(); + var serverReady = new ManualResetEventSlim(false); - builder.Services.AddScoped(sp => new HttpClient + // Start Blazor Server in background thread + var serverThread = new Thread(() => { - BaseAddress = new Uri("http://localhost:5000"), - Timeout = TimeSpan.FromMinutes(10) + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + builder.Services.AddVideoStudyUI(); + builder.Services.AddScoped(_ => new HttpClient + { + BaseAddress = new Uri("http://localhost:5000"), + Timeout = TimeSpan.FromMinutes(10) + }); + + var app = builder.Build(); + app.UseStaticFiles(); + app.UseAntiforgery(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies(typeof(Routes).Assembly); + + app.Lifetime.ApplicationStarted.Register(() => serverReady.Set()); + app.Run(appUrl); }); + serverThread.IsBackground = true; + serverThread.Start(); - builder.RootComponents.Add("#app"); + // Wait for Kestrel to be ready (max 15s) + serverReady.Wait(TimeSpan.FromSeconds(15)); - var app = builder.Build(); - - app.MainWindow + // Open native window pointing to local server + var window = new PhotinoWindow() .SetTitle("VideoStudy") .SetSize(1280, 800) .SetDevToolsEnabled(true) - .Center(); + .Center() + .Load(new Uri(appUrl)); - app.Run(); + window.WaitForClose(); } } diff --git a/VideoStudy.App/VideoStudy.App.csproj b/VideoStudy.App/VideoStudy.App.csproj index 4654c27..e9be83a 100644 --- a/VideoStudy.App/VideoStudy.App.csproj +++ b/VideoStudy.App/VideoStudy.App.csproj @@ -1,8 +1,8 @@ - + Exe - net8.0 + net9.0 enable enable VideoStudy.App @@ -10,13 +10,7 @@ - - - - - - PreserveNewest - + diff --git a/VideoStudy.UI/Components/YouTubeProcessor.razor b/VideoStudy.UI/Components/YouTubeProcessor.razor index 80b1de1..cb25da7 100644 --- a/VideoStudy.UI/Components/YouTubeProcessor.razor +++ b/VideoStudy.UI/Components/YouTubeProcessor.razor @@ -1,61 +1,37 @@ @using VideoStudy.Shared
-
+
+ - + @bind="VideoUrl" @bind:event="oninput" + @onchange="OnUrlChanged" + disabled="@IsProcessing" />
@if (VideoInfo != null) { -
+
@VideoInfo.Title
@VideoInfo.Author @VideoInfo.Duration.ToString(@"hh\:mm\:ss")
- -
- -
}
@code { [Parameter] public EventCallback OnVideoUrlChanged { get; set; } - [Parameter] public EventCallback OnStart { get; set; } [Parameter] public bool IsProcessing { get; set; } [Parameter] public VideoInfo? VideoInfo { get; set; } - [Inject] YouTubeService YouTubeService { get; set; } = default!; - private string VideoUrl { get; set; } = ""; - private async Task FetchInfo() + private async Task OnUrlChanged(ChangeEventArgs e) { - if (string.IsNullOrWhiteSpace(VideoUrl)) return; + VideoUrl = e.Value?.ToString() ?? ""; await OnVideoUrlChanged.InvokeAsync(VideoUrl); } - - private async Task StartProcessing() - { - await OnStart.InvokeAsync(); - } } diff --git a/VideoStudy.UI/Pages/Home.razor b/VideoStudy.UI/Pages/Home.razor index d0dd468..d052a4f 100644 --- a/VideoStudy.UI/Pages/Home.razor +++ b/VideoStudy.UI/Pages/Home.razor @@ -24,11 +24,9 @@
-
-
+
+ +
+ +
@if (isProcessing || currentStep > 0) @@ -117,19 +130,10 @@ StateHasChanged(); } - private async Task HandleVideoUrlChanged(string url) + private void HandleVideoUrlChanged(string url) { videoUrl = url; - try - { - AddLog($"Buscando informações: {url}"); - currentVideoInfo = await YouTubeService.GetVideoInfoAsync(url); - if (currentVideoInfo != null) AddLog($"Encontrado: {currentVideoInfo.Title}"); - } - catch (Exception ex) - { - AddLog($"Erro ao buscar vídeo: {ex.Message}"); - } + currentVideoInfo = null; } private async Task StartAnalysis() @@ -148,9 +152,22 @@ cts = new CancellationTokenSource(); var token = cts.Token; + // Buscar info do vídeo antes de iniciar currentStep = 1; + statusMessage = "Buscando informações do vídeo..."; + AddLog($"Verificando URL: {videoUrl}", 2); + try + { + currentVideoInfo = await YouTubeService.GetVideoInfoAsync(videoUrl); + AddLog($"Vídeo encontrado: {currentVideoInfo.Title}", 5); + } + catch (Exception ex) + { + throw new Exception($"Não foi possível obter informações do vídeo: {ex.Message}"); + } + statusMessage = "Iniciando análise..."; - AddLog("Enviando para o servidor...", 5); + AddLog("Enviando para o servidor...", 8); var request = new AnalysisRequest { diff --git a/VideoStudy.UI/VideoStudy.UI.csproj b/VideoStudy.UI/VideoStudy.UI.csproj index 899f855..063e6fa 100644 --- a/VideoStudy.UI/VideoStudy.UI.csproj +++ b/VideoStudy.UI/VideoStudy.UI.csproj @@ -1,7 +1,7 @@ - net8.0;net10.0 + net8.0;net9.0;net10.0 enable enable AnyCPU;x64 @@ -15,6 +15,10 @@ + + + +