using TuneRipper.Shared.Models; using System.Text.RegularExpressions; using System.Net.Http; using System.Threading.Tasks; using System.Text.RegularExpressions; using System.Diagnostics; using System.Text; namespace TuneRipper.Api.Services; public interface IVideoService { Task GetVideoDetailsAsync(string url); bool IsValidYouTubeUrl(string url); string ExtractVideoId(string url); Task ProcessVideoWithProgressAsync(string url, string format, string jobId, string sessionId, Action progressCallback); } public class VideoService : IVideoService { private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly IWebHostEnvironment _environment; private readonly ISessionService _sessionService; public VideoService(ILogger logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory, ISessionService sessionService) { _logger = logger; _httpClient = httpClientFactory.CreateClient(); _environment = environment; _sessionService = sessionService; } public async Task GetVideoDetailsAsync(string url) { try { if (!IsValidYouTubeUrl(url)) { _logger.LogWarning("Invalid YouTube URL: {Url}", url); return null; } var videoId = ExtractVideoId(url); var thumbnailUrl = $"https://img.youtube.com/vi/{videoId}/maxresdefault.jpg"; string? title = null; // Fetch HTML and parse title try { var html = await _httpClient.GetStringAsync(url); // Try OpenGraph title first var ogTitleMatch = Regex.Match(html, " var titleMatch = Regex.Match(html, "(.*?)", RegexOptions.IgnoreCase); if (titleMatch.Success) { title = titleMatch.Groups[1].Value.Replace(" - YouTube", "").Trim(); } } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch or parse video title for {Url}", url); title = $"YouTube Video {videoId}"; } var videoDetails = new YouTubeVideoDetails { Id = videoId, Title = title, ThumbnailUrl = thumbnailUrl }; _logger.LogInformation("Fetched image and title for video {VideoId}", videoId); return videoDetails; } catch (Exception ex) { _logger.LogError(ex, "Error fetching video details for URL: {Url}", url); return null; } } public async Task ProcessVideoWithProgressAsync(string url, string format, string jobId, string sessionId, Action progressCallback) { try { var ytDlpPath = Path.Combine(_environment.WebRootPath, "yt-dlp.exe"); if (!File.Exists(ytDlpPath)) { throw new FileNotFoundException($"yt-dlp.exe not found at {ytDlpPath}"); } // Get session-specific download directory var downloadDir = _sessionService.GetSessionDownloadsPath(sessionId); Directory.CreateDirectory(downloadDir); // Get video details first for better filename var videoDetails = await GetVideoDetailsAsync(url); // Generate filename with format: jobId_title.mp3 var safeTitle = SanitizeFileName(videoDetails?.Title ?? "video"); var customFileName = $"{jobId}_{safeTitle}"; var outputTemplate = Path.Combine(downloadDir, $"{customFileName}.%(ext)s"); _logger.LogInformation("Processing video with filename pattern: {FileName} in session: {SessionId}", customFileName, sessionId); // Build yt-dlp arguments var arguments = new StringBuilder(); arguments.Append($"--extract-audio "); arguments.Append($"--audio-format {format} "); arguments.Append($"--audio-quality 0 "); arguments.Append("--no-playlist "); arguments.Append("--no-warnings "); arguments.Append("--newline "); arguments.Append($"--output \"{outputTemplate}\" "); arguments.Append($"\"{url}\""); _logger.LogInformation("Starting yt-dlp process with arguments: {Args}", arguments.ToString()); var processInfo = new ProcessStartInfo { FileName = ytDlpPath, Arguments = arguments.ToString(), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WorkingDirectory = downloadDir }; using var process = Process.Start(processInfo); if (process == null) { throw new Exception("Failed to start yt-dlp process"); } var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); // Monitor progress process.OutputDataReceived += (sender, e) => { if (!string.IsNullOrEmpty(e.Data)) { outputBuilder.AppendLine(e.Data); _logger.LogDebug("yt-dlp output: {Output}", e.Data); // Parse progress from yt-dlp output ParseProgressFromOutput(e.Data, progressCallback); } }; process.ErrorDataReceived += (sender, e) => { if (!string.IsNullOrEmpty(e.Data)) { errorBuilder.AppendLine(e.Data); _logger.LogWarning("yt-dlp error: {Error}", e.Data); } }; process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Wait for process completion with timeout var completed = await Task.Run(() => process.WaitForExit(300000)); // 5 minute timeout if (!completed) { process.Kill(); throw new TimeoutException("yt-dlp process timed out after 5 minutes"); } if (process.ExitCode != 0) { var error = errorBuilder.ToString(); throw new Exception($"yt-dlp failed with exit code {process.ExitCode}. Error: {error}"); } // Find the downloaded file with our job ID pattern in the session directory var downloadedFiles = Directory.GetFiles(downloadDir, $"{jobId}_*.*") .Where(f => !f.EndsWith(".part")) .OrderByDescending(System.IO.File.GetCreationTime) .ToArray(); if (downloadedFiles.Length == 0) { throw new Exception($"No downloaded file found for job {jobId} in session {sessionId} after processing"); } var downloadedFile = downloadedFiles[0]; var finalFileName = Path.GetFileName(downloadedFile); _logger.LogInformation("Successfully processed video. Job: {JobId}, Session: {SessionId}, File: {FileName}", jobId, sessionId, finalFileName); progressCallback(100, "Processing completed successfully"); return finalFileName; } catch (Exception ex) { _logger.LogError(ex, "Error processing video with yt-dlp for job {JobId} in session {SessionId}: {Url}", jobId, sessionId, url); progressCallback(0, $"Processing failed: {ex.Message}"); throw; } } private string SanitizeFileName(string fileName) { if (string.IsNullOrWhiteSpace(fileName)) { return "video"; } // Remove or replace invalid filename characters var invalidChars = Path.GetInvalidFileNameChars(); var sanitized = new StringBuilder(); foreach (char c in fileName.Take(50)) // Limit length to 50 characters { if (invalidChars.Contains(c)) { // Replace invalid characters with underscores or safe alternatives switch (c) { case '/': case '\\': case '?': case '*': sanitized.Append('_'); break; case ':': case '|': sanitized.Append('-'); break; case '<': sanitized.Append('('); break; case '>': sanitized.Append(')'); break; case '"': sanitized.Append('\''); break; default: sanitized.Append('_'); break; } } else if (char.IsWhiteSpace(c)) { sanitized.Append('_'); } else { sanitized.Append(c); } } var result = sanitized.ToString().Trim('_'); // Ensure we have a valid filename if (string.IsNullOrWhiteSpace(result)) { result = "video"; } return result; } private void ParseProgressFromOutput(string output, Action progressCallback) { try { // Parse different yt-dlp progress patterns if (output.Contains("[download]")) { // Example: "[download] 45.2% of 3.45MiB at 1.23MiB/s ETA 00:02" var percentMatch = Regex.Match(output, @"\[download\]\s+(\d+(?:\.\d+)?)%"); if (percentMatch.Success) { if (double.TryParse(percentMatch.Groups[1].Value, out var percent)) { var progressPercent = (int)Math.Min(percent * 0.7, 70); // Download is 70% of total progress progressCallback(progressPercent, "Downloading audio..."); return; } } if (output.Contains("100%") || output.Contains("has already been downloaded")) { progressCallback(70, "Download complete, converting..."); return; } } else if (output.Contains("[ffmpeg]")) { progressCallback(85, "Converting to MP3..."); return; } else if (output.Contains("Deleting original file")) { progressCallback(95, "Finalizing..."); return; } } catch (Exception ex) { _logger.LogWarning(ex, "Error parsing progress from output: {Output}", output); } } public bool IsValidYouTubeUrl(string url) { if (string.IsNullOrWhiteSpace(url)) return false; var patterns = new[] { @"^https?://(www\.)?youtube\.com/watch\?v=[\w-]+", @"^https?://(www\.)?youtu\.be/[\w-]+", @"^https?://(www\.)?youtube\.com/embed/[\w-]+", @"^https?://(www\.)?youtube\.com/v/[\w-]+" }; return patterns.Any(pattern => Regex.IsMatch(url, pattern, RegexOptions.IgnoreCase)); } public string ExtractVideoId(string url) { if (string.IsNullOrWhiteSpace(url)) return string.Empty; try { var patterns = new Dictionary { { @"youtu\.be/([^?&]+)", "$1" }, { @"youtube\.com/watch\?v=([^&]+)", "$1" }, { @"youtube\.com/embed/([^?&]+)", "$1" }, { @"youtube\.com/v/([^?&]+)", "$1" } }; foreach (var pattern in patterns) { var match = Regex.Match(url, pattern.Key, RegexOptions.IgnoreCase); if (match.Success) { return match.Groups[1].Value; } } } catch (Exception ex) { _logger.LogError(ex, "Error extracting video ID from URL: {Url}", url); } return string.Empty; } }