Files
TuneRipper/TuneRipper.Api/Services/VideoService.cs
T
Michal Vrba 879fbd4a53 Lazily create session directories on first MP3 save
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.
2025-12-14 16:39:00 +01:00

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;
}
}