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:
@@ -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]}";
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,9 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// Add HttpClient services
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Register services
|
||||
builder.Services.AddScoped<IVideoService, VideoService>();
|
||||
|
||||
@@ -32,6 +35,9 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Enable static files serving (for downloading MP3 files)
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Enable CORS
|
||||
app.UseCors("BlazorPolicy");
|
||||
|
||||
@@ -58,6 +64,7 @@ app.MapGet("/", (IWebHostEnvironment env) => new
|
||||
check_status = "/api/video/status/{jobId}",
|
||||
get_result = "/api/video/result/{jobId}",
|
||||
download = "/api/download/{jobId}",
|
||||
static_files = "/downloads/{fileName}",
|
||||
openapi = "/openapi/v1.json"
|
||||
},
|
||||
testing = new
|
||||
@@ -77,5 +84,6 @@ app.Logger.LogInformation("
|
||||
app.Logger.LogInformation(" • HTTP: http://localhost:5112");
|
||||
app.Logger.LogInformation("?? Health check: /api/health");
|
||||
app.Logger.LogInformation("?? API info: /");
|
||||
app.Logger.LogInformation("?? Static files served from: /downloads/*");
|
||||
|
||||
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.Threading.Tasks;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace TuneRipper.Api.Services;
|
||||
|
||||
@@ -11,17 +13,20 @@ public interface IVideoService
|
||||
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
||||
bool IsValidYouTubeUrl(string url);
|
||||
string ExtractVideoId(string url);
|
||||
Task<string?> ProcessVideoWithProgressAsync(string url, string format, 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)
|
||||
public VideoService(ILogger<VideoService> logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user