Session directories are now created only when the first MP3 file is saved, not at session creation. Added EnsureSessionDirectoryExists to ISessionService and SessionService to handle this logic. Updated VideoService to call this method before saving files. Cleanup now logs when no directory exists for expired sessions.
466 lines
17 KiB
C#
466 lines
17 KiB
C#
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;
|
|
using System.IO.Compression;
|
|
|
|
namespace TuneRipper.Api.Services;
|
|
|
|
public interface IVideoService
|
|
{
|
|
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
|
bool IsValidYouTubeUrl(string url);
|
|
string ExtractVideoId(string url);
|
|
Task<string?> ProcessVideoWithProgressAsync(string url, string format, string jobId, string sessionId, Action<int, string> progressCallback);
|
|
}
|
|
|
|
public class VideoService : IVideoService
|
|
{
|
|
private readonly ILogger<VideoService> _logger;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly IWebHostEnvironment _environment;
|
|
private readonly ISessionService _sessionService;
|
|
|
|
public VideoService(ILogger<VideoService> logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory, ISessionService sessionService)
|
|
{
|
|
_logger = logger;
|
|
_httpClient = httpClientFactory.CreateClient();
|
|
_environment = environment;
|
|
_sessionService = sessionService;
|
|
}
|
|
|
|
public async Task<YouTubeVideoDetails?> 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, "<meta property='og:title' content='(.*?)'", RegexOptions.IgnoreCase);
|
|
if (!ogTitleMatch.Success)
|
|
{
|
|
ogTitleMatch = Regex.Match(html, "<meta property=\"og:title\" content=\"(.*?)\"", RegexOptions.IgnoreCase);
|
|
}
|
|
if (ogTitleMatch.Success)
|
|
{
|
|
title = ogTitleMatch.Groups[1].Value;
|
|
}
|
|
else
|
|
{
|
|
// Fallback to <title>
|
|
var titleMatch = Regex.Match(html, "<title>(.*?)</title>", 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<string?> ProcessVideoWithProgressAsync(string url, string format, string jobId, string sessionId, Action<int, string> 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}");
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
// 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<int, string> 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<string, string>
|
|
{
|
|
{ @"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;
|
|
}
|
|
} |