Compare commits
8 Commits
a709d4eae3
...
667c1c91a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 667c1c91a1 | |||
| ad5312ba10 | |||
| 724983e96c | |||
| 21113cfe5a | |||
| 5d3d327d02 | |||
| b765543e1c | |||
| 222a30d658 | |||
| c49ec3d5a5 |
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))}";
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
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<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 { }
|
||||
}
|
||||
if (candidates.Any()) section.ImageData = candidates.OrderByDescending(c => c.Score).First().Data;
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
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<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, "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 =>
|
||||
{
|
||||
// Main content page
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
21
VideoStudy.App/AppShell.razor
Normal file
21
VideoStudy.App/AppShell.razor
Normal 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
58
VideoStudy.App/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
20
VideoStudy.App/VideoStudy.App.csproj
Normal file
20
VideoStudy.App/VideoStudy.App.csproj
Normal 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>
|
||||
41
VideoStudy.App/wwwroot/index.html
Normal file
41
VideoStudy.App/wwwroot/index.html
Normal 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>
|
||||
@ -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>
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -1,9 +0,0 @@
|
||||
namespace VideoStudy.Native;
|
||||
|
||||
public partial class MainPage : ContentPage
|
||||
{
|
||||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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();
|
||||
}
|
||||
@ -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>
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
37
VideoStudy.UI/Components/YouTubeProcessor.razor
Normal file
37
VideoStudy.UI/Components/YouTubeProcessor.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
</div>
|
||||
}
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
@if (isProcessing)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
<span>Gerando PDF...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Confirmar e Gerar PDF</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="d-grid mt-4">
|
||||
<button class="btn btn-lg btn-primary" @onclick="StartAnalysis"
|
||||
disabled="@(isProcessing || string.IsNullOrWhiteSpace(videoUrl))">
|
||||
@if (isProcessing)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
<span>Analisando...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Analisar com IA</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
@page "/library"
|
||||
@inject VideoStudy.Shared.Services.PersistenceService PersistenceService
|
||||
@inject PersistenceService PersistenceService
|
||||
@using VideoStudy.Shared
|
||||
@using System.Diagnostics
|
||||
|
||||
|
||||
14
VideoStudy.UI/ServiceCollectionExtensions.cs
Normal file
14
VideoStudy.UI/ServiceCollectionExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
21
VideoStudy.UI/Services/YouTubeService.cs
Normal file
21
VideoStudy.UI/Services/YouTubeService.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
BIN
yt-dlp.exe
BIN
yt-dlp.exe
Binary file not shown.
Loading…
Reference in New Issue
Block a user