Streamline video conversion workflow & UI integration
Removed /api/video/details endpoint; video details now fetched as first step of processing and included in progress/status responses. Updated backend models and controller logic to support integrated workflow and improved error handling. Refactored frontend to show video info and conversion progress in real time, removed legacy API calls, and enhanced UI/UX with responsive video info cards and progress bars. Updated documentation and example code to reflect new workflow.
This commit is contained in:
@@ -21,6 +21,7 @@ public class VideoController : ControllerBase
|
||||
public ProcessVideoRequest Request { get; set; } = new();
|
||||
public DownloadProgress Progress { get; set; } = new();
|
||||
public ProcessVideoResponse? Result { get; set; }
|
||||
public YouTubeVideoDetails? VideoDetails { get; set; } // Add video details to job
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -30,39 +31,6 @@ public class VideoController : ControllerBase
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("details")]
|
||||
public async Task<ActionResult<YouTubeVideoDetails>> GetVideoDetails([FromQuery] string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return BadRequest("URL is required");
|
||||
}
|
||||
|
||||
if (!_videoService.IsValidYouTubeUrl(url))
|
||||
{
|
||||
return BadRequest("Invalid YouTube URL");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Fetching video details for URL: {Url}", url);
|
||||
|
||||
var videoDetails = await _videoService.GetVideoDetailsAsync(url);
|
||||
|
||||
if (videoDetails == null)
|
||||
{
|
||||
return NotFound("Video not found or unavailable");
|
||||
}
|
||||
|
||||
return Ok(videoDetails);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching video details for URL: {Url}", url);
|
||||
return StatusCode(500, $"Error fetching video details: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("process")]
|
||||
public async Task<ActionResult<ProcessVideoResponse>> StartProcessing([FromBody] ProcessVideoRequest request)
|
||||
{
|
||||
@@ -132,7 +100,22 @@ public class VideoController : ControllerBase
|
||||
return NotFound("Job not found");
|
||||
}
|
||||
|
||||
return Ok(job.Progress);
|
||||
// Return progress with video details if available
|
||||
var progress = job.Progress;
|
||||
|
||||
// If we have video details and we're past the first step, include them in a custom response
|
||||
if (job.VideoDetails != null && job.Progress.PercentageComplete >= 15)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
PercentageComplete = progress.PercentageComplete,
|
||||
Status = progress.Status,
|
||||
Message = progress.Message,
|
||||
VideoDetails = job.VideoDetails
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(progress);
|
||||
}
|
||||
|
||||
[HttpGet("result/{jobId}")]
|
||||
@@ -182,19 +165,38 @@ public class VideoController : ControllerBase
|
||||
{
|
||||
_logger.LogInformation("Processing video for job {JobId}", jobId);
|
||||
|
||||
// Simulate video processing steps
|
||||
var steps = new[]
|
||||
// Step 1: Fetch video details (NEW FIRST STEP)
|
||||
job.Progress.Status = "Processing";
|
||||
job.Progress.Message = "Fetching video details...";
|
||||
job.Progress.PercentageComplete = 5;
|
||||
|
||||
_logger.LogInformation("Job {JobId}: Fetching video details", jobId);
|
||||
|
||||
var videoDetails = await _videoService.GetVideoDetailsAsync(job.Request.Url!);
|
||||
|
||||
if (videoDetails == null)
|
||||
{
|
||||
("Fetching video information", 10),
|
||||
("Analyzing audio stream", 25),
|
||||
throw new Exception("Unable to fetch video details. Invalid URL or video unavailable.");
|
||||
}
|
||||
|
||||
// Store video details in the job
|
||||
job.VideoDetails = videoDetails;
|
||||
job.Progress.Message = "Video details retrieved successfully";
|
||||
job.Progress.PercentageComplete = 15;
|
||||
|
||||
_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)
|
||||
};
|
||||
|
||||
foreach (var (message, percentage) in steps)
|
||||
foreach (var (message, percentage) in remainingSteps)
|
||||
{
|
||||
job.Progress.Status = "Processing";
|
||||
job.Progress.Message = message;
|
||||
job.Progress.PercentageComplete = percentage;
|
||||
|
||||
@@ -204,9 +206,6 @@ public class VideoController : ControllerBase
|
||||
await Task.Delay(Random.Shared.Next(1000, 3000));
|
||||
}
|
||||
|
||||
// Get video details using the service
|
||||
var videoDetails = await _videoService.GetVideoDetailsAsync(job.Request.Url!);
|
||||
|
||||
// Complete processing
|
||||
job.Progress.Status = "Completed";
|
||||
job.Progress.Message = "Processing completed successfully";
|
||||
@@ -216,7 +215,7 @@ public class VideoController : ControllerBase
|
||||
{
|
||||
Success = true,
|
||||
Message = "Video processed successfully",
|
||||
FileName = $"{videoDetails?.Title?.Replace(" ", "_") ?? "video"}.{job.Request.Format ?? "mp3"}",
|
||||
FileName = $"{videoDetails.Title?.Replace(" ", "_") ?? "video"}.{job.Request.Format ?? "mp3"}",
|
||||
DownloadUrl = $"/api/download/{jobId}",
|
||||
VideoDetails = videoDetails
|
||||
};
|
||||
@@ -237,7 +236,7 @@ public class VideoController : ControllerBase
|
||||
Message = ex.Message,
|
||||
FileName = null,
|
||||
DownloadUrl = null,
|
||||
VideoDetails = null
|
||||
VideoDetails = job.VideoDetails // Include video details even on failure if we got them
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,5 @@ public class DownloadProgress
|
||||
public int PercentageComplete { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public YouTubeVideoDetails? VideoDetails { get; set; } // Add optional video details
|
||||
}
|
||||
@@ -8,26 +8,18 @@ In any Razor component, inject the API service:
|
||||
@inject ITuneRipperApiService ApiService
|
||||
```
|
||||
|
||||
## Updated API Workflow
|
||||
|
||||
The API has been streamlined - video details are now fetched as the first step of processing, eliminating the need for a separate details endpoint.
|
||||
|
||||
### New Workflow:
|
||||
1. **Start Processing** - Includes video details fetching as first step
|
||||
2. **Monitor Status** - Video details appear when progress reaches ~15%
|
||||
3. **Get Final Result** - Complete processing result with download URL
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Get Video Details
|
||||
|
||||
```csharp
|
||||
private async Task GetVideoDetailsExample()
|
||||
{
|
||||
var url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
var videoDetails = await ApiService.GetVideoDetailsAsync(url);
|
||||
|
||||
if (videoDetails != null)
|
||||
{
|
||||
Console.WriteLine($"Title: {videoDetails.Title}");
|
||||
Console.WriteLine($"Duration: {videoDetails.Duration}");
|
||||
Console.WriteLine($"Author: {videoDetails.Author}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Start Video Processing
|
||||
### 1. Start Video Processing (includes video details fetching)
|
||||
|
||||
```csharp
|
||||
private async Task StartProcessingExample()
|
||||
@@ -44,37 +36,138 @@ private async Task StartProcessingExample()
|
||||
if (response?.Success == true)
|
||||
{
|
||||
var jobId = response.FileName; // This contains the job ID
|
||||
await MonitorProcessing(jobId);
|
||||
await MonitorProcessingWithVideoDetails(jobId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Monitor Processing Status
|
||||
### 2. Monitor Processing Status (includes video details when available)
|
||||
|
||||
```csharp
|
||||
private async Task MonitorProcessing(string jobId)
|
||||
private async Task MonitorProcessingWithVideoDetails(string jobId)
|
||||
{
|
||||
YouTubeVideoDetails? videoDetails = null;
|
||||
bool detailsReceived = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var status = await ApiService.GetProcessingStatusAsync(jobId);
|
||||
|
||||
if (status == null) break;
|
||||
|
||||
Console.WriteLine($"Status: {status.Status} - {status.PercentageComplete}%");
|
||||
Console.WriteLine($"Progress: {status.PercentageComplete}% - {status.Message}");
|
||||
|
||||
// Video details become available at ~15% progress
|
||||
if (!detailsReceived && status.VideoDetails != null)
|
||||
{
|
||||
videoDetails = status.VideoDetails;
|
||||
detailsReceived = true;
|
||||
|
||||
Console.WriteLine($"Video Details Received:");
|
||||
Console.WriteLine($" Title: {videoDetails.Title}");
|
||||
Console.WriteLine($" Author: {videoDetails.Author}");
|
||||
Console.WriteLine($" Duration: {videoDetails.Duration}");
|
||||
}
|
||||
|
||||
if (status.Status == "Completed" || status.Status == "Failed")
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(2000); // Wait 2 seconds before checking again
|
||||
await Task.Delay(2000); // Poll every 2 seconds
|
||||
}
|
||||
|
||||
// Get final result
|
||||
var result = await ApiService.GetProcessingResultAsync(jobId);
|
||||
if (result?.Success == true)
|
||||
{
|
||||
Console.WriteLine($"Download URL: {result.DownloadUrl}");
|
||||
Console.WriteLine($"Processing completed! Download: {result.DownloadUrl}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Complete Processing Workflow
|
||||
|
||||
```csharp
|
||||
private async Task CompleteWorkflowExample()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Step 1: Start processing
|
||||
var request = new ProcessVideoRequest
|
||||
{
|
||||
Url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
Format = "mp3",
|
||||
Quality = "128"
|
||||
};
|
||||
|
||||
var startResponse = await ApiService.StartProcessingAsync(request);
|
||||
if (startResponse?.Success != true)
|
||||
{
|
||||
Console.WriteLine($"Failed to start processing: {startResponse?.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
var jobId = startResponse.FileName;
|
||||
Console.WriteLine($"Processing started with job ID: {jobId}");
|
||||
|
||||
// Step 2: Monitor progress and get video details
|
||||
YouTubeVideoDetails? videoDetails = null;
|
||||
bool processingComplete = false;
|
||||
|
||||
while (!processingComplete)
|
||||
{
|
||||
var status = await ApiService.GetProcessingStatusAsync(jobId);
|
||||
|
||||
if (status == null)
|
||||
{
|
||||
Console.WriteLine("Failed to get status");
|
||||
break;
|
||||
}
|
||||
|
||||
Console.WriteLine($"{status.PercentageComplete}%: {status.Message}");
|
||||
|
||||
// Capture video details when they become available
|
||||
if (videoDetails == null && status.VideoDetails != null)
|
||||
{
|
||||
videoDetails = status.VideoDetails;
|
||||
Console.WriteLine($"?? Video: {videoDetails.Title} by {videoDetails.Author}");
|
||||
}
|
||||
|
||||
if (status.Status == "Completed")
|
||||
{
|
||||
processingComplete = true;
|
||||
}
|
||||
else if (status.Status == "Failed")
|
||||
{
|
||||
Console.WriteLine($"Processing failed: {status.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!processingComplete)
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Get final result
|
||||
var result = await ApiService.GetProcessingResultAsync(jobId);
|
||||
if (result?.Success == true)
|
||||
{
|
||||
Console.WriteLine($"? Success! File: {result.FileName}");
|
||||
Console.WriteLine($"?? Download: {result.DownloadUrl}");
|
||||
|
||||
// Step 4: Download the file
|
||||
var fileBytes = await ApiService.DownloadFileAsync(jobId);
|
||||
if (fileBytes != null)
|
||||
{
|
||||
Console.WriteLine($"?? Downloaded {fileBytes.Length} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -120,17 +213,22 @@ private async Task ErrorHandlingExample()
|
||||
{
|
||||
try
|
||||
{
|
||||
var videoDetails = await ApiService.GetVideoDetailsAsync("invalid-url");
|
||||
|
||||
if (videoDetails == null)
|
||||
var request = new ProcessVideoRequest
|
||||
{
|
||||
// Handle case where API returns null (error occurred)
|
||||
Console.WriteLine("Failed to get video details");
|
||||
Url = "invalid-url",
|
||||
Format = "mp3",
|
||||
Quality = "128"
|
||||
};
|
||||
|
||||
var response = await ApiService.StartProcessingAsync(request);
|
||||
|
||||
if (response?.Success != true)
|
||||
{
|
||||
Console.WriteLine($"Processing failed: {response?.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Handle unexpected exceptions
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -153,40 +251,106 @@ The API service automatically uses the configuration from `appsettings.json`:
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Current Endpoints:
|
||||
- **POST** `/api/video/process` - Start processing (includes video details fetching)
|
||||
- **GET** `/api/video/status/{jobId}` - Get processing status (includes video details when available)
|
||||
- **GET** `/api/video/result/{jobId}` - Get final processing result
|
||||
- **GET** `/api/video/jobs` - List all processing jobs
|
||||
- **GET** `/api/download/{jobId}` - Download converted file
|
||||
- **GET** `/api/health` - Health check
|
||||
|
||||
### Removed Endpoints:
|
||||
- ~~**GET** `/api/video/details?url={url}` - Removed (now integrated into processing)~~
|
||||
|
||||
## Complete Component Example
|
||||
|
||||
```razor
|
||||
@page "/example"
|
||||
@inject ITuneRipperApiService ApiService
|
||||
|
||||
<h3>API Service Example</h3>
|
||||
<h3>Integrated Video Processing</h3>
|
||||
|
||||
<button @onclick="TestApi">Test API</button>
|
||||
<input @bind="youtubeUrl" placeholder="YouTube URL" />
|
||||
<button @onclick="StartProcessing" disabled="@isProcessing">
|
||||
@(isProcessing ? "Processing..." : "Convert Video")
|
||||
</button>
|
||||
|
||||
@if (!string.IsNullOrEmpty(message))
|
||||
@if (currentVideo != null)
|
||||
{
|
||||
<p>@message</p>
|
||||
<div class="video-info">
|
||||
<h4>@currentVideo.Title</h4>
|
||||
<p>By: @currentVideo.Author</p>
|
||||
<p>Duration: @currentVideo.Duration</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isProcessing)
|
||||
{
|
||||
<div class="progress">
|
||||
<div class="progress-bar" style="width: @(progressPercent)%"></div>
|
||||
<span>@progressMessage (@progressPercent%)</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string message = "";
|
||||
private string youtubeUrl = "";
|
||||
private bool isProcessing = false;
|
||||
private int progressPercent = 0;
|
||||
private string progressMessage = "";
|
||||
private YouTubeVideoDetails? currentVideo = null;
|
||||
|
||||
private async Task TestApi()
|
||||
private async Task StartProcessing()
|
||||
{
|
||||
message = "Testing API...";
|
||||
if (string.IsNullOrWhiteSpace(youtubeUrl)) return;
|
||||
|
||||
var isHealthy = await ApiService.IsApiHealthyAsync();
|
||||
isProcessing = true;
|
||||
currentVideo = null;
|
||||
progressPercent = 0;
|
||||
|
||||
if (isHealthy)
|
||||
try
|
||||
{
|
||||
message = "API is healthy!";
|
||||
// Start processing
|
||||
var request = new ProcessVideoRequest { Url = youtubeUrl, Format = "mp3", Quality = "128" };
|
||||
var response = await ApiService.StartProcessingAsync(request);
|
||||
|
||||
if (response?.Success == true)
|
||||
{
|
||||
await MonitorProgress(response.FileName);
|
||||
}
|
||||
else
|
||||
}
|
||||
finally
|
||||
{
|
||||
message = "API is not responding";
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MonitorProgress(string jobId)
|
||||
{
|
||||
while (isProcessing)
|
||||
{
|
||||
var status = await ApiService.GetProcessingStatusAsync(jobId);
|
||||
|
||||
if (status != null)
|
||||
{
|
||||
progressPercent = status.PercentageComplete;
|
||||
progressMessage = status.Message ?? "Processing...";
|
||||
|
||||
// Video details appear at ~15%
|
||||
if (currentVideo == null && status.VideoDetails != null)
|
||||
{
|
||||
currentVideo = status.VideoDetails;
|
||||
}
|
||||
|
||||
if (status.Status == "Completed" || status.Status == "Failed")
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
+483
-35
@@ -1,6 +1,8 @@
|
||||
@page "/"
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ITuneRipperApiService ApiService
|
||||
@inject ILogger<Home> Logger
|
||||
|
||||
<PageTitle>TuneRipper - YouTube to MP3 Downloader</PageTitle>
|
||||
|
||||
@@ -34,13 +36,82 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* Video Information Section - Show as soon as we have details *@
|
||||
@if (currentVideoDetails != null)
|
||||
{
|
||||
<div class="video-info-section">
|
||||
<h3 class="video-info-title">📹 Video Information</h3>
|
||||
<div class="video-info-card @(isProcessing ? "processing" : "")">
|
||||
<div class="video-thumbnail">
|
||||
@if (!string.IsNullOrEmpty(currentVideoDetails.ThumbnailUrl))
|
||||
{
|
||||
<img src="@currentVideoDetails.ThumbnailUrl" alt="Video thumbnail" class="thumbnail-img" />
|
||||
@if (isProcessing)
|
||||
{
|
||||
<div class="progress-container">
|
||||
<div class="processing-overlay">
|
||||
<div class="processing-spinner">🎵</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="video-details">
|
||||
<h4 class="video-title">@currentVideoDetails.Title</h4>
|
||||
<div class="video-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-icon">👤</span>
|
||||
<span>@currentVideoDetails.Author</span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-icon">⏱️</span>
|
||||
<span>@FormatDuration(currentVideoDetails.Duration)</span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-icon">👁️</span>
|
||||
<span>@FormatViewCount(currentVideoDetails.ViewCount) views</span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-icon">📅</span>
|
||||
<span>@currentVideoDetails.UploadDate.ToString("MMM dd, yyyy")</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (currentVideoDetails.Tags?.Any() == true)
|
||||
{
|
||||
<div class="video-tags">
|
||||
@foreach (var tag in currentVideoDetails.Tags.Take(5))
|
||||
{
|
||||
<span class="tag">@tag</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (isProcessing)
|
||||
{
|
||||
<div class="conversion-status">
|
||||
<span class="status-icon">⚡</span>
|
||||
<span class="status-text">Converting to MP3...</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isProcessing)
|
||||
{
|
||||
<div class="progress-container @(currentVideoDetails != null ? "with-video-details" : "")">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: @(progressPercentage)%"></div>
|
||||
</div>
|
||||
<div class="progress-text">@progressMessage (@progressPercentage%)</div>
|
||||
<div class="progress-text">
|
||||
@if (currentVideoDetails != null && progressPercentage > 15)
|
||||
{
|
||||
<span class="progress-highlight">@progressMessage</span>
|
||||
<span class="progress-percentage">(@progressPercentage%)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@progressMessage (@progressPercentage%)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -50,6 +121,14 @@
|
||||
<p>@outputMessage</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* API Error Message *@
|
||||
@if (!string.IsNullOrWhiteSpace(apiErrorMessage) && !isProcessing)
|
||||
{
|
||||
<div class="error-message">
|
||||
<p>❌ @apiErrorMessage</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (downloadLinks.Any())
|
||||
@@ -96,6 +175,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-container.with-video-details {
|
||||
margin-top: 15px;
|
||||
background: #f8f9ff;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e3e7ff;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
@@ -105,6 +192,11 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-container.with-video-details .progress-bar {
|
||||
height: 6px;
|
||||
background-color: #d1d9ff;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4CAF50, #45a049);
|
||||
@@ -119,6 +211,22 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-container.with-video-details .progress-text {
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.progress-highlight {
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
color: #718096;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.download-links-section {
|
||||
margin-top: 30px;
|
||||
width: 100%;
|
||||
@@ -197,15 +305,224 @@
|
||||
.download-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Video Info Styles */
|
||||
.video-info-section {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
animation: slideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.video-info-title {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-info-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.video-info-card.processing {
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 2px 12px rgba(76, 175, 80, 0.15);
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumbnail-img {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.processing-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.processing-spinner {
|
||||
font-size: 24px;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.video-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.video-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
border-radius: 8px;
|
||||
color: #c62828;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conversion-status {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(90deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@@media (max-width: 768px) {
|
||||
.video-info-card {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.thumbnail-img {
|
||||
width: 200px;
|
||||
height: 112px;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@media (max-width: 480px) {
|
||||
.video-info-card {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.thumbnail-img {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private string youtubeUrl = "";
|
||||
private string outputMessage = "";
|
||||
private string apiErrorMessage = "";
|
||||
private bool isProcessing = false;
|
||||
private int progressPercentage = 0;
|
||||
private string progressMessage = "";
|
||||
private List<DownloadItem> downloadLinks = new();
|
||||
private YouTubeVideoDetails? currentVideoDetails = null;
|
||||
|
||||
public class DownloadItem
|
||||
{
|
||||
@@ -221,59 +538,190 @@
|
||||
isProcessing = true;
|
||||
progressPercentage = 0;
|
||||
outputMessage = "";
|
||||
apiErrorMessage = "";
|
||||
currentVideoDetails = null;
|
||||
|
||||
await SimulateProgress();
|
||||
try
|
||||
{
|
||||
// Step 1: Start processing (which includes video details fetching)
|
||||
progressMessage = "Starting video processing...";
|
||||
progressPercentage = 5;
|
||||
StateHasChanged();
|
||||
|
||||
string title = ExtractTitleFromUrl(youtubeUrl);
|
||||
var request = new ProcessVideoRequest
|
||||
{
|
||||
Url = youtubeUrl,
|
||||
Format = "mp3",
|
||||
Quality = "128"
|
||||
};
|
||||
|
||||
var processingResponse = await ApiService.StartProcessingAsync(request);
|
||||
|
||||
if (processingResponse?.Success == true)
|
||||
{
|
||||
var jobId = processingResponse.FileName; // This contains the job ID
|
||||
|
||||
// Step 2: Monitor processing status and get video details
|
||||
await MonitorProcessingAndShowDetails(jobId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Processing start failed
|
||||
isProcessing = false;
|
||||
apiErrorMessage = processingResponse?.Message ?? "Failed to start processing. Please check the URL and try again.";
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
isProcessing = false;
|
||||
apiErrorMessage = $"An error occurred: {ex.Message}";
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MonitorProcessingAndShowDetails(string jobId)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool videoDetailsReceived = false;
|
||||
int maxAttempts = 60; // Maximum polling attempts (about 2 minutes)
|
||||
int attemptCount = 0;
|
||||
|
||||
while (attemptCount < maxAttempts && isProcessing)
|
||||
{
|
||||
var status = await ApiService.GetProcessingStatusAsync(jobId);
|
||||
|
||||
if (status != null)
|
||||
{
|
||||
// Update progress
|
||||
progressPercentage = status.PercentageComplete;
|
||||
progressMessage = status.Message ?? "Processing...";
|
||||
|
||||
// Check if we received video details
|
||||
if (!videoDetailsReceived && status.VideoDetails != null)
|
||||
{
|
||||
currentVideoDetails = status.VideoDetails;
|
||||
videoDetailsReceived = true;
|
||||
Logger.LogInformation("Video details received: {Title}", status.VideoDetails.Title);
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
|
||||
// Check if processing is complete
|
||||
if (status.Status == "Completed")
|
||||
{
|
||||
// Get final result
|
||||
var result = await ApiService.GetProcessingResultAsync(jobId);
|
||||
if (result?.Success == true)
|
||||
{
|
||||
await CompleteProcessing(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
apiErrorMessage = result?.Message ?? "Processing completed but failed to get result.";
|
||||
isProcessing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
return;
|
||||
}
|
||||
else if (status.Status == "Failed")
|
||||
{
|
||||
apiErrorMessage = status.Message ?? "Processing failed.";
|
||||
isProcessing = false;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
attemptCount++;
|
||||
await Task.Delay(2000); // Poll every 2 seconds
|
||||
}
|
||||
|
||||
// Timeout handling
|
||||
if (attemptCount >= maxAttempts)
|
||||
{
|
||||
apiErrorMessage = "Processing timeout. Please try again.";
|
||||
isProcessing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
isProcessing = false;
|
||||
apiErrorMessage = $"Error monitoring processing: {ex.Message}";
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompleteProcessing(ProcessVideoResponse result)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use the video details from the result if we don't have them yet
|
||||
if (currentVideoDetails == null && result.VideoDetails != null)
|
||||
{
|
||||
currentVideoDetails = result.VideoDetails;
|
||||
}
|
||||
|
||||
// Use the actual video title from API
|
||||
string title = currentVideoDetails?.Title ?? result.VideoDetails?.Title ?? ExtractTitleFromUrl(youtubeUrl);
|
||||
|
||||
downloadLinks.Add(new DownloadItem
|
||||
{
|
||||
Title = title,
|
||||
FileName = "sample.mp3",
|
||||
FileName = result.FileName ?? "sample.mp3",
|
||||
CreatedAt = DateTime.Now
|
||||
});
|
||||
|
||||
outputMessage = $"✅ Successfully converted: {title}";
|
||||
progressPercentage = 100;
|
||||
progressMessage = "Complete!";
|
||||
|
||||
// Small delay to show completion
|
||||
await Task.Delay(500);
|
||||
|
||||
isProcessing = false;
|
||||
youtubeUrl = "";
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SimulateProgress()
|
||||
catch (Exception ex)
|
||||
{
|
||||
string[] progressSteps = {
|
||||
"Fetching video information...",
|
||||
"Analyzing audio stream...",
|
||||
"Downloading audio data...",
|
||||
"Converting to MP3...",
|
||||
"Optimizing quality...",
|
||||
"Finalizing download..."
|
||||
};
|
||||
|
||||
int stepDuration = 800;
|
||||
int stepsPerProgress = 100 / progressSteps.Length;
|
||||
|
||||
for (int i = 0; i < progressSteps.Length; i++)
|
||||
{
|
||||
progressMessage = progressSteps[i];
|
||||
|
||||
int startProgress = i * stepsPerProgress;
|
||||
int endProgress = (i + 1) * stepsPerProgress;
|
||||
|
||||
for (int progress = startProgress; progress <= endProgress; progress++)
|
||||
{
|
||||
progressPercentage = Math.Min(progress, 100);
|
||||
isProcessing = false;
|
||||
apiErrorMessage = $"Error completing processing: {ex.Message}";
|
||||
StateHasChanged();
|
||||
await Task.Delay(stepDuration / stepsPerProgress);
|
||||
}
|
||||
}
|
||||
|
||||
progressPercentage = 100;
|
||||
progressMessage = "Complete!";
|
||||
StateHasChanged();
|
||||
await Task.Delay(500);
|
||||
private string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration.TotalHours >= 1)
|
||||
{
|
||||
return duration.ToString(@"h\:mm\:ss");
|
||||
}
|
||||
else
|
||||
{
|
||||
return duration.ToString(@"m\:ss");
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatViewCount(int viewCount)
|
||||
{
|
||||
if (viewCount >= 1_000_000)
|
||||
{
|
||||
return $"{viewCount / 1_000_000.0:F1}M";
|
||||
}
|
||||
else if (viewCount >= 1_000)
|
||||
{
|
||||
return $"{viewCount / 1_000.0:F1}K";
|
||||
}
|
||||
else
|
||||
{
|
||||
return viewCount.ToString("N0");
|
||||
}
|
||||
}
|
||||
|
||||
private string ExtractTitleFromUrl(string url)
|
||||
|
||||
@@ -9,7 +9,6 @@ namespace TuneRipper.Services;
|
||||
|
||||
public interface ITuneRipperApiService
|
||||
{
|
||||
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
||||
Task<ProcessVideoResponse?> StartProcessingAsync(ProcessVideoRequest request);
|
||||
Task<DownloadProgress?> GetProcessingStatusAsync(string jobId);
|
||||
Task<ProcessVideoResponse?> GetProcessingResultAsync(string jobId);
|
||||
@@ -51,51 +50,6 @@ public class TuneRipperApiService : ITuneRipperApiService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
_logger.LogWarning("GetVideoDetailsAsync called with empty URL");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetching video details for URL: {Url}", url);
|
||||
|
||||
var encodedUrl = Uri.EscapeDataString(url);
|
||||
var response = await _httpClient.GetAsync($"api/video/details?url={encodedUrl}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var videoDetails = await response.Content.ReadFromJsonAsync<YouTubeVideoDetails>(_jsonOptions);
|
||||
_logger.LogInformation("Successfully retrieved video details for URL: {Url}", url);
|
||||
return videoDetails;
|
||||
}
|
||||
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Failed to get video details. Status: {StatusCode}, Error: {Error}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error while fetching video details for URL: {Url}", url);
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Timeout while fetching video details for URL: {Url}", url);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error while fetching video details for URL: {Url}", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ProcessVideoResponse?> StartProcessingAsync(ProcessVideoRequest request)
|
||||
{
|
||||
try
|
||||
@@ -159,8 +113,20 @@ public class TuneRipperApiService : ITuneRipperApiService
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var status = await response.Content.ReadFromJsonAsync<DownloadProgress>(_jsonOptions);
|
||||
return status;
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Try to deserialize as extended status first (with video details)
|
||||
try
|
||||
{
|
||||
var extendedStatus = JsonSerializer.Deserialize<DownloadProgress>(content, _jsonOptions);
|
||||
return extendedStatus;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to basic status
|
||||
var basicStatus = JsonSerializer.Deserialize<DownloadProgress>(content, _jsonOptions);
|
||||
return basicStatus;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
|
||||
Reference in New Issue
Block a user