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:
+2
-1
@@ -360,4 +360,5 @@ MigrationBackup/
|
|||||||
.ionide/
|
.ionide/
|
||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
/TuneRipper.Api/wwwroot/downloads
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ namespace TuneRipper.Api.Controllers;
|
|||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class DownloadController : ControllerBase
|
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}")]
|
[HttpGet("{jobId}")]
|
||||||
public IActionResult DownloadFile(string jobId)
|
public IActionResult DownloadFile(string jobId)
|
||||||
{
|
{
|
||||||
@@ -16,19 +25,81 @@ public class DownloadController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// In production, you would:
|
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
|
||||||
// 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";
|
|
||||||
|
|
||||||
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(ex, "Error downloading file for job ID: {JobId}", jobId);
|
||||||
return StatusCode(500, $"Error downloading file: {ex.Message}");
|
return StatusCode(500, $"Error downloading file: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,18 +112,111 @@ public class DownloadController : ControllerBase
|
|||||||
return BadRequest("Job ID is required");
|
return BadRequest("Job ID is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return download metadata
|
try
|
||||||
var info = new
|
|
||||||
{
|
{
|
||||||
jobId,
|
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads");
|
||||||
fileName = $"video_{jobId}.mp3",
|
|
||||||
fileSize = "3.2 MB",
|
if (!Directory.Exists(downloadsDir))
|
||||||
format = "MP3",
|
{
|
||||||
quality = "128 kbps",
|
return NotFound("Downloads directory not found");
|
||||||
createdAt = DateTime.UtcNow,
|
}
|
||||||
expiresAt = DateTime.UtcNow.AddHours(24)
|
|
||||||
};
|
|
||||||
|
|
||||||
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]}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ public class VideoController : ControllerBase
|
|||||||
public DownloadProgress Progress { get; set; } = new();
|
public DownloadProgress Progress { get; set; } = new();
|
||||||
public ProcessVideoResponse? Result { get; set; }
|
public ProcessVideoResponse? Result { get; set; }
|
||||||
public YouTubeVideoDetails? VideoDetails { get; set; } // Add video details to job
|
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;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ public class VideoController : ControllerBase
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Processing video for job {JobId}", jobId);
|
_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.Status = "Processing";
|
||||||
job.Progress.Message = "Fetching video details...";
|
job.Progress.Message = "Fetching video details...";
|
||||||
job.Progress.PercentageComplete = 5;
|
job.Progress.PercentageComplete = 5;
|
||||||
@@ -186,41 +187,48 @@ public class VideoController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogInformation("Job {JobId}: Video details retrieved - Title: {Title}", jobId, videoDetails.Title);
|
_logger.LogInformation("Job {JobId}: Video details retrieved - Title: {Title}", jobId, videoDetails.Title);
|
||||||
|
|
||||||
// Continue with remaining processing steps
|
// Step 2: Process video with yt-dlp
|
||||||
var remainingSteps = new[]
|
job.Progress.Message = "Starting download and conversion...";
|
||||||
{
|
job.Progress.PercentageComplete = 20;
|
||||||
("Analyzing audio stream", 30),
|
|
||||||
("Downloading audio data", 60),
|
var downloadedFileName = await _videoService.ProcessVideoWithProgressAsync(
|
||||||
("Converting to MP3", 85),
|
job.Request.Url!,
|
||||||
("Finalizing", 100)
|
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;
|
throw new Exception("Processing completed but no file was generated");
|
||||||
job.Progress.PercentageComplete = percentage;
|
|
||||||
|
|
||||||
_logger.LogInformation("Job {JobId}: {Message} ({Percentage}%)", jobId, message, percentage);
|
|
||||||
|
|
||||||
// Simulate processing time
|
|
||||||
await Task.Delay(Random.Shared.Next(1000, 3000));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the downloaded file name
|
||||||
|
job.DownloadedFileName = downloadedFileName;
|
||||||
|
|
||||||
// Complete processing
|
// Complete processing
|
||||||
job.Progress.Status = "Completed";
|
job.Progress.Status = "Completed";
|
||||||
job.Progress.Message = "Processing completed successfully";
|
job.Progress.Message = "Processing completed successfully";
|
||||||
job.Progress.PercentageComplete = 100;
|
job.Progress.PercentageComplete = 100;
|
||||||
|
|
||||||
|
// Create download URL for the actual file
|
||||||
|
var downloadUrl = $"/downloads/{Uri.EscapeDataString(downloadedFileName)}";
|
||||||
|
|
||||||
job.Result = new ProcessVideoResponse
|
job.Result = new ProcessVideoResponse
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Video processed successfully",
|
Message = "Video processed successfully",
|
||||||
FileName = $"{videoDetails.Title?.Replace(" ", "_") ?? "video"}.{job.Request.Format ?? "mp3"}",
|
FileName = downloadedFileName,
|
||||||
DownloadUrl = $"/api/download/{jobId}",
|
DownloadUrl = downloadUrl,
|
||||||
VideoDetails = videoDetails
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
|
// Add HttpClient services
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
builder.Services.AddScoped<IVideoService, VideoService>();
|
builder.Services.AddScoped<IVideoService, VideoService>();
|
||||||
|
|
||||||
@@ -32,6 +35,9 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Enable static files serving (for downloading MP3 files)
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
// Enable CORS
|
// Enable CORS
|
||||||
app.UseCors("BlazorPolicy");
|
app.UseCors("BlazorPolicy");
|
||||||
|
|
||||||
@@ -58,6 +64,7 @@ app.MapGet("/", (IWebHostEnvironment env) => new
|
|||||||
check_status = "/api/video/status/{jobId}",
|
check_status = "/api/video/status/{jobId}",
|
||||||
get_result = "/api/video/result/{jobId}",
|
get_result = "/api/video/result/{jobId}",
|
||||||
download = "/api/download/{jobId}",
|
download = "/api/download/{jobId}",
|
||||||
|
static_files = "/downloads/{fileName}",
|
||||||
openapi = "/openapi/v1.json"
|
openapi = "/openapi/v1.json"
|
||||||
},
|
},
|
||||||
testing = new
|
testing = new
|
||||||
@@ -77,5 +84,6 @@ app.Logger.LogInformation("
|
|||||||
app.Logger.LogInformation(" • HTTP: http://localhost:5112");
|
app.Logger.LogInformation(" • HTTP: http://localhost:5112");
|
||||||
app.Logger.LogInformation("?? Health check: /api/health");
|
app.Logger.LogInformation("?? Health check: /api/health");
|
||||||
app.Logger.LogInformation("?? API info: /");
|
app.Logger.LogInformation("?? API info: /");
|
||||||
|
app.Logger.LogInformation("?? Static files served from: /downloads/*");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# TuneRipper API - yt-dlp Integration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The TuneRipper API has been updated to use `yt-dlp.exe` for real YouTube video processing with progress monitoring. Videos are downloaded and converted to MP3 files, stored in the `wwwroot/downloads` directory, and served directly through the API.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
1. **.NET 10** installed
|
||||||
|
2. **yt-dlp.exe** placed in `TuneRipper.Api/wwwroot/yt-dlp.exe`
|
||||||
|
3. **ffmpeg** available in PATH (required by yt-dlp for audio conversion)
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
```
|
||||||
|
TuneRipper.Api/
|
||||||
|
??? wwwroot/
|
||||||
|
? ??? yt-dlp.exe # YouTube downloader executable
|
||||||
|
? ??? downloads/ # Downloaded MP3 files (auto-created)
|
||||||
|
??? Controllers/
|
||||||
|
??? Services/
|
||||||
|
??? Program.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
|
||||||
|
### 1. Program.cs Updates
|
||||||
|
- Added `UseStaticFiles()` middleware for serving downloaded files
|
||||||
|
- Added `AddHttpClient()` for VideoService HTTP requests
|
||||||
|
- Updated logging and endpoint information
|
||||||
|
|
||||||
|
### 2. VideoService Updates
|
||||||
|
- Integrated real `yt-dlp.exe` processing with progress monitoring
|
||||||
|
- Real-time progress parsing from yt-dlp output
|
||||||
|
- File storage in `wwwroot/downloads/` directory
|
||||||
|
- Progress callbacks for UI updates
|
||||||
|
|
||||||
|
### 3. VideoController Updates
|
||||||
|
- Uses actual yt-dlp processing instead of simulation
|
||||||
|
- Stores downloaded file information in job tracking
|
||||||
|
- Returns real download URLs for completed files
|
||||||
|
|
||||||
|
### 4. DownloadController Updates
|
||||||
|
- Serves actual MP3 files from `wwwroot/downloads/`
|
||||||
|
- Proper content type detection
|
||||||
|
- File cleanup capabilities
|
||||||
|
- Download metadata endpoints
|
||||||
|
|
||||||
|
## API Workflow
|
||||||
|
|
||||||
|
### 1. Start Processing
|
||||||
|
```http
|
||||||
|
POST /api/video/process
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
"format": "mp3",
|
||||||
|
"quality": "128"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Processing started",
|
||||||
|
"fileName": "job-id-guid",
|
||||||
|
"downloadUrl": null,
|
||||||
|
"videoDetails": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Monitor Progress
|
||||||
|
```http
|
||||||
|
GET /api/video/status/{jobId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Early Stage):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"percentageComplete": 15,
|
||||||
|
"status": "Processing",
|
||||||
|
"message": "Video details retrieved successfully",
|
||||||
|
"videoDetails": {
|
||||||
|
"id": "dQw4w9WgXcQ",
|
||||||
|
"title": "Rick Astley - Never Gonna Give You Up",
|
||||||
|
"thumbnailUrl": "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Processing):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"percentageComplete": 45,
|
||||||
|
"status": "Processing",
|
||||||
|
"message": "Downloading audio...",
|
||||||
|
"videoDetails": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Get Final Result
|
||||||
|
```http
|
||||||
|
GET /api/video/result/{jobId}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Video processed successfully",
|
||||||
|
"fileName": "dQw4w9WgXcQ_20241212_093022.mp3",
|
||||||
|
"downloadUrl": "/downloads/dQw4w9WgXcQ_20241212_093022.mp3",
|
||||||
|
"videoDetails": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Download File
|
||||||
|
```http
|
||||||
|
GET /api/download/{jobId}
|
||||||
|
```
|
||||||
|
**Response:** Binary MP3 file download
|
||||||
|
|
||||||
|
**Alternative Static File Access:**
|
||||||
|
```http
|
||||||
|
GET /downloads/{fileName}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progress Monitoring
|
||||||
|
|
||||||
|
The VideoService monitors yt-dlp output and translates it to progress updates:
|
||||||
|
|
||||||
|
- **5-15%**: Fetching video details
|
||||||
|
- **15-70%**: Downloading audio (yt-dlp download progress × 0.7)
|
||||||
|
- **70-85%**: Converting to MP3 (ffmpeg processing)
|
||||||
|
- **85-95%**: Finalizing (cleanup)
|
||||||
|
- **100%**: Complete
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Common Errors:
|
||||||
|
1. **yt-dlp.exe not found**: Ensure yt-dlp.exe is in wwwroot directory
|
||||||
|
2. **ffmpeg not available**: Install ffmpeg and add to PATH
|
||||||
|
3. **Invalid YouTube URL**: URL format validation
|
||||||
|
4. **Download timeout**: 5-minute timeout for processing
|
||||||
|
5. **File not found**: Cleanup or missing download directory
|
||||||
|
|
||||||
|
### Error Response Format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Error description",
|
||||||
|
"fileName": null,
|
||||||
|
"downloadUrl": null,
|
||||||
|
"videoDetails": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Management
|
||||||
|
|
||||||
|
### Download Directory
|
||||||
|
- Location: `wwwroot/downloads/`
|
||||||
|
- Naming: `{videoId}_{timestamp}.{ext}`
|
||||||
|
- Auto-cleanup: Implement retention policies as needed
|
||||||
|
|
||||||
|
### File Serving
|
||||||
|
- Static files: Direct access via `/downloads/{fileName}`
|
||||||
|
- API download: Controlled access via `/api/download/{jobId}`
|
||||||
|
- Content-Type: Automatic detection based on file extension
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### appsettings.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"TuneRipper.Api.Services.VideoService": "Debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### yt-dlp Arguments Used:
|
||||||
|
- `--extract-audio`: Extract audio only
|
||||||
|
- `--audio-format mp3`: Convert to MP3
|
||||||
|
- `--audio-quality 0`: Best quality
|
||||||
|
- `--no-playlist`: Single video only
|
||||||
|
- `--no-warnings`: Clean output
|
||||||
|
- `--newline`: Line-by-line progress
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing:
|
||||||
|
1. Start the API: `dotnet run --project TuneRipper.Api`
|
||||||
|
2. Use API testing tools or the Blazor client
|
||||||
|
3. Check logs for yt-dlp progress
|
||||||
|
4. Verify files in `wwwroot/downloads/`
|
||||||
|
|
||||||
|
### Health Check:
|
||||||
|
```http
|
||||||
|
GET /api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job Listing:
|
||||||
|
```http
|
||||||
|
GET /api/video/jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
1. **File Storage**: Consider cloud storage for production
|
||||||
|
2. **Cleanup**: Implement automatic file cleanup/retention
|
||||||
|
3. **Security**: Validate URLs and sanitize filenames
|
||||||
|
4. **Performance**: Consider queue-based processing for high load
|
||||||
|
5. **Monitoring**: Add detailed logging and metrics
|
||||||
|
6. **Updates**: Keep yt-dlp.exe updated for compatibility
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues:
|
||||||
|
1. **Permissions**: Ensure API can write to wwwroot/downloads
|
||||||
|
2. **Antivirus**: yt-dlp.exe may be flagged by antivirus
|
||||||
|
3. **Network**: YouTube may block requests from some IPs
|
||||||
|
4. **Format Support**: Some videos may not support audio extraction
|
||||||
|
|
||||||
|
### Debug Logging:
|
||||||
|
Enable debug logging for VideoService to see detailed yt-dlp output:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"TuneRipper.Api.Services.VideoService": "Debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -3,6 +3,8 @@ using System.Text.RegularExpressions;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace TuneRipper.Api.Services;
|
namespace TuneRipper.Api.Services;
|
||||||
|
|
||||||
@@ -11,17 +13,20 @@ public interface IVideoService
|
|||||||
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
||||||
bool IsValidYouTubeUrl(string url);
|
bool IsValidYouTubeUrl(string url);
|
||||||
string ExtractVideoId(string url);
|
string ExtractVideoId(string url);
|
||||||
|
Task<string?> ProcessVideoWithProgressAsync(string url, string format, Action<int, string> progressCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class VideoService : IVideoService
|
public class VideoService : IVideoService
|
||||||
{
|
{
|
||||||
private readonly ILogger<VideoService> _logger;
|
private readonly ILogger<VideoService> _logger;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
|
||||||
public VideoService(ILogger<VideoService> logger)
|
public VideoService(ILogger<VideoService> logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_httpClient = new HttpClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_environment = environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url)
|
public async Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url)
|
||||||
@@ -85,6 +90,169 @@ public class VideoService : IVideoService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> ProcessVideoWithProgressAsync(string url, string format, 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);
|
||||||
|
|
||||||
|
// Generate safe filename
|
||||||
|
var videoId = ExtractVideoId(url);
|
||||||
|
var fileName = $"{videoId}_{DateTime.Now:yyyyMMdd_HHmmss}.%(ext)s";
|
||||||
|
var outputTemplate = Path.Combine(downloadDir, fileName);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
var downloadedFiles = Directory.GetFiles(downloadDir, $"{videoId}_*.*")
|
||||||
|
.Where(f => !f.EndsWith(".part"))
|
||||||
|
.OrderByDescending(File.GetCreationTime)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (downloadedFiles.Length == 0)
|
||||||
|
{
|
||||||
|
throw new Exception("No downloaded file found after processing");
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadedFile = downloadedFiles[0];
|
||||||
|
var finalFileName = Path.GetFileName(downloadedFile);
|
||||||
|
|
||||||
|
_logger.LogInformation("Successfully processed video. File: {FileName}", finalFileName);
|
||||||
|
|
||||||
|
progressCallback(100, "Processing completed successfully");
|
||||||
|
|
||||||
|
return finalFileName;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing video with yt-dlp: {Url}", url);
|
||||||
|
progressCallback(0, $"Processing failed: {ex.Message}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
public bool IsValidYouTubeUrl(string url)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
|||||||
Binary file not shown.
+30
-22
@@ -123,7 +123,7 @@
|
|||||||
<span class="download-title">@download.Title</span>
|
<span class="download-title">@download.Title</span>
|
||||||
<span class="download-date">@download.CreatedAt.ToString("MMM dd, yyyy HH:mm")</span>
|
<span class="download-date">@download.CreatedAt.ToString("MMM dd, yyyy HH:mm")</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="download-link-btn" @onclick="() => DownloadFile(download.FileName)">
|
<button class="download-link-btn" @onclick="() => DownloadFile(download.DownloadUrl)">
|
||||||
<span class="download-icon">⬇️</span>
|
<span class="download-icon">⬇️</span>
|
||||||
Download MP3
|
Download MP3
|
||||||
</button>
|
</button>
|
||||||
@@ -475,6 +475,7 @@
|
|||||||
{
|
{
|
||||||
public string Title { get; set; } = "";
|
public string Title { get; set; } = "";
|
||||||
public string FileName { get; set; } = "";
|
public string FileName { get; set; } = "";
|
||||||
|
public string DownloadUrl { get; set; } = "";
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +565,7 @@
|
|||||||
var result = await ApiService.GetProcessingResultAsync(jobId);
|
var result = await ApiService.GetProcessingResultAsync(jobId);
|
||||||
if (result?.Success == true)
|
if (result?.Success == true)
|
||||||
{
|
{
|
||||||
await CompleteProcessing(result);
|
await CompleteProcessing(result, jobId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -603,7 +604,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CompleteProcessing(ProcessVideoResponse result)
|
private async Task CompleteProcessing(ProcessVideoResponse result, string jobId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -616,10 +617,24 @@
|
|||||||
// Use the actual video title from API
|
// Use the actual video title from API
|
||||||
string title = currentVideoDetails?.Title ?? result.VideoDetails?.Title ?? ExtractTitleFromUrl(youtubeUrl);
|
string title = currentVideoDetails?.Title ?? result.VideoDetails?.Title ?? ExtractTitleFromUrl(youtubeUrl);
|
||||||
|
|
||||||
|
// Create the download URL - either from the result or construct it from the job ID
|
||||||
|
string downloadUrl;
|
||||||
|
if (!string.IsNullOrEmpty(result.DownloadUrl))
|
||||||
|
{
|
||||||
|
// If the result contains a direct download URL, use it
|
||||||
|
downloadUrl = result.DownloadUrl.StartsWith("http") ? result.DownloadUrl : $"https://localhost:7160{result.DownloadUrl}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fallback: construct download URL from job ID
|
||||||
|
downloadUrl = $"https://localhost:7160/api/download/{jobId}";
|
||||||
|
}
|
||||||
|
|
||||||
downloadLinks.Add(new DownloadItem
|
downloadLinks.Add(new DownloadItem
|
||||||
{
|
{
|
||||||
Title = title,
|
Title = title,
|
||||||
FileName = result.FileName ?? "sample.mp3",
|
FileName = result.FileName ?? "sample.mp3",
|
||||||
|
DownloadUrl = downloadUrl,
|
||||||
CreatedAt = DateTime.Now
|
CreatedAt = DateTime.Now
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -655,9 +670,19 @@
|
|||||||
return $"Audio File {DateTime.Now:HHmmss}";
|
return $"Audio File {DateTime.Now:HHmmss}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DownloadFile(string fileName)
|
private async Task DownloadFile(string downloadUrl)
|
||||||
{
|
{
|
||||||
await JSRuntime.InvokeVoidAsync("downloadFile", fileName);
|
try
|
||||||
|
{
|
||||||
|
// Use the browser to navigate directly to the download URL
|
||||||
|
await JSRuntime.InvokeVoidAsync("open", downloadUrl, "_blank");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error downloading file from URL: {DownloadUrl}", downloadUrl);
|
||||||
|
apiErrorMessage = $"Error downloading file: {ex.Message}";
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleKeyPress(KeyboardEventArgs e)
|
private async Task HandleKeyPress(KeyboardEventArgs e)
|
||||||
@@ -667,21 +692,4 @@
|
|||||||
await DownloadMp3();
|
await DownloadMp3();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (firstRender)
|
|
||||||
{
|
|
||||||
await JSRuntime.InvokeVoidAsync("eval", @"
|
|
||||||
window.downloadFile = function(fileName) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = fileName;
|
|
||||||
link.download = fileName;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
};
|
|
||||||
");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user