Real YouTube download & MP3 conversion with yt-dlp

- Integrate yt-dlp.exe for real YouTube audio download/conversion
- Serve actual MP3 files from wwwroot/downloads via API and static files
- Report real-time progress from yt-dlp to frontend
- Enhance DownloadController: file serving, metadata, deletion endpoints
- Update VideoController to track and return real file info/URLs
- Enable static file serving in Program.cs; update API info/logging
- Update Blazor frontend to use real download URLs and remove old JS logic
- Add README_UPDATED.md with new workflow and troubleshooting
- Improve error handling, logging, and file cleanup throughout backend
- Update .gitignore for downloads and yt-dlp.exe
This commit is contained in:
Michal Vrba
2025-12-12 09:54:59 +01:00
parent 3a5de85cd4
commit 406cf31b49
8 changed files with 660 additions and 65 deletions
+184 -20
View File
@@ -6,6 +6,15 @@ namespace TuneRipper.Api.Controllers;
[Route("api/[controller]")]
public class DownloadController : ControllerBase
{
private readonly IWebHostEnvironment _environment;
private readonly ILogger<DownloadController> _logger;
public DownloadController(IWebHostEnvironment environment, ILogger<DownloadController> logger)
{
_environment = environment;
_logger = logger;
}
[HttpGet("{jobId}")]
public IActionResult DownloadFile(string jobId)
{
@@ -16,19 +25,81 @@ public class DownloadController : ControllerBase
try
{
// In production, you would:
// 1. Verify the job exists and is completed
// 2. Get the actual file path from storage
// 3. Return the actual file
// For demo purposes, return a sample response
var sampleContent = System.Text.Encoding.UTF8.GetBytes("Sample MP3 file content - this would be actual audio data in production");
var fileName = $"video_{jobId}.mp3";
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
return File(sampleContent, "audio/mpeg", fileName);
if (!Directory.Exists(downloadsDir))
{
_logger.LogWarning("Downloads directory not found: {Path}", downloadsDir);
return NotFound("Downloads directory not found");
}
// Find files related to this job ID
var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*")
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
.OrderByDescending(System.IO.File.GetCreationTime)
.ToArray();
if (jobFiles.Length == 0)
{
// Fallback: look for any audio files that might match the pattern
var audioFiles = Directory.GetFiles(downloadsDir, "*.*")
.Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".webm", StringComparison.OrdinalIgnoreCase))
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
.OrderByDescending(System.IO.File.GetCreationTime)
.ToArray();
if (audioFiles.Length == 0)
{
_logger.LogWarning("No files found for job ID: {JobId}", jobId);
return NotFound("File not found");
}
jobFiles = audioFiles.Take(1).ToArray();
}
var filePath = jobFiles[0];
var fileName = Path.GetFileName(filePath);
if (!System.IO.File.Exists(filePath))
{
_logger.LogWarning("File not found: {FilePath}", filePath);
return NotFound("File not found");
}
_logger.LogInformation("Serving file: {FileName} for job: {JobId}", fileName, jobId);
// Determine content type based on file extension
var contentType = Path.GetExtension(fileName).ToLowerInvariant() switch
{
".mp3" => "audio/mpeg",
".m4a" => "audio/mp4",
".webm" => "audio/webm",
".wav" => "audio/wav",
".ogg" => "audio/ogg",
_ => "application/octet-stream"
};
// Create a safe download filename
var safeFileName = fileName;
if (fileName.Contains("_"))
{
// Try to create a more user-friendly filename
var parts = fileName.Split('_');
if (parts.Length >= 2)
{
var extension = Path.GetExtension(fileName);
safeFileName = $"TuneRipper_{parts[0]}{extension}";
}
}
var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
return File(fileStream, contentType, safeFileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading file for job ID: {JobId}", jobId);
return StatusCode(500, $"Error downloading file: {ex.Message}");
}
}
@@ -41,18 +112,111 @@ public class DownloadController : ControllerBase
return BadRequest("Job ID is required");
}
// Return download metadata
var info = new
try
{
jobId,
fileName = $"video_{jobId}.mp3",
fileSize = "3.2 MB",
format = "MP3",
quality = "128 kbps",
createdAt = DateTime.UtcNow,
expiresAt = DateTime.UtcNow.AddHours(24)
};
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
if (!Directory.Exists(downloadsDir))
{
return NotFound("Downloads directory not found");
}
return Ok(info);
// Find files related to this job ID
var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*")
.Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp"))
.OrderByDescending(System.IO.File.GetCreationTime)
.ToArray();
if (jobFiles.Length == 0)
{
return NotFound("File not found");
}
var filePath = jobFiles[0];
var fileName = Path.GetFileName(filePath);
var fileInfo = new FileInfo(filePath);
// Return download metadata
var info = new
{
jobId,
fileName,
fileSize = FormatFileSize(fileInfo.Length),
fileSizeBytes = fileInfo.Length,
format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
createdAt = fileInfo.CreationTime,
lastModified = fileInfo.LastWriteTime,
downloadUrl = $"/api/download/{jobId}"
};
return Ok(info);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting download info for job ID: {JobId}", jobId);
return StatusCode(500, $"Error getting download info: {ex.Message}");
}
}
[HttpDelete("{jobId}")]
public IActionResult DeleteFile(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId))
{
return BadRequest("Job ID is required");
}
try
{
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
if (!Directory.Exists(downloadsDir))
{
return NotFound("Downloads directory not found");
}
// Find and delete files related to this job ID
var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*");
var deletedCount = 0;
foreach (var filePath in jobFiles)
{
try
{
System.IO.File.Delete(filePath);
deletedCount++;
_logger.LogInformation("Deleted file: {FilePath}", filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete file: {FilePath}", filePath);
}
}
if (deletedCount == 0)
{
return NotFound("No files found to delete");
}
return Ok(new { message = $"Deleted {deletedCount} file(s)", deletedCount });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting files for job ID: {JobId}", jobId);
return StatusCode(500, $"Error deleting files: {ex.Message}");
}
}
private static string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}
+28 -20
View File
@@ -22,6 +22,7 @@ public class VideoController : ControllerBase
public DownloadProgress Progress { get; set; } = new();
public ProcessVideoResponse? Result { get; set; }
public YouTubeVideoDetails? VideoDetails { get; set; } // Add video details to job
public string? DownloadedFileName { get; set; } // Store actual downloaded file name
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -165,7 +166,7 @@ public class VideoController : ControllerBase
{
_logger.LogInformation("Processing video for job {JobId}", jobId);
// Step 1: Fetch video details (NEW FIRST STEP)
// Step 1: Fetch video details
job.Progress.Status = "Processing";
job.Progress.Message = "Fetching video details...";
job.Progress.PercentageComplete = 5;
@@ -186,41 +187,48 @@ public class VideoController : ControllerBase
_logger.LogInformation("Job {JobId}: Video details retrieved - Title: {Title}", jobId, videoDetails.Title);
// Continue with remaining processing steps
var remainingSteps = new[]
{
("Analyzing audio stream", 30),
("Downloading audio data", 60),
("Converting to MP3", 85),
("Finalizing", 100)
};
// Step 2: Process video with yt-dlp
job.Progress.Message = "Starting download and conversion...";
job.Progress.PercentageComplete = 20;
var downloadedFileName = await _videoService.ProcessVideoWithProgressAsync(
job.Request.Url!,
job.Request.Format ?? "mp3",
(percentage, message) =>
{
// Update progress from yt-dlp
job.Progress.PercentageComplete = Math.Max(20, percentage); // Ensure minimum 20%
job.Progress.Message = message;
_logger.LogDebug("Job {JobId}: Progress {Percentage}% - {Message}", jobId, percentage, message);
}
);
foreach (var (message, percentage) in remainingSteps)
if (string.IsNullOrEmpty(downloadedFileName))
{
job.Progress.Message = message;
job.Progress.PercentageComplete = percentage;
_logger.LogInformation("Job {JobId}: {Message} ({Percentage}%)", jobId, message, percentage);
// Simulate processing time
await Task.Delay(Random.Shared.Next(1000, 3000));
throw new Exception("Processing completed but no file was generated");
}
// Store the downloaded file name
job.DownloadedFileName = downloadedFileName;
// Complete processing
job.Progress.Status = "Completed";
job.Progress.Message = "Processing completed successfully";
job.Progress.PercentageComplete = 100;
// Create download URL for the actual file
var downloadUrl = $"/downloads/{Uri.EscapeDataString(downloadedFileName)}";
job.Result = new ProcessVideoResponse
{
Success = true,
Message = "Video processed successfully",
FileName = $"{videoDetails.Title?.Replace(" ", "_") ?? "video"}.{job.Request.Format ?? "mp3"}",
DownloadUrl = $"/api/download/{jobId}",
FileName = downloadedFileName,
DownloadUrl = downloadUrl,
VideoDetails = videoDetails
};
_logger.LogInformation("Successfully completed processing for job {JobId}", jobId);
_logger.LogInformation("Successfully completed processing for job {JobId}. File: {FileName}", jobId, downloadedFileName);
}
catch (Exception ex)
{