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
+2 -1
View File
@@ -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
+184 -20
View File
@@ -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]}";
} }
} }
+28 -20
View File
@@ -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)
{ {
+8
View File
@@ -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();
+238
View File
@@ -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"
}
}
}
```
+170 -2
View File
@@ -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
View File
@@ -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);
};
");
}
}
} }