Adopt GUID-based {jobId}_{title}.ext filename convention for all downloads, ensuring unique, human-readable, and traceable files. Update DownloadController to use precise jobId-based file matching and generate user-friendly download names. Pass jobId to VideoService for consistent naming; add robust title sanitization. Remove fallback file search logic for predictability. Update README and API docs to reflect new format and workflow. Improve logging and error messages for clarity.
371 lines
13 KiB
C#
371 lines
13 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;
|
|
|
|
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, Action<int, string> progressCallback);
|
|
}
|
|
|
|
public class VideoService : IVideoService
|
|
{
|
|
private readonly ILogger<VideoService> _logger;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly IWebHostEnvironment _environment;
|
|
|
|
public VideoService(ILogger<VideoService> logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory)
|
|
{
|
|
_logger = logger;
|
|
_httpClient = httpClientFactory.CreateClient();
|
|
_environment = environment;
|
|
}
|
|
|
|
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, 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}");
|
|
}
|
|
|
|
// Create downloads directory if it doesn't exist
|
|
var downloadDir = Path.Combine(_environment.WebRootPath, "downloads");
|
|
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}", customFileName);
|
|
|
|
// 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
|
|
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} after processing");
|
|
}
|
|
|
|
var downloadedFile = downloadedFiles[0];
|
|
var finalFileName = Path.GetFileName(downloadedFile);
|
|
|
|
_logger.LogInformation("Successfully processed video. Job: {JobId}, File: {FileName}", jobId, finalFileName);
|
|
|
|
progressCallback(100, "Processing completed successfully");
|
|
|
|
return finalFileName;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error processing video with yt-dlp for job {JobId}: {Url}", jobId, 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<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;
|
|
}
|
|
} |