Compare commits

..

8 Commits

Author SHA1 Message Date
667c1c91a1 fix: one app. 2026-05-15 21:18:55 -03:00
ad5312ba10 debug: habilita DevTools e captura erros JS no Photino
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:39:06 -03:00
724983e96c fix: corrige inicialização do Photino (App.razor + index.html)
App.razor tinha <!DOCTYPE html> + blazor.web.js que conflitavam com
blazor.photino.js do index.html. App.razor montado dentro de #app
não pode ter wrapper HTML — simplificado para apenas <Routes />.

- App.razor: remove HTML/head/body/blazor.web.js
- index.html: adiciona Bootstrap, traduz para pt-BR, fix #blazor-error-ui
- MainLayout.razor: remove link Semantic Kernel e #blazor-error-ui duplicado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:48:40 -03:00
21113cfe5a fix: copia wwwroot para o diretório de saída do VideoStudy.App
Photino.Blazor busca wwwroot em bin/Debug/net8.0/wwwroot/.
Content Update garante cópia sem conflito com itens implícitos do SDK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:38:31 -03:00
5d3d327d02 chore: remove IPdfSaver de Models.cs (interface morta)
PDF salvo diretamente pelo PersistenceService no Home.razor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:33:43 -03:00
b765543e1c refactor: renomeia VideoStudy.Linux -> VideoStudy.App (cross-platform)
- OutputType WinExe -> Exe (funciona em Windows, Linux e macOS)
- Namespace VideoStudy.Linux -> VideoStudy.App
- Remove IPdfSaver (nunca injetado, PDF salvo pelo PersistenceService)
- Remove Platforms AnyCPU;x64 (desnecessário para Photino cross-platform)
- Simplifica Program.cs removendo código morto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:32:40 -03:00
222a30d658 fix: corrige deadlock no FFmpeg por stderr redirecionado sem leitura
Redirecionar stderr sem consumir o buffer causa deadlock quando FFmpeg
escreve muito (codec info, progress). Removida a redireção — stderr
vai para o processo pai (API console).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:28:03 -03:00
c49ec3d5a5 refactor: limpeza geral e troca de Puppeteer por FFmpeg
- Remove projetos mortos: VideoStudy.Native (MAUI), Controllers/LicenseController
- Remove serviços não usados: FFmpegService, HardwareIdService, LicenseManager,
  PdfGeneratorService, ScreenshotService, TranscriptionService do UI
- Remove dependências pesadas do UI: Whisper.net, YoutubeExplode, QuestPDF
- Remove PuppeteerSharp e SkiaSharp do API (150MB Chromium não é mais necessário)
- Screenshots agora usam FFmpeg diretamente (mais simples, mais confiável)
- YouTubeService reescrito para chamar /api/video-info em vez de YoutubeExplode
- Adiciona campo UserContext em AnalysisRequest (contexto livre do usuário)
- UI traduzida para português; aba de arquivo local removida (nunca funcionou)
- YouTubeProcessor simplificado: sem modo Advanced/Whisper
- GetYtDlpPath busca yt-dlp.exe subindo até 7 níveis de diretório
- Cookies do yt-dlp configuráveis via YtDlp:CookiesBrowser no appsettings
- Chave Groq agora lida de env var GROQ_API_KEY (appsettings.json sem segredos)
- VideoStudy.Linux (Photino) adicionado à solução como host multiplataforma
- yt-dlp atualizado de 2025.01.26 para 2026.03.17 (fix do nsig extraction)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:08:05 -03:00
42 changed files with 696 additions and 1030 deletions

View File

@ -1,7 +1,19 @@
{
"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:*)",
"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:*)"
]
}
}

View File

@ -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<string, List<string>> _activations = new();
[HttpPost("activate")]
public ActionResult<LicenseStatus> 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<string>();
}
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<bool> 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))}";
}
}

View File

@ -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<AnalysisService> _logger;
private readonly IConfiguration _configuration;
public AnalysisService(Kernel kernel, ILogger<AnalysisService> logger)
public AnalysisService(Kernel kernel, ILogger<AnalysisService> 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";
if (dir.Parent == null) break;
dir = dir.Parent;
}
else
return "yt-dlp"; // fallback to PATH
}
private string GetCookiesArg()
{
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;
}
var browser = _configuration["YtDlp:CookiesBrowser"];
return string.IsNullOrWhiteSpace(browser) ? "" : $"--cookies-from-browser {browser}";
}
public async IAsyncEnumerable<AnalysisEvent> 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,11 @@ 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;
string? transcriptReadable = null;
try {
var (t, _) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir);
transcript = t;
(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}";
@ -99,38 +83,37 @@ 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<TutorialSection>? 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!);
var pdfBytes = GeneratePdf(docTitle!, summary!, request.VideoUrl, sections, category!, transcriptReadable);
finalResult = new AnalysisResult {
VideoTitle = videoInfo.Title,
DocumentTitle = docTitle!,
@ -148,7 +131,6 @@ public class AnalysisService
}
}
// LIMPEZA E RESULTADO FINAL
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
if (errorMessage != null) {
@ -158,103 +140,203 @@ public class AnalysisService
}
}
private async Task CaptureScreenshotsInParallelAsync(string videoUrl, List<TutorialSection> sections, TimeSpan videoDuration, CancellationToken ct)
private async Task CaptureScreenshotsAsync(string videoUrl, List<TutorialSection> 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($"<html><body style='margin:0;background:black;'><video id='v' width='1280' height='720' src='{rawUrl}'></video></body></html>");
await page.WaitForSelectorAsync("#v");
if (!TimeSpan.TryParse(section.ImageTimestamp, out var ts) || ts > duration) return;
if (!TimeSpan.TryParse(section.ImageTimestamp, out var ts)) return;
int target = (int)ts.TotalSeconds;
var candidates = new List<(byte[] Data, double Score)>();
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 { }
FileName = "ffmpeg",
Arguments = args,
RedirectStandardError = false,
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);
}
if (candidates.Any()) section.ImageData = candidates.OrderByDescending(c => c.Score).First().Data;
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<VideoInfo> 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<(string flat, string readable)> 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)), "");
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()).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<TutorialSection> sections, string rawJson, string category, string docTitle, string summary)> GenerateTutorialContentAsync(string transcript, VideoInfo video, string inLang, string? outLang, CancellationToken ct)
private string ParseVttToReadable(string vtt)
{
var langMap = new Dictionary<string, string> { {"en", "English"}, {"pt", "Portuguese (Brazilian)"}, {"es", "Spanish"} };
// 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)
{
var langMap = new Dictionary<string, string> { { "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}.";
@ -272,65 +354,127 @@ Escreva tudo em {outName}.";
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var sections = root.GetProperty("sections").EnumerateArray().Select(el => new TutorialSection {
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(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
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<string?> 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();
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 double CalculateImageClarityScore(byte[] bytes)
private byte[] GeneratePdf(string title, string summary, string url, List<TutorialSection> sections, string category, string? transcriptReadable = null)
{
try {
using var img = SKBitmap.Decode(bytes);
if (img == null) return -1;
var lumas = new List<float>();
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; }
}
private byte[] GeneratePdf(string title, string summary, string url, List<TutorialSection> sections, string category)
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 =>
{
var color = category switch { "TUTORIAL" => Colors.Green.Medium, "MEETING" => 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(); });
});
// 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

@ -13,14 +13,6 @@
<PackageReference Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.70.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.2" />
<PackageReference Include="QuestPDF" Version="2023.12.0" />
<PackageReference Include="PuppeteerSharp" Version="20.1.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.7" />
</ItemGroup>
<ItemGroup>
<None Update="Binaries\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>

View File

@ -13,9 +13,12 @@
"Model": "llama3.1"
},
"Groq": {
"ApiKey": "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",
"ApiKey": "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",
"Model": "llama-3.3-70b-versatile",
"BaseUrl": "https://api.groq.com/openai/v1"
}
},
"YtDlp": {
"CookiesBrowser": "firefox"
}
}

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>

58
VideoStudy.App/Program.cs Normal file
View File

@ -0,0 +1,58 @@
using System.Threading;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Photino.NET;
using VideoStudy.UI;
namespace VideoStudy.App;
class Program
{
[STAThread]
static void Main(string[] args)
{
const string appUrl = "http://localhost:5002";
var serverReady = new ManualResetEventSlim(false);
// Start Blazor Server in background thread
var serverThread = new Thread(() =>
{
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();
// Wait for Kestrel to be ready (max 15s)
serverReady.Wait(TimeSpan.FromSeconds(15));
// Open native window pointing to local server
var window = new PhotinoWindow()
.SetTitle("VideoStudy")
.SetSize(1280, 800)
.SetDevToolsEnabled(true)
.Center()
.Load(new Uri(appUrl));
window.WaitForClose();
}
}

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>VideoStudy.App</RootNamespace>
<!-- Cross-platform: Windows, Linux, macOS -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Photino.NET" Version="4.0.16" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VideoStudy.UI\VideoStudy.UI.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,41 @@
<!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" />
</head>
<body>
<div id="app">
<div style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;">
<div class="spinner-border text-primary" role="status"></div>
<div style="margin-top:10px;color:#666;">Carregando VideoStudy...</div>
</div>
</div>
<div id="blazor-error-ui" style="display:none;">
Ocorreu um erro inesperado.
<a href="" class="reload">Recarregar</a>
<a class="dismiss">🗙</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
window.onerror = function(msg, url, line, col, error) {
document.getElementById('app').innerHTML =
'<div style="color:red;padding:2rem;font-family:monospace;">' +
'<b>Erro JS:</b><br>' + msg + '<br>' + url + ':' + line + '</div>';
};
window.addEventListener('unhandledrejection', function(e) {
document.getElementById('app').innerHTML =
'<div style="color:red;padding:2rem;font-family:monospace;">' +
'<b>Promise rejeitada:</b><br>' + (e.reason?.message || e.reason) + '</div>';
});
</script>
<script src="_framework/blazor.photino.js"></script>
</body>
</html>

View File

@ -1,7 +0,0 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="VideoStudy.Native.App">
<Application.Resources>
</Application.Resources>
</Application>

View File

@ -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());
}
}

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
xmlns:ui="clr-namespace:VideoStudy.UI;assembly=VideoStudy.UI"
x:Class="VideoStudy.Native.MainPage"
BackgroundColor="White">
<blazor:BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent Selector="#app" ComponentType="{x:Type ui:Routes}" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
</ContentPage>

View File

@ -1,9 +0,0 @@
namespace VideoStudy.Native;
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
}

View File

@ -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<App>();
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<IPdfSaver, WindowsPdfSaver>();
// Persistence Service (LiteDB)
builder.Services.AddScoped<VideoStudy.Shared.Services.PersistenceService>();
return builder.Build();
}
}

View File

@ -1,7 +0,0 @@
<maui:MauiWinUIApplication
x:Class="VideoStudy.Native.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:VideoStudy.Native.WinUI">
</maui:MauiWinUIApplication>

View File

@ -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();
}

View File

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="com.companyname.videostudy"
Publisher="CN=Ricardo"
Version="1.0.0.0" />
<Properties>
<DisplayName>VideoStudy</DisplayName>
<PublisherDisplayName>Ricardo</PublisherDisplayName>
<Logo>Resources\AppIcon\appicon.svg</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="VideoStudy"
Description="VideoStudy"
BackgroundColor="transparent"
Square150x150Logo="Resources\AppIcon\appicon.svg"
Square44x44Logo="Resources\AppIcon\appicon.svg">
<uap:DefaultTile Wide310x150Logo="Resources\AppIcon\appicon.svg" />
<uap:SplashScreen Image="Resources\Splash\splash.svg" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<Capability Name="internetClient" />
</Capabilities>
</Package>

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<circle cx="64" cy="64" r="60" fill="#512BD4"/>
</svg>

Before

Width:  |  Height:  |  Size: 200 B

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="40" fill="white">VS</text>
</svg>

Before

Width:  |  Height:  |  Size: 280 B

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="168" height="208" viewBox="0 0 168 208" xmlns="http://www.w3.org/2000/svg">
<rect width="168" height="208" fill="#512BD4"/>
</svg>

Before

Width:  |  Height:  |  Size: 200 B

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" fill="#512BD4"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="20" fill="white">VideoStudy</text>
</svg>

Before

Width:  |  Height:  |  Size: 338 B

View File

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">#FFFFFF</Color>
<Color x:Key="Black">#000000</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
</ResourceDictionary>

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{StaticResource Primary}" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{StaticResource White}" />
<Setter Property="BackgroundColor" Value="{StaticResource Primary}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="14,10" />
</Style>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{StaticResource Black}" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style TargetType="Page">
<Setter Property="BackgroundColor" Value="{StaticResource White}" />
</Style>
</ResourceDictionary>

View File

@ -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<string?> 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;
}
}

View File

@ -1,39 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<OutputType>WinExe</OutputType>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationTitle>VideoStudy</ApplicationTitle>
<ApplicationId>com.companyname.videostudy</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<WindowsPackageType>None</WindowsPackageType>
</PropertyGroup>
<ItemGroup>
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<MauiImage Include="Resources\Images\*" />
<MauiFont Include="Resources\Fonts\*" />
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.31" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.31" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.31" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VideoStudy.UI\VideoStudy.UI.csproj" />
<ProjectReference Include="..\VideoStudy.Shared\VideoStudy.Shared.csproj" />
</ItemGroup>
</Project>

View File

@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>VideoStudy.Native</title>
<base href="/" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="_content/VideoStudy.UI/app.css" />
</head>
<body>
<div id="app">
<div style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);">
<div class="spinner-border text-primary" role="status"></div>
<div style="margin-top:10px;">Carregando VideoStudy...</div>
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>

View File

@ -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; }
}
/// <summary>
@ -101,10 +102,6 @@ public class DownloadProgress
public string Status { get; set; } = string.Empty;
}
public interface IPdfSaver
{
Task<string?> SavePdfAsync(byte[] pdfData, string suggestedFileName);
}
/// <summary>
/// A single event in the analysis stream.

View File

@ -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();
}
}

View File

@ -1,26 +1 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<!-- Bootstrap 5.3.2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="VideoStudy.Desktop.styles.css" />
<HeadOutlet />
</head>
<body>
<Routes />
<!-- Bootstrap 5.3.2 JS -->
<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>
<Routes />

View File

@ -0,0 +1,37 @@
@using VideoStudy.Shared
<div class="processor-card p-2">
<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" @bind:event="oninput"
@onchange="OnUrlChanged"
disabled="@IsProcessing" />
</div>
@if (VideoInfo != null)
{
<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>
@code {
[Parameter] public EventCallback<string> OnVideoUrlChanged { get; set; }
[Parameter] public bool IsProcessing { get; set; }
[Parameter] public VideoInfo? VideoInfo { get; set; }
private string VideoUrl { get; set; } = "";
private async Task OnUrlChanged(ChangeEventArgs e)
{
VideoUrl = e.Value?.ToString() ?? "";
await OnVideoUrlChanged.InvokeAsync(VideoUrl);
}
}

View File

@ -6,22 +6,12 @@
</div>
<main>
<div class="top-row px-4">
<a href="https://github.com/microsoft/semantic-kernel" target="_blank">About Semantic Kernel</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<style>
.page {
position: relative;
@ -29,84 +19,15 @@
flex-direction: column;
}
main {
flex: 1;
}
main { flex: 1; }
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.sidebar .top-row {
background-color: rgba(0,0,0,0.25);
border-bottom: none;
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@@media (max-width: 640.98px) {
.top-row:not(.auth) {
display: none;
}
.top-row.auth {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
.page { flex-direction: row; }
.sidebar { width: 250px; height: 100vh; position: sticky; top: 0; }
article { padding-left: 2rem !important; padding-right: 1.5rem !important; }
}
</style>

View File

@ -1,158 +1,81 @@
@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
<PageTitle>VideoStudy - Video Analysis</PageTitle>
<PageTitle>VideoStudy</PageTitle>
<div class="container-fluid py-4">
<!-- Header -->
<header class="text-center mb-5">
<h1 class="display-4 fw-bold" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">
VideoStudy (Native)
VideoStudy
</h1>
<p class="lead text-muted">Transforme videos em apostilas inteligentes com IA</p>
<p class="lead text-muted">Transforme vídeos em guias de estudo com IA</p>
</header>
<div class="row">
<div class="col-lg-8 mx-auto">
<!-- Tabs Navigation -->
<ul class="nav nav-pills nav-fill mb-4 shadow-sm p-1 bg-light rounded-pill">
<li class="nav-item">
<button class="nav-link rounded-pill py-3 fw-bold @(activeTab == "youtube" ? "active bg-primary text-white" : "text-muted")"
@onclick="@(() => activeTab = "youtube")" disabled="@(isProcessing || isFetchingInfo)">
<span style="font-size:1.1rem;">YouTube Video</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link rounded-pill py-3 fw-bold text-muted" disabled>
Local Video (Coming Soon)
</button>
</li>
</ul>
<div class="card shadow-sm border-0 rounded-4 overflow-hidden mb-4 p-4 bg-white">
<YouTubeProcessor
OnVideoUrlChanged="HandleVideoUrlChanged"
IsProcessing="@isProcessing"
VideoInfo="@currentVideoInfo" />
<!-- Tab Content -->
<div class="card shadow-soft border-0 rounded-4 overflow-hidden mb-4 p-4 bg-white">
@if (activeTab == "youtube")
{
<div class="mb-4">
<label class="form-label fw-bold text-muted small">YouTube URL</label>
<div class="input-group mb-3">
<input type="text" class="form-control form-control-lg" placeholder="https://www.youtube.com/watch?v=..."
@bind="videoUrl" disabled="@(isProcessing || isFetchingInfo)" />
<div class="mt-3">
<label class="form-label fw-bold text-muted small">
Contexto <span class="fw-normal">(opcional)</span>
</label>
<textarea class="form-control" rows="2"
placeholder="Ex: tutorial técnico de Python para iniciantes / aula escolar sobre fotossíntese / passo a passo para montar um móvel"
@bind="userContext" disabled="@isProcessing"></textarea>
</div>
</div>
}
<!-- Video Info Preview -->
@if (videoInfo != null)
{
<div class="border rounded-3 p-3 mb-4 d-flex align-items-start gap-3" style="background: #f0f4ff;">
@if (!string.IsNullOrEmpty(videoInfo.ThumbnailUrl))
{
<img src="@videoInfo.ThumbnailUrl" alt="Thumbnail"
style="width: 180px; border-radius: 8px; flex-shrink: 0;" />
}
<div class="flex-grow-1">
<h6 class="fw-bold mb-1">@videoInfo.Title</h6>
<div class="text-muted small">
<span class="me-3">@videoInfo.Author</span>
<span>@videoInfo.Duration.ToString(@"hh\:mm\:ss")</span>
</div>
</div>
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearVideoInfo"
disabled="@(isProcessing)" title="Limpar">X</button>
</div>
}
<!-- Language Selectors -->
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-bold text-muted small">Idioma do Video (Legendas)</label>
<select class="form-select" @bind="selectedLanguage" disabled="@(isProcessing || isFetchingInfo)">
<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">
<option value="pt">Português (BR)</option>
<option value="en">English</option>
<option value="pt">Portuguese (BR)</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-bold text-muted small">Idioma de Saida (Tutorial)</label>
<select class="form-select" @bind="selectedOutputLanguage" disabled="@(isProcessing || isFetchingInfo)">
<option value="pt">Portuguese (BR)</option>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="es">Español</option>
<option value="fr">Français</option>
</select>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-4">
@if (videoInfo == null)
{
<!-- Step 1: Fetch video info -->
<button class="btn btn-lg btn-primary w-100 py-3 fw-bold shadow-sm"
@onclick="FetchVideoInfo"
disabled="@(isProcessing || isFetchingInfo || string.IsNullOrWhiteSpace(videoUrl))">
@if (isFetchingInfo)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Verificando video...</span>
}
else
{
<span>Generate Tutorial PDF</span>
}
</button>
}
else
{
<!-- Step 2: Confirm and generate -->
<div class="d-flex gap-2">
<button class="btn btn-lg btn-success flex-grow-1 py-3 fw-bold shadow-sm"
@onclick="StartAnalysis"
disabled="@isProcessing">
<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>Gerando PDF...</span>
<span>Analisando...</span>
}
else
{
<span>Confirmar e Gerar PDF</span>
<span>Analisar com IA</span>
}
</button>
</div>
}
</div>
</div>
<!-- Progress Indicator -->
@if (isProcessing || currentStep > 0)
{
<div class="progress mb-3" style="height: 25px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: @(progress)%">
@statusMessage
</div>
</div>
<ProgressIndicator Status="@statusMessage" Percent="@progress" CurrentStepIndex="@currentStep" />
}
<!-- Logs (Collapsible) -->
@if (logs.Count > 0)
{
<div class="card shadow-sm mb-4 border-0 rounded-4 overflow-hidden">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center" style="cursor: pointer;" @onclick="ToggleLogs">
<small class="fw-bold">Execution Logs</small>
<small>@logs.Count items @(showLogs ? "v" : ">")</small>
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"
style="cursor: pointer;" @onclick="ToggleLogs">
<small class="fw-bold">Log de execução</small>
<small>@logs.Count eventos @(showLogs ? "▼" : "▶")</small>
</div>
@if (showLogs)
{
@ -160,7 +83,7 @@
@foreach (var log in logs)
{
<div class="text-dark border-bottom py-1">
<span class="text-muted">[@log.Timestamp:HH:mm:ss]</span> @log.Message
<span class="text-muted">[@log.Timestamp.ToString("HH:mm:ss")]</span> @log.Message
</div>
}
</div>
@ -168,33 +91,28 @@
</div>
}
@if (!string.IsNullOrEmpty(generatedPdfPath))
{
<PdfPreview PdfPath="@generatedPdfPath" OnCancel="@(() => generatedPdfPath = null)" />
}
</div>
</div>
</div>
<style>
.nav-pills .nav-link.active {
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.3);
}
.shadow-soft {
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
</style>
@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<LogEntry> 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,46 +125,15 @@
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 void HandleVideoUrlChanged(string url)
{
videoInfo = null;
}
private async Task FetchVideoInfo()
{
if (string.IsNullOrWhiteSpace(videoUrl)) return;
isFetchingInfo = true;
showLogs = true;
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<VideoInfo>(
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (videoInfo != null)
AddLog($"Video encontrado: {videoInfo.Title} ({videoInfo.Duration:hh\\:mm\\:ss})");
}
catch (Exception ex)
{
AddLog($"Erro: {ex.Message}");
}
finally
{
isFetchingInfo = false;
}
videoUrl = url;
currentVideoInfo = null;
}
private async Task StartAnalysis()
@ -254,6 +141,7 @@
isProcessing = true;
progress = 0;
currentStep = 0;
generatedPdfPath = null;
logs.Clear();
showLogs = true;
@ -261,90 +149,86 @@
try
{
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 requisição para a API...", 5);
AddLog("Enviando para o servidor...", 8);
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);
using var response = await Http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
response.EnsureSuccessStatusCode();
if (!response.IsSuccessStatusCode)
var stream = response.Content.ReadFromJsonAsAsyncEnumerable<AnalysisEvent>(
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<AnalysisEvent>(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();
}
}

View File

@ -1,5 +1,5 @@
@page "/library"
@inject VideoStudy.Shared.Services.PersistenceService PersistenceService
@inject PersistenceService PersistenceService
@using VideoStudy.Shared
@using System.Diagnostics

View File

@ -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<YouTubeService>();
services.AddScoped<PersistenceService>();
return services;
}
}

View File

@ -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;
/// <summary>
/// Retorna as subpastas físicas dentro do diretório base.
/// </summary>
public List<string> GetFolders()
{
if (!Directory.Exists(_basePath)) return new List<string>();
return Directory.GetDirectories(_basePath)
.Select(Path.GetFileName)
.Where(name => name != null)
.ToList()!;
}
/// <summary>
/// Cria uma nova pasta física.
/// </summary>
public void CreateFolder(string folderName)
{
var path = Path.Combine(_basePath, folderName);
@ -48,27 +38,30 @@ public class PersistenceService
}
}
/// <summary>
/// Salva o PDF no disco e registra no LiteDB.
/// </summary>
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<VideoStudySession> 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<VideoStudySession>("sessions");
@ -87,9 +80,6 @@ public class PersistenceService
return session;
}
/// <summary>
/// Recupera todas as sessões do banco.
/// </summary>
public List<VideoStudySession> GetAllSessions()
{
using var db = new LiteDatabase(_dbPath);
@ -98,4 +88,13 @@ public class PersistenceService
.OrderByDescending(x => x.CreatedAt)
.ToList();
}
public List<VideoStudySession> GetSessionsByFolder(string folderName)
{
using var db = new LiteDatabase(_dbPath);
return db.GetCollection<VideoStudySession>("sessions")
.Find(x => x.FolderName == folderName)
.OrderByDescending(x => x.CreatedAt)
.ToList();
}
}

View File

@ -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<VideoInfo> GetVideoInfoAsync(string url)
{
var encodedUrl = Uri.EscapeDataString(url);
var info = await _httpClient.GetFromJsonAsync<VideoInfo>($"api/video-info?url={encodedUrl}");
return info ?? throw new Exception("Não foi possível obter informações do vídeo.");
}
}

View File

@ -1,13 +1,12 @@
<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>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
@ -16,12 +15,17 @@
<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>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.2" />
<PackageReference Include="LiteDB" Version="5.0.21" />
</ItemGroup>
<ItemGroup>

View File

@ -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

View File

@ -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.App", "VideoStudy.App\VideoStudy.App.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

Binary file not shown.