Files
TuneRipper/TuneRipper/Pages/Home.razor
T
Michal Vrba 471010c536 Improve API base URL handling for downloads and logging
Refactored API and Blazor client to dynamically construct download URLs using the configured API base URL instead of hardcoded localhost addresses. Enhanced root endpoint and startup logs to reflect environment-specific URLs. Added GetBaseUrl() to ITuneRipperApiService for consistent URL generation.
2025-12-16 14:46:05 +01:00

732 lines
28 KiB
Plaintext

@page "/"
@using Microsoft.AspNetCore.Components.Web
@using TuneRipper.Shared.Models
@using TuneRipper.Services
@inject IJSRuntime JSRuntime
@inject ITuneRipperApiService ApiService
@inject IClientSessionService SessionService
@inject ILogger<Home> Logger
<PageTitle>TuneRipper - YouTube to MP3 Downloader</PageTitle>
<!-- Floating Musical Notes Background -->
<div class="page-background">
<div class="music-notes">
<div class="note">♪</div>
<div class="note">♫</div>
<div class="note">♪</div>
<div class="note">♬</div>
<div class="note">♫</div>
<div class="note">♪</div>
<div class="note">♬</div>
<div class="note">♫</div>
<div class="note">♪</div>
</div>
<div class="hero-section">
<div class="logo-container">
<img src="images/logo.png" alt="TuneRipper Logo" class="logo" />
<h1 class="app-title">TuneRipper</h1>
<p class="app-subtitle">Convert YouTube videos to MP3 instantly</p>
</div>
<div class="download-form">
<div class="input-container">
<input type="text"
@bind="youtubeUrl"
@onkeypress="HandleKeyPress"
@onblur="OnInputBlur"
placeholder="Paste your YouTube link here..."
class="url-input"
disabled="@isProcessing" />
<button @onclick="DownloadMp3"
@onmousedown="PreventDefault"
class="download-btn @(isProcessing ? "processing" : "")"
disabled="@(isProcessing)">
<span class="btn-icon">🎵</span>
@if (isProcessing)
{
<span>Processing... (@progressPercentage%)</span>
}
else
{
<span>Download MP3</span>
}
</button>
</div>
@* Enhanced Progress Section - Show immediately when processing starts *@
@if (isProcessing)
{
<div class="enhanced-progress-section">
<div class="progress-header">
<h3 class="progress-title">
<span class="progress-icon @GetProgressIconClass()">@GetProgressIcon()</span>
@GetProgressStageTitle()
</h3>
<div class="progress-stats">
<span class="progress-percentage">@progressPercentage%</span>
<span class="progress-elapsed">⏱️ @GetElapsedTime()</span>
@if (estimatedTimeRemaining.HasValue && estimatedTimeRemaining.Value > TimeSpan.Zero)
{
<span class="progress-eta">ETA: @GetEstimatedTimeRemaining()</span>
}
</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar-background">
<div class="progress-bar-fill" style="width: @(progressPercentage)%">
<div class="progress-bar-shimmer"></div>
</div>
</div>
<div class="progress-stages">
@foreach (var stage in progressStages)
{
<div class="progress-stage @GetStageClass(stage)">
<div class="stage-icon">@stage.Icon</div>
<div class="stage-label">@stage.Label</div>
</div>
}
</div>
</div>
<div class="progress-details">
<div class="progress-message">
<span class="message-text">@progressMessage</span>
@if (progressPercentage > 0 && progressPercentage < 100)
{
<div class="progress-dots">
<span class="dot">.</span>
<span class="dot">.</span>
<span class="dot">.</span>
</div>
}
</div>
@if (currentStageDetails != null)
{
<div class="stage-details">
<small>@currentStageDetails</small>
</div>
}
</div>
</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="processing-overlay">
<div class="processing-spinner">🎵</div>
<div class="processing-text">@progressPercentage%</div>
</div>
}
}
</div>
<div class="video-details">
<h4 class="video-title">@currentVideoDetails.Title</h4>
</div>
</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(outputMessage) && !isProcessing)
{
<div class="output-message success">
<div class="success-icon">✅</div>
<p>@outputMessage</p>
</div>
}
@* API Error Message *@
@if (!string.IsNullOrWhiteSpace(apiErrorMessage) && !isProcessing)
{
<div class="error-message">
<div class="error-icon">❌</div>
<p>@apiErrorMessage</p>
</div>
}
</div>
@if (downloadLinks.Any())
{
<div class="download-links-section">
<h3 class="downloads-title">📥 Your Downloads</h3>
@* Download All in ZIP Button *@
@if (!string.IsNullOrWhiteSpace(currentSessionId) && downloadLinks.Count > 1)
{
<div class="download-all-container">
<button class="download-all-btn @(isLoadingZip ? "loading" : "")"
@onclick="DownloadAllInZip"
disabled="@isLoadingZip">
<span class="download-all-icon">📦</span>
@if (isLoadingZip)
{
<span>Preparing ZIP...</span>
<div class="loading-spinner">⏳</div>
}
else
{
<span>Download All in ZIP</span>
}
@if (sessionZipInfo != null)
{
<span class="zip-info">(@sessionZipInfo.Mp3FilesCount files, @sessionZipInfo.FileSize)</span>
}
</button>
</div>
}
<div class="download-links">
@foreach (var download in downloadLinks.AsEnumerable().Reverse())
{
<div class="download-item">
<div class="download-info">
<span class="download-title">@download.Title</span>
<span class="download-date">@download.CreatedAt.ToString("MMM dd, yyyy HH:mm")</span>
</div>
<button class="download-link-btn" @onclick="() => DownloadFile(download.DownloadUrl)">
<span class="download-icon">⬇️</span>
Download MP3
</button>
</div>
}
</div>
</div>
}
<div class="features">
<div class="feature">
<span class="feature-icon">⚡</span>
<span>Fast Conversion</span>
</div>
<div class="feature">
<span class="feature-icon">🎧</span>
<span>High Quality</span>
</div>
<div class="feature">
<span class="feature-icon">🆓</span>
<span>Completely Free</span>
</div>
</div>
<DonateButton />
@* Session info display - moved to bottom *@
@if (!string.IsNullOrWhiteSpace(currentSessionId))
{
<div class="session-info">
<small>Session: @currentSessionId.Substring(0, 8)...</small>
</div>
}
</div>
</div>
@code {
private string youtubeUrl = "";
private string outputMessage = "";
private string apiErrorMessage = "";
private bool isProcessing = false;
private bool isLoadingZip = false;
private int progressPercentage = 0;
private string progressMessage = "";
private string? currentStageDetails = null;
private List<DownloadItem> downloadLinks = new();
private YouTubeVideoDetails? currentVideoDetails = null;
private string currentSessionId = "";
private SessionZipInfo? sessionZipInfo = null;
// Enhanced progress tracking
private DateTime processingStartTime;
private TimeSpan? estimatedTimeRemaining;
private List<ProgressStage> progressStages = new();
private string currentProgressStage = "initializing";
public class DownloadItem
{
public string Title { get; set; } = "";
public string FileName { get; set; } = "";
public string DownloadUrl { get; set; } = "";
public DateTime CreatedAt { get; set; }
}
public class ProgressStage
{
public string Id { get; set; } = "";
public string Label { get; set; } = "";
public string Icon { get; set; } = "";
public bool IsCompleted { get; set; }
public bool IsActive { get; set; }
public int MinProgress { get; set; }
public int MaxProgress { get; set; }
}
protected override async Task OnInitializedAsync()
{
InitializeProgressStages();
try
{
// Initialize session on component load
currentSessionId = await SessionService.GetCurrentSessionIdAsync();
Logger.LogInformation("Initialized with session: {SessionId}", currentSessionId);
// Load session ZIP info if available
await LoadSessionZipInfoAsync();
StateHasChanged();
}
catch (Exception ex)
{
Logger.LogError(ex, "Error initializing session");
// Don't block the UI if session initialization fails
}
}
private void InitializeProgressStages()
{
progressStages = new List<ProgressStage>
{
new() { Id = "initializing", Label = "Initializing", Icon = "🚀", MinProgress = 0, MaxProgress = 15 },
new() { Id = "fetching", Label = "Fetching Video", Icon = "📡", MinProgress = 15, MaxProgress = 35 },
new() { Id = "extracting", Label = "Extracting Audio", Icon = "🔄", MinProgress = 35, MaxProgress = 70 },
new() { Id = "converting", Label = "Converting to MP3", Icon = "🎵", MinProgress = 70, MaxProgress = 90 },
new() { Id = "finalizing", Label = "Finalizing", Icon = "✨", MinProgress = 90, MaxProgress = 100 }
};
}
private void UpdateProgressStage(int percentage)
{
foreach (var stage in progressStages)
{
stage.IsCompleted = percentage > stage.MaxProgress;
stage.IsActive = percentage >= stage.MinProgress && percentage <= stage.MaxProgress;
}
var activeStage = progressStages.FirstOrDefault(s => s.IsActive);
if (activeStage != null && currentProgressStage != activeStage.Id)
{
currentProgressStage = activeStage.Id;
UpdateStageDetails(activeStage.Id, percentage);
}
}
private void UpdateStageDetails(string stageId, int percentage)
{
currentStageDetails = stageId switch
{
"initializing" => "Preparing download request and validating URL...",
"fetching" => $"Analyzing video content and metadata... ({percentage}%)",
"extracting" => $"Extracting high-quality audio stream... ({percentage}%)",
"converting" => $"Converting to MP3 format with optimal settings... ({percentage}%)",
"finalizing" => $"Completing conversion and preparing download... ({percentage}%)",
_ => null
};
}
private string GetProgressStageTitle()
{
var activeStage = progressStages.FirstOrDefault(s => s.IsActive);
return activeStage?.Label ?? "Processing";
}
private string GetProgressIcon()
{
var activeStage = progressStages.FirstOrDefault(s => s.IsActive);
return activeStage?.Icon ?? "🎵";
}
private string GetProgressIconClass()
{
if (progressPercentage < 25) return "spinning";
if (progressPercentage < 50) return "pulse";
if (progressPercentage < 75) return "bounce";
if (progressPercentage < 100) return "glow";
return "success";
}
private string GetStageClass(ProgressStage stage)
{
if (stage.IsCompleted) return "completed";
if (stage.IsActive) return "active";
return "pending";
}
private string GetElapsedTime()
{
var elapsed = DateTime.Now - processingStartTime;
return elapsed.TotalMinutes >= 1
? $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
: $"{elapsed.Seconds}s";
}
private string GetEstimatedTimeRemaining()
{
if (!estimatedTimeRemaining.HasValue) return "";
var eta = estimatedTimeRemaining.Value;
return eta.TotalMinutes >= 1
? $"{eta.Minutes:D2}:{eta.Seconds:D2}"
: $"{eta.Seconds}s";
}
private void CalculateEstimatedTime(int currentProgress)
{
if (currentProgress > 5) // Only estimate after some progress
{
var elapsed = DateTime.Now - processingStartTime;
var progressRate = currentProgress / elapsed.TotalSeconds;
var remainingProgress = 100 - currentProgress;
var estimatedSeconds = remainingProgress / progressRate;
if (estimatedSeconds > 0 && estimatedSeconds < 300) // Cap at 5 minutes
{
estimatedTimeRemaining = TimeSpan.FromSeconds(estimatedSeconds);
}
}
}
private async Task LoadSessionZipInfoAsync()
{
try
{
if (!string.IsNullOrWhiteSpace(currentSessionId))
{
sessionZipInfo = await ApiService.GetSessionZipInfoAsync(currentSessionId);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading session ZIP info");
// Don't block the UI if ZIP info loading fails
}
}
private async Task DownloadAllInZip()
{
if (string.IsNullOrWhiteSpace(currentSessionId))
{
apiErrorMessage = "Session not available for ZIP download";
return;
}
try
{
isLoadingZip = true;
StateHasChanged();
Logger.LogInformation("Downloading session ZIP for session: {SessionId}", currentSessionId);
// Create the download URL for the ZIP file using the configured API base URL
var baseUrl = ApiService.GetBaseUrl();
var zipDownloadUrl = $"{baseUrl}/api/download/zip/{currentSessionId}";
// Use browser to download the ZIP file
await JSRuntime.InvokeVoidAsync("open", zipDownloadUrl, "_blank");
Logger.LogInformation("Successfully initiated ZIP download for session: {SessionId}", currentSessionId);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error downloading session ZIP for session: {SessionId}", currentSessionId);
apiErrorMessage = $"Error downloading ZIP file: {ex.Message}";
StateHasChanged();
}
finally
{
isLoadingZip = false;
StateHasChanged();
}
}
private async Task DownloadMp3()
{
if (!string.IsNullOrWhiteSpace(youtubeUrl))
{
isProcessing = true;
progressPercentage = 0;
outputMessage = "";
apiErrorMessage = "";
currentVideoDetails = null;
processingStartTime = DateTime.Now;
estimatedTimeRemaining = null;
currentStageDetails = null;
// Reset progress stages
foreach (var stage in progressStages)
{
stage.IsCompleted = false;
stage.IsActive = false;
}
progressStages.First().IsActive = true;
try
{
// Ensure we have a session
if (string.IsNullOrWhiteSpace(currentSessionId))
{
currentSessionId = await SessionService.GetCurrentSessionIdAsync();
}
// Step 1: Start processing (which includes video details fetching)
progressMessage = "Starting video processing...";
progressPercentage = 5;
UpdateProgressStage(progressPercentage);
StateHasChanged();
var request = new ProcessVideoRequest
{
Url = youtubeUrl,
Format = "mp3",
Quality = "128",
SessionId = currentSessionId // Include session ID in request
};
var processingResponse = await ApiService.StartProcessingAsync(request);
if (processingResponse?.Success == true)
{
var jobId = processingResponse.FileName; // This contains the job ID
var sessionId = processingResponse.SessionId ?? currentSessionId;
// Update current session ID if server returned a different one
if (!string.IsNullOrWhiteSpace(processingResponse.SessionId) &&
processingResponse.SessionId != currentSessionId)
{
currentSessionId = processingResponse.SessionId;
await SessionService.SetSessionIdAsync(currentSessionId);
}
// Step 2: Monitor processing status and get video details
await MonitorProcessingAndShowDetails(jobId, sessionId);
}
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, string sessionId)
{
try
{
bool videoDetailsReceived = false;
int maxAttempts = 60; // Maximum polling attempts (about 2 minutes)
int attemptCount = 0;
int lastProgress = 0;
while (attemptCount < maxAttempts && isProcessing)
{
var status = await ApiService.GetProcessingStatusAsync(jobId);
if (status != null)
{
// Update progress only if it has increased (prevent UI flicker)
if (status.PercentageComplete > lastProgress)
{
progressPercentage = status.PercentageComplete;
lastProgress = progressPercentage;
UpdateProgressStage(progressPercentage);
CalculateEstimatedTime(progressPercentage);
}
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, jobId, sessionId);
}
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, string jobId, string sessionId)
{
try
{
// Ensure we reach 100% completion
progressPercentage = 100;
progressMessage = "Complete!";
UpdateProgressStage(progressPercentage);
// 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);
// Create the session-aware download URL using the configured API base URL
string downloadUrl;
if (!string.IsNullOrEmpty(result.DownloadUrl) && result.DownloadUrl.StartsWith("http"))
{
// Server provided a full download URL
downloadUrl = result.DownloadUrl;
}
else if (!string.IsNullOrEmpty(result.DownloadUrl) && result.DownloadUrl.StartsWith("/"))
{
// Server provided a relative URL, make it absolute using the API base URL
var baseUrl = ApiService.GetBaseUrl();
downloadUrl = $"{baseUrl}{result.DownloadUrl}";
}
else
{
// Construct session-aware download URL using the API base URL
var baseUrl = ApiService.GetBaseUrl();
downloadUrl = $"{baseUrl}/api/download/{sessionId}/{jobId}";
}
downloadLinks.Add(new DownloadItem
{
Title = title,
FileName = result.FileName ?? "sample.mp3",
DownloadUrl = downloadUrl,
CreatedAt = DateTime.Now
});
var processingTime = DateTime.Now - processingStartTime;
outputMessage = $"Successfully converted: {title} (took {processingTime.TotalSeconds:F1}s)";
// Reload session ZIP info after successful download
await LoadSessionZipInfoAsync();
// Show completion for a moment
StateHasChanged();
await Task.Delay(1000);
isProcessing = false;
youtubeUrl = "";
StateHasChanged();
}
catch (Exception ex)
{
isProcessing = false;
apiErrorMessage = $"Error completing processing: {ex.Message}";
StateHasChanged();
}
}
private string ExtractTitleFromUrl(string url)
{
if (url.Contains("youtube.com/watch?v=") || url.Contains("youtu.be/"))
{
var videoId = url.Contains("youtu.be/")
? url.Substring(url.LastIndexOf('/') + 1).Split('?')[0]
: url.Split("v=")[1].Split('&')[0];
return $"YouTube Video {videoId.Substring(0, Math.Min(8, videoId.Length))}";
}
return $"Audio File {DateTime.Now:HHmmss}";
}
private async Task DownloadFile(string downloadUrl)
{
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)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(youtubeUrl) && !isProcessing)
{
await DownloadMp3();
}
}
// Prevent default blur behavior to allow immediate button clicking
private void OnInputBlur()
{
// This method intentionally left empty to capture blur event without action
// This helps maintain focus state for better UX
}
// Prevent mousedown default to allow immediate button click
private async Task PreventDefault(MouseEventArgs e)
{
// Allow the button click to proceed immediately without waiting for blur
await Task.CompletedTask;
}
}