fix: one app.

This commit is contained in:
Ricardo Carneiro 2026-05-15 21:18:55 -03:00
parent ad5312ba10
commit 667c1c91a1
8 changed files with 230 additions and 76 deletions

View File

@ -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:*)"
]
}
}

View File

@ -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<string> 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<string>();
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<string>();
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<TutorialSection> 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<TutorialSection> sections, string category)
private byte[] GeneratePdf(string title, string summary, string url, List<TutorialSection> 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();
}
}

View File

@ -0,0 +1,21 @@
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using VideoStudy.UI
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoStudy</title>
<base href="/" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="_content/VideoStudy.UI/app.css" />
<HeadOutlet />
</head>
<body>
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@ -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<AppShell>()
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(Routes).Assembly);
app.Lifetime.ApplicationStarted.Register(() => serverReady.Set());
app.Run(appUrl);
});
serverThread.IsBackground = true;
serverThread.Start();
builder.RootComponents.Add<VideoStudy.UI.App>("#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();
}
}

View File

@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>VideoStudy.App</RootNamespace>
@ -10,13 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Photino.Blazor" Version="3.2.0" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<PackageReference Include="Photino.NET" Version="4.0.16" />
</ItemGroup>
<ItemGroup>

View File

@ -1,61 +1,37 @@
@using VideoStudy.Shared
<div class="processor-card p-2">
<div class="input-group mb-3">
<div class="mb-3">
<label class="form-label fw-bold text-muted small">URL do YouTube</label>
<input type="text" class="form-control form-control-lg"
placeholder="https://www.youtube.com/watch?v=..."
@bind="VideoUrl" disabled="@IsProcessing" />
<button type="button" class="btn btn-primary"
@onclick="FetchInfo"
disabled="@(IsProcessing || string.IsNullOrWhiteSpace(VideoUrl))">
Verificar
</button>
@bind="VideoUrl" @bind:event="oninput"
@onchange="OnUrlChanged"
disabled="@IsProcessing" />
</div>
@if (VideoInfo != null)
{
<div class="mb-4 p-3 bg-light rounded-3">
<div class="p-3 bg-light rounded-3">
<div class="fw-bold">@VideoInfo.Title</div>
<div class="d-flex gap-3 text-muted small mt-1">
<span>@VideoInfo.Author</span>
<span>@VideoInfo.Duration.ToString(@"hh\:mm\:ss")</span>
</div>
</div>
<div class="d-grid">
<button class="btn btn-lg btn-primary" @onclick="StartProcessing" disabled="@IsProcessing">
@if (IsProcessing)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Analisando...</span>
}
else
{
<span>Analisar com IA</span>
}
</button>
</div>
}
</div>
@code {
[Parameter] public EventCallback<string> 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();
}
}

View File

@ -24,11 +24,9 @@
<div class="card shadow-sm border-0 rounded-4 overflow-hidden mb-4 p-4 bg-white">
<YouTubeProcessor
OnVideoUrlChanged="HandleVideoUrlChanged"
OnStart="StartAnalysis"
IsProcessing="@isProcessing"
VideoInfo="@currentVideoInfo" />
<!-- Contexto do usuário -->
<div class="mt-3">
<label class="form-label fw-bold text-muted small">
Contexto <span class="fw-normal">(opcional)</span>
@ -38,7 +36,7 @@
@bind="userContext" disabled="@isProcessing"></textarea>
</div>
<div class="row g-3 mt-1">
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label fw-bold text-muted small">Idioma de saída</label>
<select class="form-select" @bind="selectedLanguage" disabled="@isProcessing">
@ -49,6 +47,21 @@
</select>
</div>
</div>
<div class="d-grid mt-4">
<button class="btn btn-lg btn-primary" @onclick="StartAnalysis"
disabled="@(isProcessing || string.IsNullOrWhiteSpace(videoUrl))">
@if (isProcessing)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Analisando...</span>
}
else
{
<span>Analisar com IA</span>
}
</button>
</div>
</div>
@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
{

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Platforms>AnyCPU;x64</Platforms>
@ -15,6 +15,10 @@
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.22" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.5" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
</ItemGroup>