using TuneRipper.Shared.Models; using TuneRipper.Api.Configuration; using Microsoft.Extensions.Options; using System.Text.RegularExpressions; using System.Diagnostics; using System.Text; using System.IO.Compression; 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 ISessionService _sessionService; private readonly YtDlpOptions _options; public VideoService( ILogger logger, IHttpClientFactory httpClientFactory, ISessionService sessionService, IOptions options) { _logger = logger; _httpClient = httpClientFactory.CreateClient(); _sessionService = sessionService; _options = options.Value; } 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 = _options.ExecutablePath; if (Path.IsPathRooted(ytDlpPath) && !File.Exists(ytDlpPath)) { throw new FileNotFoundException($"yt-dlp not found at {ytDlpPath}"); } var supportedFormats = new HashSet(StringComparer.OrdinalIgnoreCase) { "mp3", "m4a", "opus", "wav", "flac" }; if (!supportedFormats.Contains(format)) { throw new ArgumentException($"Unsupported audio format: {format}", nameof(format)); } // Ensure session directory exists only when we're about to save the first MP3 file _sessionService.EnsureSessionDirectoryExists(sessionId); // Get session-specific download directory (now guaranteed to exist) var downloadDir = _sessionService.GetSessionDownloadsPath(sessionId); // 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); var processInfo = new ProcessStartInfo { FileName = ytDlpPath, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WorkingDirectory = downloadDir }; processInfo.ArgumentList.Add("--extract-audio"); processInfo.ArgumentList.Add("--audio-format"); processInfo.ArgumentList.Add(format); processInfo.ArgumentList.Add("--audio-quality"); processInfo.ArgumentList.Add("0"); processInfo.ArgumentList.Add("--no-playlist"); processInfo.ArgumentList.Add("--no-warnings"); processInfo.ArgumentList.Add("--newline"); processInfo.ArgumentList.Add("--ffmpeg-location"); processInfo.ArgumentList.Add(_options.FfmpegExecutablePath); processInfo.ArgumentList.Add("--output"); processInfo.ArgumentList.Add(outputTemplate); processInfo.ArgumentList.Add(url); _logger.LogInformation( "Starting yt-dlp for job {JobId} with format {Format}", jobId, format); 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(); using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds(Math.Max(30, _options.ProcessTimeoutSeconds))); try { await process.WaitForExitAsync(timeout.Token); } catch (OperationCanceledException) { if (!process.HasExited) { process.Kill(entireProcessTree: true); } throw new TimeoutException( $"yt-dlp process timed out after {_options.ProcessTimeoutSeconds} seconds"); } 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); // Update the session ZIP file after successful download await UpdateSessionZipAsync(sessionId, downloadDir); 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 async Task UpdateSessionZipAsync(string sessionId, string sessionDir) { try { var zipFileName = $"session_{sessionId}.zip"; var zipFilePath = Path.Combine(sessionDir, zipFileName); // Get all MP3 files in the session directory var mp3Files = Directory.GetFiles(sessionDir, "*.mp3") .Where(f => !f.EndsWith(zipFileName)) // Exclude the ZIP file itself .ToArray(); if (mp3Files.Length == 0) { _logger.LogWarning("No MP3 files found to add to ZIP for session: {SessionId}", sessionId); return; } // Delete existing ZIP file if it exists if (File.Exists(zipFilePath)) { File.Delete(zipFilePath); } // Create new ZIP file with all MP3 files using (var archive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create)) { foreach (var mp3File in mp3Files) { var fileName = Path.GetFileName(mp3File); // Create user-friendly filename for ZIP entry var friendlyFileName = CreateUserFriendlyFileName(fileName); // Add file to ZIP archive.CreateEntryFromFile(mp3File, friendlyFileName); } } _logger.LogInformation("Updated session ZIP file: {ZipFile} with {Count} MP3 files for session: {SessionId}", zipFileName, mp3Files.Length, sessionId); } catch (Exception ex) { _logger.LogError(ex, "Error updating session ZIP file for session: {SessionId}", sessionId); // Don't throw - ZIP creation failure shouldn't break the main process } } private string CreateUserFriendlyFileName(string originalFileName) { try { // Parse our pattern: jobId_title.ext var nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName); var extension = Path.GetExtension(originalFileName); // Split by underscore - first part is job ID, rest is title var underscoreIndex = nameWithoutExt.IndexOf('_'); if (underscoreIndex > 0 && underscoreIndex < nameWithoutExt.Length - 1) { // Extract title part (everything after the first underscore) var titlePart = nameWithoutExt.Substring(underscoreIndex + 1); // Convert underscores back to spaces in title var friendlyTitle = titlePart.Replace("_", " "); // Create a clean filename: "Title.mp3" if (friendlyTitle.Length > 0) { // Remove any duplicate spaces and clean up friendlyTitle = System.Text.RegularExpressions.Regex.Replace(friendlyTitle, @"\s+", " ").Trim(); return $"{friendlyTitle}{extension}"; } } // Fallback to original filename if pattern doesn't match return originalFileName; } catch (Exception) { // If anything goes wrong, return the original filename return originalFileName; } } 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; } }