diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ccc3179..0c178d3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,15 @@ { "permissions": { "allow": [ - "Bash(dotnet:*)" + "Bash(dotnet:*)", + "Bash(./yt-dlp.exe:*)", + "Bash(ffmpeg -version)", + "Bash(curl -s http://localhost:5000/health)", + "Bash(curl:*)", + "Bash(git rm:*)", + "Bash(taskkill /F /PID 9680)", + "Bash(taskkill:*)", + "Bash(git add:*)" ] } } diff --git a/VideoStudy.API/Controllers/LicenseController.cs b/VideoStudy.API/Controllers/LicenseController.cs deleted file mode 100644 index 02f5395..0000000 --- a/VideoStudy.API/Controllers/LicenseController.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using System.Collections.Concurrent; - -namespace VideoStudy.API.Controllers; - -public class ActivationRequest -{ - public string Email { get; set; } = string.Empty; - public string HardwareId { get; set; } = string.Empty; -} - -public class LicenseStatus -{ - public bool IsActive { get; set; } - public string Message { get; set; } = string.Empty; - public string Token { get; set; } = string.Empty; -} - -[ApiController] -[Route("api/[controller]")] -public class LicenseController : ControllerBase -{ - // In-memory simulation for Phase 4 Demo - private static readonly ConcurrentDictionary> _activations = new(); - - [HttpPost("activate")] - public ActionResult Activate([FromBody] ActivationRequest request) - { - if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.HardwareId)) - { - return BadRequest(new LicenseStatus { IsActive = false, Message = "Invalid request" }); - } - - // Simulate database lookup - if (!_activations.ContainsKey(request.Email)) - { - _activations[request.Email] = new List(); - } - - var userDevices = _activations[request.Email]; - - // Check if device already active - if (userDevices.Contains(request.HardwareId)) - { - return Ok(new LicenseStatus { IsActive = true, Message = "Device already activated", Token = GenerateMockToken(request) }); - } - - // Limit to 3 devices - if (userDevices.Count >= 3) - { - return StatusCode(403, new LicenseStatus { IsActive = false, Message = "Activation limit reached (Max 3 devices)" }); - } - - // Activate - userDevices.Add(request.HardwareId); - return Ok(new LicenseStatus { IsActive = true, Message = "Activation successful", Token = GenerateMockToken(request) }); - } - - [HttpPost("validate")] - public ActionResult Validate([FromBody] string token) - { - // Mock validation - return Ok(!string.IsNullOrEmpty(token) && token.StartsWith("MOCK_JWT_")); - } - - private string GenerateMockToken(ActivationRequest req) - { - return $"MOCK_JWT_{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(req.Email + req.HardwareId))}"; - } -} diff --git a/VideoStudy.API/Services/AnalysisService.cs b/VideoStudy.API/Services/AnalysisService.cs index 2860d47..9a897e7 100644 --- a/VideoStudy.API/Services/AnalysisService.cs +++ b/VideoStudy.API/Services/AnalysisService.cs @@ -1,15 +1,13 @@ using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text.Json; using System.Text.RegularExpressions; -using System.Threading; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; -using PuppeteerSharp; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; -using SkiaSharp; using VideoStudy.Shared; namespace VideoStudy.API.Services; @@ -18,51 +16,39 @@ public class AnalysisService { private readonly Kernel _kernel; private readonly ILogger _logger; + private readonly IConfiguration _configuration; - public AnalysisService(Kernel kernel, ILogger logger) + public AnalysisService(Kernel kernel, ILogger logger, IConfiguration configuration) { _kernel = kernel; _logger = logger; + _configuration = configuration; QuestPDF.Settings.License = LicenseType.Community; - - Task.Run(async () => { - try - { - var browserFetcher = new BrowserFetcher(); - await browserFetcher.DownloadAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to download Chromium."); - } - }); } private string GetYtDlpPath() { - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) + string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "yt-dlp.exe" : + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "yt-dlp_macos" : "yt-dlp_linux"; + + // Walk up from base directory looking for yt-dlp + var dir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); + for (int i = 0; i < 7; i++) { - var exeName = "yt-dlp.exe"; - var currentPath = Path.Combine(Directory.GetCurrentDirectory(), exeName); - if (File.Exists(currentPath)) return currentPath; - var basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, exeName); - if (File.Exists(basePath)) return basePath; - var binPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Binaries", exeName); + var path = Path.Combine(dir.FullName, exeName); + if (File.Exists(path)) return path; + var binPath = Path.Combine(dir.FullName, "Binaries", exeName); if (File.Exists(binPath)) return binPath; - return "yt-dlp"; - } - else - { - var baseDir = AppDomain.CurrentDomain.BaseDirectory; - var binariesDir = Path.Combine(baseDir, "Binaries"); - string executableName = "yt-dlp_linux"; - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) - executableName = "yt-dlp_macos"; - var fullPath = Path.Combine(binariesDir, executableName); - if (!File.Exists(fullPath)) return "yt-dlp"; - try { Process.Start("chmod", $"+x \"{fullPath}\"").WaitForExit(); } catch { } - return fullPath; + if (dir.Parent == null) break; + dir = dir.Parent; } + return "yt-dlp"; // fallback to PATH + } + + private string GetCookiesArg() + { + var browser = _configuration["YtDlp:CookiesBrowser"]; + return string.IsNullOrWhiteSpace(browser) ? "" : $"--cookies-from-browser {browser}"; } public async IAsyncEnumerable AnalyzeVideoAsync(AnalysisRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -73,7 +59,6 @@ public class AnalysisService string? errorMessage = null; AnalysisResult? finalResult = null; - // ETAPA 0: Obter Info do Vídeo yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Obtendo informações do vídeo..." }; VideoInfo? videoInfo = null; try { @@ -86,12 +71,10 @@ public class AnalysisService { yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Analisando: {videoInfo.Title}" }; - // ETAPA 1: Transcrição yield return new AnalysisEvent { ProgressPercentage = 15, Message = "Obtendo transcrição..." }; string? transcript = null; try { - var (t, _) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir); - transcript = t; + transcript = 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}"; @@ -99,35 +82,34 @@ public class AnalysisService if (errorMessage == null && transcript != null) { - // ETAPA 2: Inteligência Artificial - yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo técnico..." }; + yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo..." }; List? sections = null; string? rawJson = null, category = null, docTitle = null, summary = null; try { - var aiResult = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, cancellationToken); + var aiResult = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, request.UserContext, cancellationToken); sections = aiResult.sections; rawJson = aiResult.rawJson; category = aiResult.category; docTitle = aiResult.docTitle; summary = aiResult.summary; } catch (Exception ex) { - errorMessage = $"IA Indisponível: {ex.Message}. Verifique se excedeu o limite do Groq."; + errorMessage = $"IA Indisponível: {ex.Message}. Verifique a chave do Groq."; } if (errorMessage == null && sections != null) { - // ETAPA 3: Screenshots - var sectionsToCapture = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); - if (sectionsToCapture.Any()) + var sectionsWithImages = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); + if (sectionsWithImages.Any()) { - yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsToCapture.Count} imagens críticas..." }; + yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsWithImages.Count} imagens com FFmpeg..." }; try { - await CaptureScreenshotsInParallelAsync(request.VideoUrl, sectionsToCapture, videoInfo.Duration, cancellationToken); - } catch { /* Erros de imagem são ignorados para não travar o PDF */ } + await CaptureScreenshotsAsync(request.VideoUrl, sectionsWithImages, videoInfo.Duration, cancellationToken); + } catch (Exception ex) { + _logger.LogWarning(ex, "Falha na captura de screenshots — continuando sem imagens."); + } } - // ETAPA 4: PDF yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando documento PDF..." }; try { var pdfBytes = GeneratePdf(docTitle!, summary!, request.VideoUrl, sections, category!); @@ -148,7 +130,6 @@ public class AnalysisService } } - // LIMPEZA E RESULTADO FINAL try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } if (errorMessage != null) { @@ -158,179 +139,225 @@ public class AnalysisService } } - private async Task CaptureScreenshotsInParallelAsync(string videoUrl, List sections, TimeSpan videoDuration, CancellationToken ct) + private async Task CaptureScreenshotsAsync(string videoUrl, List sections, TimeSpan videoDuration, CancellationToken ct) { - var rawUrl = await GetRawVideoStreamUrl(videoUrl); - if (string.IsNullOrEmpty(rawUrl)) return; - using var sem = new SemaphoreSlim(3); - await Task.WhenAll(sections.Select(s => ProcessSingleSection(s, rawUrl, videoDuration, sem, ct))); + var streamUrl = await GetRawVideoStreamUrl(videoUrl); + if (string.IsNullOrEmpty(streamUrl)) return; + + using var sem = new SemaphoreSlim(2); + await Task.WhenAll(sections.Select(s => CaptureFrameAsync(s, streamUrl, videoDuration, sem, ct))); } - private async Task ProcessSingleSection(TutorialSection section, string rawUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct) + private async Task CaptureFrameAsync(TutorialSection section, string streamUrl, TimeSpan duration, SemaphoreSlim sem, CancellationToken ct) { await sem.WaitAsync(ct); + var outputFile = Path.Combine(Path.GetTempPath(), $"vs_frame_{Guid.NewGuid()}.jpg"); try { - using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, Args = new[] { "--no-sandbox" } }); - using var page = await browser.NewPageAsync(); - await page.SetViewportAsync(new ViewPortOptions { Width = 1280, Height = 720 }); - await page.SetContentAsync($""); - await page.WaitForSelectorAsync("#v"); - - if (!TimeSpan.TryParse(section.ImageTimestamp, out var ts)) return; - int target = (int)ts.TotalSeconds; - var candidates = new List<(byte[] Data, double Score)>(); + if (!TimeSpan.TryParse(section.ImageTimestamp, out var ts) || ts > duration) return; - foreach (var offset in new[] { 0, -1, 1 }) + var args = $"-ss {ts:hh\\:mm\\:ss} -i \"{streamUrl}\" -frames:v 1 -q:v 2 -y \"{outputFile}\""; + var psi = new ProcessStartInfo { - if (ct.IsCancellationRequested) break; - int time = Math.Max(0, target + offset); - if (time > duration.TotalSeconds) continue; - try { - await page.EvaluateFunctionAsync($"(s) => document.getElementById('v').currentTime = s", time); - await page.WaitForFunctionAsync("() => document.getElementById('v').readyState >= 3", new WaitForFunctionOptions { Timeout = 5000 }); - var data = await page.ScreenshotDataAsync(new ScreenshotOptions { Type = ScreenshotType.Jpeg, Quality = 85 }); - var score = CalculateImageClarityScore(data); - if (score > 10) candidates.Add((data, score)); - } catch { } - } - if (candidates.Any()) section.ImageData = candidates.OrderByDescending(c => c.Score).First().Data; + FileName = "ffmpeg", + Arguments = args, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = Process.Start(psi)!; + await p.WaitForExitAsync(ct); + + if (p.ExitCode == 0 && File.Exists(outputFile)) + section.ImageData = await File.ReadAllBytesAsync(outputFile, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Screenshot falhou em {Timestamp}", section.ImageTimestamp); + } + finally + { + if (File.Exists(outputFile)) File.Delete(outputFile); + sem.Release(); } - finally { sem.Release(); } } public async Task GetVideoInfoAsync(string url, CancellationToken ct) { - var path = GetYtDlpPath(); - var proc = Process.Start(new ProcessStartInfo { FileName = path, Arguments = $"--print title --print channel --print duration --print thumbnail --print description \"{url}\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }); - await proc!.WaitForExitAsync(ct); - var output = await proc.StandardOutput.ReadToEndAsync(); + var ytdlp = GetYtDlpPath(); + var cookies = GetCookiesArg(); + var psi = new ProcessStartInfo + { + FileName = ytdlp, + Arguments = $"{cookies} --print title --print channel --print duration --print thumbnail \"{url}\"", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var proc = Process.Start(psi)!; + await proc.WaitForExitAsync(ct); + var output = await proc.StandardOutput.ReadToEndAsync(ct); var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); if (lines.Length < 1) throw new Exception("Falha ao ler dados do vídeo via yt-dlp."); - + double.TryParse(lines.Length > 2 ? lines[2] : "0", System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var sec); - return new VideoInfo { Title = lines[0].Trim(), Author = lines[1].Trim(), Duration = TimeSpan.FromSeconds(sec), ThumbnailUrl = lines.Length > 3 ? lines[3].Trim() : "", Description = lines.Length > 4 ? string.Join("\n", lines.Skip(4)).Trim() : "", Url = url }; + return new VideoInfo + { + Title = lines[0].Trim(), + Author = lines.Length > 1 ? lines[1].Trim() : "", + Duration = TimeSpan.FromSeconds(sec), + ThumbnailUrl = lines.Length > 3 ? lines[3].Trim() : "", + Url = url + }; } - private async Task<(string, string)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir) + private async Task GetTranscriptViaYtDlpAsync(string url, string lang, string dir) { - var path = GetYtDlpPath(); - var start = new ProcessStartInfo { FileName = path, Arguments = $"--skip-download --write-sub --write-auto-sub --sub-lang {lang},en --sub-format vtt --output \"%(id)s\" \"{url}\"", WorkingDirectory = dir, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; - using var p = Process.Start(start); - await p!.WaitForExitAsync(); + var ytdlp = GetYtDlpPath(); + var cookies = GetCookiesArg(); + var psi = new ProcessStartInfo + { + FileName = ytdlp, + Arguments = $"{cookies} --skip-download --write-auto-sub --sub-lang {lang},en --sub-format vtt --output \"%(id)s\" \"{url}\"", + WorkingDirectory = dir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = Process.Start(psi)!; + await p.WaitForExitAsync(); var file = Directory.GetFiles(dir, "*.vtt").FirstOrDefault(); - return file == null ? ("", "") : (ParseVttToText(await File.ReadAllTextAsync(file)), ""); + return file == null ? "" : ParseVttToText(await File.ReadAllTextAsync(file)); } private string ParseVttToText(string vtt) { - var lines = vtt.Split('\n').Select(l => l.Trim()).Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("WEBVTT") && !l.StartsWith("NOTE") && !l.Contains("-->")); + var lines = vtt.Split('\n') + .Select(l => l.Trim()) + .Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("WEBVTT") && !l.StartsWith("NOTE") && !l.Contains("-->")); return string.Join(" ", lines.Select(l => Regex.Replace(l, @"<[^>]*>", ""))).Replace(" ", " "); } - private async Task<(List sections, string rawJson, string category, string docTitle, string summary)> GenerateTutorialContentAsync(string transcript, VideoInfo video, string inLang, string? outLang, CancellationToken ct) + 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) { - var langMap = new Dictionary { {"en", "English"}, {"pt", "Portuguese (Brazilian)"}, {"es", "Spanish"} }; + var langMap = new Dictionary { { "en", "English" }, { "pt", "Portuguese (Brazilian)" }, { "es", "Spanish" }, { "fr", "French" } }; var outName = langMap.GetValueOrDefault(outLang ?? inLang, "Portuguese (Brazilian)"); var dur = video.Duration.ToString(@"hh\:mm\:ss"); + var contextSection = string.IsNullOrWhiteSpace(userContext) + ? "" + : $"\n### CONTEXTO DO USUÁRIO:\n{userContext}\nUse este contexto para ajustar o nível de detalhe, linguagem e foco da análise.\n"; - var prompt = $@" -Você é um ANALISTA TÉCNICO DE CONTEÚDO especializado em converter vídeos em documentação estruturada. - -### REGRAS DE OURO: -1. NÃO RESUMA: Transforme cada explicação do vídeo em um tópico técnico detalhado e denso. -2. ISONOMIA: Dedique o mesmo nível de profundidade a todos os tópicos. Se o vídeo explica 5 curiosidades, gere 5 seções extensas. -3. FOCO NO TÍTULO: Garanta que o tema ""{video.Title}"" seja a seção de maior clareza e detalhamento. -4. SCREENSHOTS: Insira `[SCREENSHOT: HH:MM:SS]` ao final de parágrafos que descrevam algo visualmente importante. (Limite: {dur}). + var prompt = $@"Você é um ANALISTA TÉCNICO DE CONTEÚDO especializado em converter vídeos em documentação estruturada. +{contextSection} +### REGRAS: +1. Transforme cada explicação em um tópico técnico detalhado — NÃO resuma demais. +2. Dedique o mesmo nível de profundidade a todos os tópicos. +3. Garanta que o tema ""{video.Title}"" seja a seção de maior clareza. +4. Insira `[SCREENSHOT: HH:MM:SS]` ao final de parágrafos com algo visualmente importante. (Limite: {dur}) ### DADOS: - Título: {video.Title} -- Descrição: {video.Description} - Transcrição: {transcript[..Math.Min(transcript.Length, 25000)]} ### FORMATO DE SAÍDA (JSON): {{ - ""category"": ""TUTORIAL | MEETING | LECTURE | OTHER"", + ""category"": ""TUTORIAL | LECTURE | OTHER"", ""shortTitle"": ""Título Curto e Limpo"", - ""summary"": ""Um único parágrafo de até 4 linhas sintetizando o valor principal do vídeo."", + ""summary"": ""Um parágrafo de até 4 linhas resumindo o valor principal do vídeo."", ""sections"": [ - {{ ""title"": ""Título do Tópico"", ""content"": ""Explicação técnica densa... [SCREENSHOT: HH:MM:SS]"" }} + {{ ""title"": ""Título do Tópico"", ""content"": ""Explicação densa... [SCREENSHOT: HH:MM:SS]"" }} ] }} Escreva tudo em {outName}."; var chatService = _kernel.GetRequiredService(); var result = await chatService.GetChatMessageContentAsync(prompt, cancellationToken: ct); - + string rawContent = result.Content ?? "{}"; _logger.LogInformation("Resposta bruta da IA: {RawContent}", rawContent); var jsonMatch = Regex.Match(rawContent, @"\{[\s\S]*\}"); if (!jsonMatch.Success) throw new Exception("A IA não retornou um JSON válido."); - + string json = jsonMatch.Value; using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - - var sections = root.GetProperty("sections").EnumerateArray().Select(el => new TutorialSection { - Title = el.GetProperty("title").GetString() ?? "", - Content = Regex.Replace(el.GetProperty("content").GetString() ?? "", @"\[SCREENSHOT: \d{2}:\d{2}:\d{2}\]", "").Trim(), - ImageTimestamp = Regex.Match(el.GetProperty("content").GetString() ?? "", @"\[SCREENSHOT:\s*(\d{2}:\d{2}:\d{2})\]").Groups[1].Value + + var sections = root.GetProperty("sections").EnumerateArray().Select(el => + { + var content = el.GetProperty("content").GetString() ?? ""; + var tsMatch = Regex.Match(content, @"\[SCREENSHOT:\s*(\d{2}:\d{2}:\d{2})\]"); + return new TutorialSection + { + Title = el.GetProperty("title").GetString() ?? "", + Content = Regex.Replace(content, @"\[SCREENSHOT: \d{2}:\d{2}:\d{2}\]", "").Trim(), + ImageTimestamp = tsMatch.Success ? tsMatch.Groups[1].Value : null + }; }).ToList(); - return (sections, json, root.GetProperty("category").GetString() ?? "OTHER", root.GetProperty("shortTitle").GetString() ?? video.Title, root.GetProperty("summary").GetString() ?? ""); + return (sections, json, + root.GetProperty("category").GetString() ?? "OTHER", + root.GetProperty("shortTitle").GetString() ?? video.Title, + root.GetProperty("summary").GetString() ?? ""); } private async Task GetRawVideoStreamUrl(string url) { - var proc = Process.Start(new ProcessStartInfo { FileName = GetYtDlpPath(), Arguments = $"-g -f b \"{url}\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }); - return (await proc!.StandardOutput.ReadLineAsync())?.Trim(); - } - - private double CalculateImageClarityScore(byte[] bytes) - { - try { - using var img = SKBitmap.Decode(bytes); - if (img == null) return -1; - var lumas = new List(); - for (int y = 0; y < img.Height; y += 20) for (int x = 0; x < img.Width; x += 20) { - var p = img.GetPixel(x,y); - lumas.Add(p.Red * 0.299f + p.Green * 0.587f + p.Blue * 0.114f); - } - var avg = lumas.Average(); - return Math.Sqrt(lumas.Sum(v => Math.Pow(v - avg, 2)) / lumas.Count); - } catch { return -1; } + var ytdlp = GetYtDlpPath(); + var cookies = GetCookiesArg(); + var psi = new ProcessStartInfo + { + FileName = ytdlp, + Arguments = $"{cookies} -g -f \"bv*[height<=720][ext=mp4]/bv*[height<=720]/b\" \"{url}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var proc = Process.Start(psi)!; + var line = await proc.StandardOutput.ReadLineAsync(); + await proc.WaitForExitAsync(); + return line?.Trim(); } private byte[] GeneratePdf(string title, string summary, string url, List sections, string category) { - var color = category switch { "TUTORIAL" => Colors.Green.Medium, "MEETING" => Colors.Orange.Medium, _ => Colors.Blue.Medium }; - return Document.Create(container => { - container.Page(page => { + var color = category switch { "TUTORIAL" => Colors.Green.Medium, "LECTURE" => Colors.Orange.Medium, _ => Colors.Blue.Medium }; + return Document.Create(container => + { + container.Page(page => + { page.Margin(2, Unit.Centimetre); page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Segoe UI")); - page.Header().Column(c => { - c.Item().Row(r => { + page.Header().Column(c => + { + c.Item().Row(r => + { r.RelativeItem().Text(title).SemiBold().FontSize(22).FontColor(Colors.Blue.Darken3); r.ConstantItem(80).AlignRight().Text(category).Bold().FontSize(10).FontColor(color); }); c.Item().PaddingTop(5).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); }); - page.Content().PaddingVertical(1, Unit.Centimetre).Column(col => { - col.Item().Background(Colors.Grey.Lighten4).Padding(10).Column(rc => { + page.Content().PaddingVertical(1, Unit.Centimetre).Column(col => + { + col.Item().Background(Colors.Grey.Lighten4).Padding(10).Column(rc => + { rc.Item().Text("Resumo").Bold().FontSize(12).FontColor(Colors.Blue.Medium); rc.Item().PaddingTop(2).Text(summary).Italic(); }); col.Item().PaddingTop(10).Text(t => { t.Span("Fonte: ").SemiBold(); t.Span(url).Italic().FontSize(9); }); - - foreach (var s in sections) { + + foreach (var s in sections) + { col.Item().PaddingTop(20).Text(s.Title).Bold().FontSize(16).FontColor(color); col.Item().PaddingTop(5).Text(s.Content).LineHeight(1.5f); if (s.ImageData != null) col.Item().PaddingVertical(10).Image(s.ImageData).FitWidth(); } }); - page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app - "); x.CurrentPageNumber(); }); + page.Footer().AlignCenter().Text(x => { x.Span("VideoStudy.app — "); x.CurrentPageNumber(); }); }); }).GeneratePdf(); } -} \ No newline at end of file +} diff --git a/VideoStudy.API/VideoStudy.API.csproj b/VideoStudy.API/VideoStudy.API.csproj index cd6a0b3..217bac8 100644 --- a/VideoStudy.API/VideoStudy.API.csproj +++ b/VideoStudy.API/VideoStudy.API.csproj @@ -13,14 +13,6 @@ - - - - - - - PreserveNewest - diff --git a/VideoStudy.API/appsettings.json b/VideoStudy.API/appsettings.json index f3b7b32..bfeb456 100644 --- a/VideoStudy.API/appsettings.json +++ b/VideoStudy.API/appsettings.json @@ -13,9 +13,12 @@ "Model": "llama3.1" }, "Groq": { - "ApiKey": "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", + "ApiKey": "", "Model": "llama-3.3-70b-versatile", "BaseUrl": "https://api.groq.com/openai/v1" } + }, + "YtDlp": { + "CookiesBrowser": "firefox" } } diff --git a/VideoStudy.Linux/Program.cs b/VideoStudy.Linux/Program.cs new file mode 100644 index 0000000..df30367 --- /dev/null +++ b/VideoStudy.Linux/Program.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using Photino.Blazor; +using VideoStudy.UI; +using VideoStudy.Shared; + +namespace VideoStudy.Linux; + +class Program +{ + [STAThread] + static void Main(string[] args) + { + var ComponentsApp = typeof(VideoStudy.UI.App); + + var builder = PhotinoBlazorAppBuilder.CreateDefault(args); + + // Registro de serviços da UI Unificada + builder.Services.AddVideoStudyUI(); + + // Configurar HttpClient para a API + builder.Services.AddScoped(sp => new HttpClient + { + BaseAddress = new Uri("http://localhost:5000"), + Timeout = TimeSpan.FromMinutes(10) + }); + + // Registrar um IPdfSaver dummy ou específico para Linux se necessário + // No Linux, o Photino pode usar o explorador de arquivos nativo ou salvar direto na pasta Downloads + builder.Services.AddSingleton(); + + builder.RootComponents.Add(ComponentsApp, "#app"); + + var app = builder.Build(); + + app.MainWindow + .SetTitle("VideoStudy - Study Smarter with AI") + .SetSize(1200, 800) + .SetIconFile("wwwroot/favicon.ico") // Se houver + .Center(); + + app.Run(); + } +} + +// Implementação básica de salvamento para Linux +public class LinuxPdfSaver : IPdfSaver +{ + public async Task SavePdfAsync(byte[] pdfData, string suggestedFileName) + { + // No Linux, por simplicidade inicial, vamos salvar na pasta Downloads do usuário + var downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + var filePath = Path.Combine(downloadsPath, suggestedFileName); + + await File.WriteAllBytesAsync(filePath, pdfData); + + // No Linux, podemos tentar abrir o arquivo usando 'xdg-open' + try + { + System.Diagnostics.Process.Start("xdg-open", filePath); + } + catch { } + + return filePath; + } +} diff --git a/VideoStudy.Linux/VideoStudy.Linux.csproj b/VideoStudy.Linux/VideoStudy.Linux.csproj new file mode 100644 index 0000000..d9b84a9 --- /dev/null +++ b/VideoStudy.Linux/VideoStudy.Linux.csproj @@ -0,0 +1,19 @@ + + + + WinExe + net8.0 + enable + enable + AnyCPU;x64 + + + + + + + + + + + diff --git a/VideoStudy.Native/wwwroot/index.html b/VideoStudy.Linux/wwwroot/index.html similarity index 71% rename from VideoStudy.Native/wwwroot/index.html rename to VideoStudy.Linux/wwwroot/index.html index dcc3c83..2a51d32 100644 --- a/VideoStudy.Native/wwwroot/index.html +++ b/VideoStudy.Linux/wwwroot/index.html @@ -2,19 +2,18 @@ - - VideoStudy.Native + + VideoStudy - Linux -
-
+
-
Carregando VideoStudy...
+
Starting VideoStudy...
@@ -24,8 +23,7 @@ 🗙
- - + + - diff --git a/VideoStudy.Native/App.xaml b/VideoStudy.Native/App.xaml deleted file mode 100644 index 043d14d..0000000 --- a/VideoStudy.Native/App.xaml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/VideoStudy.Native/App.xaml.cs b/VideoStudy.Native/App.xaml.cs deleted file mode 100644 index 9b2b22e..0000000 --- a/VideoStudy.Native/App.xaml.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace VideoStudy.Native; - -public partial class App : Application -{ - public App() - { - InitializeComponent(); - } - - protected override Window CreateWindow(IActivationState? activationState) - { - return new Window(new MainPage()); - } -} \ No newline at end of file diff --git a/VideoStudy.Native/MainPage.xaml b/VideoStudy.Native/MainPage.xaml deleted file mode 100644 index 3cd1540..0000000 --- a/VideoStudy.Native/MainPage.xaml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - diff --git a/VideoStudy.Native/MainPage.xaml.cs b/VideoStudy.Native/MainPage.xaml.cs deleted file mode 100644 index 118a9b2..0000000 --- a/VideoStudy.Native/MainPage.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace VideoStudy.Native; - -public partial class MainPage : ContentPage -{ - public MainPage() - { - InitializeComponent(); - } -} diff --git a/VideoStudy.Native/MauiProgram.cs b/VideoStudy.Native/MauiProgram.cs deleted file mode 100644 index 4411ab3..0000000 --- a/VideoStudy.Native/MauiProgram.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.AspNetCore.Components.WebView.Maui; -using Microsoft.Extensions.Logging; -using VideoStudy.Native.Services; -using VideoStudy.Shared; -using VideoStudy.UI; - -namespace VideoStudy.Native; - -public static class MauiProgram -{ - public static MauiApp CreateMauiApp() - { - var builder = MauiApp.CreateBuilder(); - builder - .UseMauiApp(); - - builder.Services.AddMauiBlazorWebView(); - -#if DEBUG - builder.Services.AddBlazorWebViewDeveloperTools(); - builder.Logging.AddDebug(); - builder.Logging.SetMinimumLevel(LogLevel.Debug); -#endif - - // Configurar HttpClient para API Local (timeout longo para análise de vídeo) - builder.Services.AddScoped(sp => new HttpClient - { - BaseAddress = new Uri("http://localhost:5000"), - Timeout = TimeSpan.FromMinutes(10) - }); - - // PDF Saver (FileSavePicker do Windows) - builder.Services.AddSingleton(); - - // Persistence Service (LiteDB) - builder.Services.AddScoped(); - - return builder.Build(); - } -} diff --git a/VideoStudy.Native/Platforms/Windows/App.xaml b/VideoStudy.Native/Platforms/Windows/App.xaml deleted file mode 100644 index edd9f80..0000000 --- a/VideoStudy.Native/Platforms/Windows/App.xaml +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/VideoStudy.Native/Platforms/Windows/App.xaml.cs b/VideoStudy.Native/Platforms/Windows/App.xaml.cs deleted file mode 100644 index be2c7fd..0000000 --- a/VideoStudy.Native/Platforms/Windows/App.xaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Maui; -using Microsoft.Maui.Hosting; - -namespace VideoStudy.Native.WinUI; - -public partial class App : MauiWinUIApplication -{ - public App() - { - } - - protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); -} \ No newline at end of file diff --git a/VideoStudy.Native/Platforms/Windows/Package.appxmanifest b/VideoStudy.Native/Platforms/Windows/Package.appxmanifest deleted file mode 100644 index a1e6542..0000000 --- a/VideoStudy.Native/Platforms/Windows/Package.appxmanifest +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - VideoStudy - Ricardo - Resources\AppIcon\appicon.svg - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/VideoStudy.Native/Resources/AppIcon/appicon.svg b/VideoStudy.Native/Resources/AppIcon/appicon.svg deleted file mode 100644 index aab74a7..0000000 --- a/VideoStudy.Native/Resources/AppIcon/appicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/VideoStudy.Native/Resources/AppIcon/appiconfg.svg b/VideoStudy.Native/Resources/AppIcon/appiconfg.svg deleted file mode 100644 index 0a9eb11..0000000 --- a/VideoStudy.Native/Resources/AppIcon/appiconfg.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - VS - diff --git a/VideoStudy.Native/Resources/Images/dotnet_bot.svg b/VideoStudy.Native/Resources/Images/dotnet_bot.svg deleted file mode 100644 index 98aad0b..0000000 --- a/VideoStudy.Native/Resources/Images/dotnet_bot.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/VideoStudy.Native/Resources/Splash/splash.svg b/VideoStudy.Native/Resources/Splash/splash.svg deleted file mode 100644 index c3dec0d..0000000 --- a/VideoStudy.Native/Resources/Splash/splash.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - VideoStudy - diff --git a/VideoStudy.Native/Resources/Styles/Colors.xaml b/VideoStudy.Native/Resources/Styles/Colors.xaml deleted file mode 100644 index ec4a6f5..0000000 --- a/VideoStudy.Native/Resources/Styles/Colors.xaml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - #512BD4 - #DFD8F7 - #2B0B98 - #FFFFFF - #000000 - #E1E1E1 - #C8C8C8 - #ACACAC - #919191 - #6E6E6E - #404040 - #212121 - #141414 - - - - - - - \ No newline at end of file diff --git a/VideoStudy.Native/Resources/Styles/Styles.xaml b/VideoStudy.Native/Resources/Styles/Styles.xaml deleted file mode 100644 index 549654d..0000000 --- a/VideoStudy.Native/Resources/Styles/Styles.xaml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - diff --git a/VideoStudy.Native/Services/WindowsPdfSaver.cs b/VideoStudy.Native/Services/WindowsPdfSaver.cs deleted file mode 100644 index fdf7714..0000000 --- a/VideoStudy.Native/Services/WindowsPdfSaver.cs +++ /dev/null @@ -1,38 +0,0 @@ -using VideoStudy.Shared; -using Windows.Storage; -using Windows.Storage.Pickers; -using WinRT.Interop; - -namespace VideoStudy.Native.Services; - -public class WindowsPdfSaver : IPdfSaver -{ - public async Task SavePdfAsync(byte[] pdfData, string suggestedFileName) - { - var savePicker = new FileSavePicker - { - SuggestedStartLocation = PickerLocationId.Downloads, - SuggestedFileName = suggestedFileName - }; - savePicker.FileTypeChoices.Add("PDF", [".pdf"]); - - // Get the window handle for the picker - var window = Application.Current?.Windows.FirstOrDefault(); - var mauiWindow = window?.Handler?.PlatformView; - if (mauiWindow is Microsoft.UI.Xaml.Window winuiWindow) - { - var hwnd = WindowNative.GetWindowHandle(winuiWindow); - InitializeWithWindow.Initialize(savePicker, hwnd); - } - - var file = await savePicker.PickSaveFileAsync(); - if (file == null) return null; - - await FileIO.WriteBytesAsync(file, pdfData); - - // Open the PDF after saving - await Windows.System.Launcher.LaunchFileAsync(file); - - return file.Path; - } -} diff --git a/VideoStudy.Native/VideoStudy.Native.csproj b/VideoStudy.Native/VideoStudy.Native.csproj deleted file mode 100644 index f5986c2..0000000 --- a/VideoStudy.Native/VideoStudy.Native.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - net10.0-windows10.0.19041.0 - 10.0.17763.0 - WinExe - true - true - enable - enable - - VideoStudy - com.companyname.videostudy - 1.0 - 1 - None - - - - - - - - - - - - - - - - - - - - - - - diff --git a/VideoStudy.Shared/Models.cs b/VideoStudy.Shared/Models.cs index 9ef791d..6e642cc 100644 --- a/VideoStudy.Shared/Models.cs +++ b/VideoStudy.Shared/Models.cs @@ -8,9 +8,10 @@ public class AnalysisRequest public string VideoUrl { get; set; } = string.Empty; public string Language { get; set; } = "en"; // Input language (subtitles) public string? OutputLanguage { get; set; } // Output language (tutorial). null = same as Language - public string Mode { get; set; } = "fast"; // fast or advanced - public string? TranscriptText { get; set; } // Optional: if client already transcribed it + public string Mode { get; set; } = "fast"; public double DurationSeconds { get; set; } + // Contexto livre do usuário: "tutorial técnico de Python", "aula de física escolar", etc. + public string? UserContext { get; set; } } /// diff --git a/VideoStudy.Shared/Services/HardwareIdService.cs b/VideoStudy.Shared/Services/HardwareIdService.cs deleted file mode 100644 index db893ee..0000000 --- a/VideoStudy.Shared/Services/HardwareIdService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Text; - -namespace VideoStudy.Shared.Services; - -public interface IHardwareIdService -{ - string GetHardwareId(); -} - -public class HardwareIdService : IHardwareIdService -{ - public string GetHardwareId() - { - try - { - string rawId; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - rawId = GetWindowsHardwareId(); - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - rawId = GetMacOSHardwareId(); - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - rawId = GetLinuxHardwareId(); - else - rawId = "UNKNOWN_PLATFORM_" + Environment.MachineName; - - return ComputeSHA256(rawId); - } - catch - { - // Fallback for permissions issues - return ComputeSHA256(Environment.MachineName + Environment.UserName); - } - } - - private string GetWindowsHardwareId() - { - // Simple fallback to MachineName + ProcessorCount to avoid System.Management dependency in Shared - return Environment.MachineName + Environment.ProcessorCount; - } - - private string GetMacOSHardwareId() - { - // Ideally use 'system_profiler SPHardwareDataType' - return Environment.MachineName; - } - - private string GetLinuxHardwareId() - { - // Try reading machine-id - try - { - if (File.Exists("/etc/machine-id")) - return File.ReadAllText("/etc/machine-id").Trim(); - if (File.Exists("/var/lib/dbus/machine-id")) - return File.ReadAllText("/var/lib/dbus/machine-id").Trim(); - } - catch {} - return Environment.MachineName; - } - - private string ComputeSHA256(string rawData) - { - using var sha256 = SHA256.Create(); - var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(rawData)); - return BitConverter.ToString(bytes).Replace("-", "").ToLower(); - } -} diff --git a/VideoStudy.UI/PdfPreview.razor b/VideoStudy.UI/Components/PdfPreview.razor similarity index 100% rename from VideoStudy.UI/PdfPreview.razor rename to VideoStudy.UI/Components/PdfPreview.razor diff --git a/VideoStudy.UI/ProgressIndicator.razor b/VideoStudy.UI/Components/ProgressIndicator.razor similarity index 100% rename from VideoStudy.UI/ProgressIndicator.razor rename to VideoStudy.UI/Components/ProgressIndicator.razor diff --git a/VideoStudy.UI/Components/YouTubeProcessor.razor b/VideoStudy.UI/Components/YouTubeProcessor.razor new file mode 100644 index 0000000..80b1de1 --- /dev/null +++ b/VideoStudy.UI/Components/YouTubeProcessor.razor @@ -0,0 +1,61 @@ +@using VideoStudy.Shared + +
+
+ + +
+ + @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() + { + if (string.IsNullOrWhiteSpace(VideoUrl)) return; + 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 3f77791..d0dd468 100644 --- a/VideoStudy.UI/Pages/Home.razor +++ b/VideoStudy.UI/Pages/Home.razor @@ -1,158 +1,68 @@ @page "/" @inject HttpClient Http -@inject IPdfSaver PdfSaver +@inject YouTubeService YouTubeService +@inject PersistenceService PersistenceService +@inject NavigationManager NavigationManager @using VideoStudy.Shared @using System.Net.Http.Json @using System.Text.Json -@using System.Threading // Added for Cancellation +@using System.Threading -VideoStudy - Video Analysis +VideoStudy
-

- VideoStudy (Native) + VideoStudy

-

Transforme videos em apostilas inteligentes com IA

+

Transforme vídeos em guias de estudo com IA

- - +
+ - -
- @if (activeTab == "youtube") - { -
- -
- -
-
- } - - - @if (videoInfo != null) - { -
- @if (!string.IsNullOrEmpty(videoInfo.ThumbnailUrl)) - { - Thumbnail - } -
-
@videoInfo.Title
-
- @videoInfo.Author - @videoInfo.Duration.ToString(@"hh\:mm\:ss") -
-
- -
- } - - -
-
- - -
-
- - -
+ +
+ +
- -
- @if (videoInfo == null) - { - - - } - else - { - -
- -
- } +
+
+ + +
- @if (isProcessing || currentStep > 0) { -
-
- @statusMessage -
-
+ } - @if (logs.Count > 0) {
-
- Execution Logs - @logs.Count items @(showLogs ? "v" : ">") +
+ Log de execução + @logs.Count eventos @(showLogs ? "▼" : "▶")
@if (showLogs) { @@ -160,7 +70,7 @@ @foreach (var log in logs) {
- [@log.Timestamp:HH:mm:ss] @log.Message + [@log.Timestamp.ToString("HH:mm:ss")] @log.Message
}
@@ -168,33 +78,28 @@
} + @if (!string.IsNullOrEmpty(generatedPdfPath)) + { + + } +
- - @code { - private string activeTab = "youtube"; private bool isProcessing = false; - private bool isFetchingInfo = false; private int progress = 0; private int currentStep = 0; - private string statusMessage = "Ready"; + private string statusMessage = "Pronto"; private bool showLogs = false; private List logs = new(); private string videoUrl = string.Empty; - private string selectedLanguage = "en"; - private string selectedOutputLanguage = "pt"; - private VideoInfo? videoInfo; + private string selectedLanguage = "pt"; + private string? userContext; + private VideoInfo? currentVideoInfo; + private string? generatedPdfPath; private class LogEntry { @@ -207,45 +112,23 @@ private void AddLog(string message, int? eventProgress = null) { logs.Insert(0, new LogEntry { Message = message }); + if (logs.Count > 100) logs.RemoveAt(logs.Count - 1); if (eventProgress.HasValue) progress = eventProgress.Value; StateHasChanged(); } - private void ClearVideoInfo() + private async Task HandleVideoUrlChanged(string url) { - videoInfo = null; - } - - private async Task FetchVideoInfo() - { - if (string.IsNullOrWhiteSpace(videoUrl)) return; - isFetchingInfo = true; - showLogs = true; - + videoUrl = url; try { - AddLog("Buscando informacoes do video..."); - var response = await Http.GetAsync($"api/video-info?url={Uri.EscapeDataString(videoUrl)}"); - - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(); - throw new Exception($"API Error: {error}"); - } - - videoInfo = await response.Content.ReadFromJsonAsync( - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - if (videoInfo != null) - AddLog($"Video encontrado: {videoInfo.Title} ({videoInfo.Duration:hh\\:mm\\:ss})"); + AddLog($"Buscando informações: {url}"); + currentVideoInfo = await YouTubeService.GetVideoInfoAsync(url); + if (currentVideoInfo != null) AddLog($"Encontrado: {currentVideoInfo.Title}"); } catch (Exception ex) { - AddLog($"Erro: {ex.Message}"); - } - finally - { - isFetchingInfo = false; + AddLog($"Erro ao buscar vídeo: {ex.Message}"); } } @@ -254,6 +137,7 @@ isProcessing = true; progress = 0; currentStep = 0; + generatedPdfPath = null; logs.Clear(); showLogs = true; @@ -261,90 +145,73 @@ try { + cts = new CancellationTokenSource(); + var token = cts.Token; + currentStep = 1; statusMessage = "Iniciando análise..."; - AddLog("🚀 Enviando requisição para a API...", 5); + AddLog("Enviando para o servidor...", 5); var request = new AnalysisRequest { VideoUrl = videoUrl, - Language = selectedLanguage, - OutputLanguage = selectedOutputLanguage, - Mode = "native" + Mode = "fast", + Language = "en", + OutputLanguage = selectedLanguage, + DurationSeconds = currentVideoInfo?.Duration.TotalSeconds ?? 0, + UserContext = string.IsNullOrWhiteSpace(userContext) ? null : userContext.Trim() }; - cts = new CancellationTokenSource(); - var cancellationToken = cts.Token; - - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "api/analyze") + var httpRequest = new HttpRequestMessage(HttpMethod.Post, "api/analyze") { Content = JsonContent.Create(request) }; - using var response = await Http.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - - if (!response.IsSuccessStatusCode) + using var response = await Http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, token); + response.EnsureSuccessStatusCode(); + + var stream = response.Content.ReadFromJsonAsAsyncEnumerable( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, token); + + await foreach (var ev in stream.WithCancellation(token)) { - var error = await response.Content.ReadAsStringAsync(); - throw new Exception($"API Error ({response.StatusCode}): {error}"); - } + if (ev == null) continue; + if (ev.IsError) throw new Exception(ev.Message); - var stream = response.Content.ReadFromJsonAsAsyncEnumerable(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, cancellationToken); - - await foreach (var analysisEvent in stream.WithCancellation(cancellationToken)) - { - if (analysisEvent == null) continue; - - if (analysisEvent.IsError) - { - throw new Exception($"Erro da API: {analysisEvent.Message}"); - } - - AddLog(analysisEvent.Message, analysisEvent.ProgressPercentage); - statusMessage = analysisEvent.Message; - progress = analysisEvent.ProgressPercentage; + AddLog(ev.Message, ev.ProgressPercentage); + statusMessage = ev.Message; StateHasChanged(); - if (analysisEvent.Result != null) + if (ev.Result != null) { - var result = analysisEvent.Result; - AddLog("✅ Análise concluída!", 100); - statusMessage = "Pronto!"; + AddLog("Análise concluída!", 100); - if (result.PdfData != null && result.PdfData.Length > 0) + if (ev.Result.PdfData != null && ev.Result.PdfData.Length > 0) { - AddLog("Salvando PDF..."); - var fileName = $"VideoStudy_{result.VideoTitle ?? "Tutorial"}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; - // Remove invalid filename chars - fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars())); - var savedPath = await PdfSaver.SavePdfAsync(result.PdfData, fileName); - if (savedPath != null) - AddLog($"✓ PDF salvo: {savedPath}"); - else - AddLog("⚠️ Salvamento do PDF cancelado pelo usuário"); - } - else - { - AddLog("⚠️ Nenhum dado de PDF retornado da API"); + AddLog("Salvando PDF na biblioteca...", 100); + var session = await PersistenceService.SaveSessionAsync( + ev.Result.PdfData, + ev.Result.DocumentTitle, + videoUrl); + + generatedPdfPath = session.FilePath; + AddLog($"PDF salvo em: {session.FilePath}", 100); + + await Task.Delay(2000); + NavigationManager.NavigateTo("library"); } break; } } } - catch (OperationCanceledException) - { - AddLog("⏸️ Operação cancelada."); - statusMessage = "Cancelado"; - } catch (Exception ex) { - AddLog($"❌ Erro: {ex.Message}"); - statusMessage = "Erro"; + AddLog($"Erro: {ex.Message}"); + statusMessage = "Erro na análise"; } finally { isProcessing = false; - videoInfo = null; cts?.Dispose(); } } diff --git a/VideoStudy.UI/Pages/Library.razor b/VideoStudy.UI/Pages/Library.razor index e88af74..41b0cd5 100644 --- a/VideoStudy.UI/Pages/Library.razor +++ b/VideoStudy.UI/Pages/Library.razor @@ -1,5 +1,5 @@ @page "/library" -@inject VideoStudy.Shared.Services.PersistenceService PersistenceService +@inject PersistenceService PersistenceService @using VideoStudy.Shared @using System.Diagnostics diff --git a/VideoStudy.UI/ServiceCollectionExtensions.cs b/VideoStudy.UI/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..5491968 --- /dev/null +++ b/VideoStudy.UI/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using VideoStudy.UI.Services; + +namespace VideoStudy.UI; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddVideoStudyUI(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/VideoStudy.Shared/Services/PersistenceService.cs b/VideoStudy.UI/Services/PersistenceService.cs similarity index 76% rename from VideoStudy.Shared/Services/PersistenceService.cs rename to VideoStudy.UI/Services/PersistenceService.cs index d893ee1..afcfcf9 100644 --- a/VideoStudy.Shared/Services/PersistenceService.cs +++ b/VideoStudy.UI/Services/PersistenceService.cs @@ -1,7 +1,7 @@ using LiteDB; using VideoStudy.Shared; -namespace VideoStudy.Shared.Services; +namespace VideoStudy.UI.Services; public class PersistenceService { @@ -10,8 +10,6 @@ public class PersistenceService public PersistenceService() { - // Define a pasta base em %USERPROFILE%/MeuVideoStudy - // Funciona em Windows, macOS e Linux (limitado pelo ambiente MAUI) _basePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "MeuVideoStudy"); _dbPath = Path.Combine(_basePath, "videostudy.db"); @@ -23,22 +21,14 @@ public class PersistenceService public string GetBasePath() => _basePath; - /// - /// Retorna as subpastas físicas dentro do diretório base. - /// public List GetFolders() { - if (!Directory.Exists(_basePath)) return new List(); - return Directory.GetDirectories(_basePath) .Select(Path.GetFileName) .Where(name => name != null) .ToList()!; } - /// - /// Cria uma nova pasta física. - /// public void CreateFolder(string folderName) { var path = Path.Combine(_basePath, folderName); @@ -48,27 +38,30 @@ public class PersistenceService } } - /// - /// Salva o PDF no disco e registra no LiteDB. - /// + public void RenameFolder(string oldName, string newName) + { + var oldPath = Path.Combine(_basePath, oldName); + var newPath = Path.Combine(_basePath, newName); + if (Directory.Exists(oldPath) && !Directory.Exists(newPath)) + { + Directory.Move(oldPath, newPath); + } + } + public async Task SaveSessionAsync(byte[] pdfData, string title, string? youtubeId, string folderName = "Geral") { - // Garantir que a pasta existe var folderPath = Path.Combine(_basePath, folderName); if (!Directory.Exists(folderPath)) { Directory.CreateDirectory(folderPath); } - // Criar nome de arquivo seguro var safeTitle = string.Join("_", title.Split(Path.GetInvalidFileNameChars())); var fileName = $"{safeTitle}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; var filePath = Path.Combine(folderPath, fileName); - // Gravar arquivo físico await File.WriteAllBytesAsync(filePath, pdfData); - // Registrar no LiteDB using var db = new LiteDatabase(_dbPath); var collection = db.GetCollection("sessions"); @@ -87,9 +80,6 @@ public class PersistenceService return session; } - /// - /// Recupera todas as sessões do banco. - /// public List GetAllSessions() { using var db = new LiteDatabase(_dbPath); @@ -98,4 +88,13 @@ public class PersistenceService .OrderByDescending(x => x.CreatedAt) .ToList(); } + + public List GetSessionsByFolder(string folderName) + { + using var db = new LiteDatabase(_dbPath); + return db.GetCollection("sessions") + .Find(x => x.FolderName == folderName) + .OrderByDescending(x => x.CreatedAt) + .ToList(); + } } diff --git a/VideoStudy.UI/Services/YouTubeService.cs b/VideoStudy.UI/Services/YouTubeService.cs new file mode 100644 index 0000000..c9c4092 --- /dev/null +++ b/VideoStudy.UI/Services/YouTubeService.cs @@ -0,0 +1,21 @@ +using System.Net.Http.Json; +using VideoStudy.Shared; + +namespace VideoStudy.UI.Services; + +public class YouTubeService +{ + private readonly HttpClient _httpClient; + + public YouTubeService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task GetVideoInfoAsync(string url) + { + var encodedUrl = Uri.EscapeDataString(url); + var info = await _httpClient.GetFromJsonAsync($"api/video-info?url={encodedUrl}"); + return info ?? throw new Exception("Não foi possível obter informações do vídeo."); + } +} diff --git a/VideoStudy.UI/VideoStudy.UI.csproj b/VideoStudy.UI/VideoStudy.UI.csproj index a6ab39a..899f855 100644 --- a/VideoStudy.UI/VideoStudy.UI.csproj +++ b/VideoStudy.UI/VideoStudy.UI.csproj @@ -7,7 +7,6 @@ AnyCPU;x64 - @@ -22,6 +21,7 @@ + diff --git a/VideoStudy.UI/_Imports.razor b/VideoStudy.UI/_Imports.razor index f091a74..d103e0e 100644 --- a/VideoStudy.UI/_Imports.razor +++ b/VideoStudy.UI/_Imports.razor @@ -9,4 +9,6 @@ @using VideoStudy.UI @using VideoStudy.UI.Layout @using VideoStudy.UI.Pages +@using VideoStudy.UI.Components +@using VideoStudy.UI.Services @using VideoStudy.Shared \ No newline at end of file diff --git a/VideoStudy.sln b/VideoStudy.sln index 1e41b75..379f129 100644 --- a/VideoStudy.sln +++ b/VideoStudy.sln @@ -7,80 +7,72 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Shared", "VideoS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.API", "VideoStudy.API\VideoStudy.API.csproj", "{022CD193-2FB4-4507-BAA2-56DB7A40841E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "VideoStudy.Desktop", "VideoStudy.Desktop", "{A245341F-91C7-4FC3-9EB8-FC6455180427}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Desktop", "VideoStudy.Desktop\VideoStudy.Desktop\VideoStudy.Desktop.csproj", "{0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Desktop.Client", "VideoStudy.Desktop\VideoStudy.Desktop.Client\VideoStudy.Desktop.Client.csproj", "{59672094-7BE6-4CB2-8401-59D59D8AF07A}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.UI", "VideoStudy.UI\VideoStudy.UI.csproj", "{CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Native", "VideoStudy.Native\VideoStudy.Native.csproj", "{0387DAAC-724C-43D1-9C68-D8383F05E909}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VideoStudy.Linux", "VideoStudy.Linux\VideoStudy.Linux.csproj", "{D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|x64.ActiveCfg = Debug|x64 {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|x64.Build.0 = Debug|x64 + {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|x86.ActiveCfg = Debug|Any CPU + {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Debug|x86.Build.0 = Debug|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|Any CPU.ActiveCfg = Release|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|Any CPU.Build.0 = Release|Any CPU {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|x64.ActiveCfg = Release|x64 {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|x64.Build.0 = Release|x64 + {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|x86.ActiveCfg = Release|Any CPU + {C2F5E2F7-0872-422B-B246-A9EC585AF3CC}.Release|x86.Build.0 = Release|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|Any CPU.Build.0 = Debug|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|x64.ActiveCfg = Debug|x64 {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|x64.Build.0 = Debug|x64 + {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|x86.ActiveCfg = Debug|Any CPU + {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Debug|x86.Build.0 = Debug|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|Any CPU.ActiveCfg = Release|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|Any CPU.Build.0 = Release|Any CPU {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|x64.ActiveCfg = Release|x64 {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|x64.Build.0 = Release|x64 - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|x64.ActiveCfg = Debug|Any CPU - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Debug|x64.Build.0 = Debug|Any CPU - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|Any CPU.Build.0 = Release|Any CPU - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|x64.ActiveCfg = Release|x64 - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC}.Release|x64.Build.0 = Release|x64 - {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|x64.ActiveCfg = Debug|Any CPU - {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Debug|x64.Build.0 = Debug|Any CPU - {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|Any CPU.Build.0 = Release|Any CPU - {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|x64.ActiveCfg = Release|x64 - {59672094-7BE6-4CB2-8401-59D59D8AF07A}.Release|x64.Build.0 = Release|x64 + {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|x86.ActiveCfg = Release|Any CPU + {022CD193-2FB4-4507-BAA2-56DB7A40841E}.Release|x86.Build.0 = Release|Any CPU {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|Any CPU.Build.0 = Debug|Any CPU {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|x64.ActiveCfg = Debug|x64 {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|x64.Build.0 = Debug|x64 + {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Debug|x86.Build.0 = Debug|Any CPU {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|Any CPU.Build.0 = Release|Any CPU {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|x64.ActiveCfg = Release|x64 {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|x64.Build.0 = Release|x64 - {0387DAAC-724C-43D1-9C68-D8383F05E909}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0387DAAC-724C-43D1-9C68-D8383F05E909}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0387DAAC-724C-43D1-9C68-D8383F05E909}.Debug|x64.ActiveCfg = Debug|x64 - {0387DAAC-724C-43D1-9C68-D8383F05E909}.Debug|x64.Build.0 = Debug|x64 - {0387DAAC-724C-43D1-9C68-D8383F05E909}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0387DAAC-724C-43D1-9C68-D8383F05E909}.Release|Any CPU.Build.0 = Release|Any CPU - {0387DAAC-724C-43D1-9C68-D8383F05E909}.Release|x64.ActiveCfg = Release|x64 - {0387DAAC-724C-43D1-9C68-D8383F05E909}.Release|x64.Build.0 = Release|x64 + {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|x86.ActiveCfg = Release|Any CPU + {CE82389C-E484-4EAC-8F78-0FB5C6EB63A4}.Release|x86.Build.0 = Release|Any CPU + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Debug|x64.ActiveCfg = Debug|x64 + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Debug|x64.Build.0 = Debug|x64 + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Debug|x86.ActiveCfg = Debug|Any CPU + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Debug|x86.Build.0 = Debug|Any CPU + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Release|Any CPU.Build.0 = Release|Any CPU + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Release|x64.ActiveCfg = Release|x64 + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Release|x64.Build.0 = Release|x64 + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Release|x86.ActiveCfg = Release|Any CPU + {D6ABA32E-D5C0-4E94-8E1C-2E6B2C83519E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {0E1E304A-DEC7-4704-BCE8-65A4DACE00BC} = {A245341F-91C7-4FC3-9EB8-FC6455180427} - {59672094-7BE6-4CB2-8401-59D59D8AF07A} = {A245341F-91C7-4FC3-9EB8-FC6455180427} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {68B75065-924C-42D1-8B6C-A4B5678C2A85} EndGlobalSection diff --git a/yt-dlp.exe b/yt-dlp.exe index 15e3f95..81024dd 100644 Binary files a/yt-dlp.exe and b/yt-dlp.exe differ