From e2d9ee3c591c4b40427f2913292f6127b85b3929 Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Thu, 11 Dec 2025 14:27:29 +0100 Subject: [PATCH] Initial implementation of TuneRipper API backend - Add Health, Video, and Download controllers with endpoints for health checks, video details, processing, status, results, and simulated downloads - Implement IVideoService for YouTube URL validation and mock video info - Add API models for responses and job status tracking - Configure DI, CORS (BlazorPolicy), OpenAPI, and root metadata endpoint in Program.cs - Add README with usage, troubleshooting, and development notes - Minor project file cleanup and encoding fix --- .../Controllers/DownloadController.cs | 58 +++++ .../Controllers/HealthController.cs | 29 +++ TuneRipper.Api/Controllers/VideoController.cs | 244 ++++++++++++++++++ TuneRipper.Api/Program.cs | 60 ++++- TuneRipper.Api/README.md | 84 ++++++ TuneRipper.Api/Services/VideoService.cs | 134 ++++++++++ TuneRipper.Api/TuneRipper.Api.csproj | 6 +- TuneRipper.Shared/Models/ApiModels.cs | 28 ++ 8 files changed, 637 insertions(+), 6 deletions(-) create mode 100644 TuneRipper.Api/Controllers/DownloadController.cs create mode 100644 TuneRipper.Api/Controllers/HealthController.cs create mode 100644 TuneRipper.Api/Controllers/VideoController.cs create mode 100644 TuneRipper.Api/README.md create mode 100644 TuneRipper.Api/Services/VideoService.cs create mode 100644 TuneRipper.Shared/Models/ApiModels.cs diff --git a/TuneRipper.Api/Controllers/DownloadController.cs b/TuneRipper.Api/Controllers/DownloadController.cs new file mode 100644 index 0000000..b932542 --- /dev/null +++ b/TuneRipper.Api/Controllers/DownloadController.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; + +namespace TuneRipper.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class DownloadController : ControllerBase +{ + [HttpGet("{jobId}")] + public IActionResult DownloadFile(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + return BadRequest("Job ID is required"); + } + + 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"; + + return File(sampleContent, "audio/mpeg", fileName); + } + catch (Exception ex) + { + return StatusCode(500, $"Error downloading file: {ex.Message}"); + } + } + + [HttpGet("info/{jobId}")] + public IActionResult GetDownloadInfo(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + return BadRequest("Job ID is required"); + } + + // Return download metadata + var info = new + { + jobId, + fileName = $"video_{jobId}.mp3", + fileSize = "3.2 MB", + format = "MP3", + quality = "128 kbps", + createdAt = DateTime.UtcNow, + expiresAt = DateTime.UtcNow.AddHours(24) + }; + + return Ok(info); + } +} \ No newline at end of file diff --git a/TuneRipper.Api/Controllers/HealthController.cs b/TuneRipper.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..cfa32dd --- /dev/null +++ b/TuneRipper.Api/Controllers/HealthController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; + +namespace TuneRipper.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class HealthController : ControllerBase +{ + [HttpGet] + public IActionResult Get() + { + var response = new + { + status = "healthy", + timestamp = DateTime.UtcNow, + version = "1.0.0", + service = "TuneRipper.Api", + environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" + }; + + return Ok(response); + } + + [HttpGet("ping")] + public IActionResult Ping() + { + return Ok(new { message = "pong", timestamp = DateTime.UtcNow }); + } +} \ No newline at end of file diff --git a/TuneRipper.Api/Controllers/VideoController.cs b/TuneRipper.Api/Controllers/VideoController.cs new file mode 100644 index 0000000..24a6d4f --- /dev/null +++ b/TuneRipper.Api/Controllers/VideoController.cs @@ -0,0 +1,244 @@ +using Microsoft.AspNetCore.Mvc; +using TuneRipper.Shared.Models; +using TuneRipper.Api.Services; +using System.Collections.Concurrent; + +namespace TuneRipper.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class VideoController : ControllerBase +{ + private readonly IVideoService _videoService; + private readonly ILogger _logger; + + // In-memory storage for processing status (in production, use database or Redis) + private static readonly ConcurrentDictionary _processingJobs = new(); + + private class ProcessingJob + { + public string Id { get; set; } = string.Empty; + public ProcessVideoRequest Request { get; set; } = new(); + public DownloadProgress Progress { get; set; } = new(); + public ProcessVideoResponse? Result { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public VideoController(IVideoService videoService, ILogger logger) + { + _videoService = videoService; + _logger = logger; + } + + [HttpGet("details")] + public async Task> 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> StartProcessing([FromBody] ProcessVideoRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Url)) + { + return BadRequest("Invalid request"); + } + + if (!_videoService.IsValidYouTubeUrl(request.Url)) + { + return BadRequest("Invalid YouTube URL"); + } + + try + { + _logger.LogInformation("Starting video processing for URL: {Url}", request.Url); + + // Generate a job ID + var jobId = Guid.NewGuid().ToString(); + + // Create processing job + var job = new ProcessingJob + { + Id = jobId, + Request = request, + Progress = new DownloadProgress + { + PercentageComplete = 0, + Status = "Queued", + Message = "Processing request queued" + } + }; + + _processingJobs[jobId] = job; + + // Start processing in background (fire and forget) + _ = Task.Run(() => ProcessVideoAsync(jobId)); + + var response = new ProcessVideoResponse + { + Success = true, + Message = "Processing started", + FileName = jobId, // Use job ID as temporary filename + DownloadUrl = null, // Will be available when processing completes + VideoDetails = null // Will be populated during processing + }; + + return Accepted(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting video processing for URL: {Url}", request.Url); + return StatusCode(500, $"Error starting video processing: {ex.Message}"); + } + } + + [HttpGet("status/{jobId}")] + public ActionResult GetProcessingStatus(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + return BadRequest("Job ID is required"); + } + + if (!_processingJobs.TryGetValue(jobId, out var job)) + { + return NotFound("Job not found"); + } + + return Ok(job.Progress); + } + + [HttpGet("result/{jobId}")] + public ActionResult GetProcessingResult(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + return BadRequest("Job ID is required"); + } + + if (!_processingJobs.TryGetValue(jobId, out var job)) + { + return NotFound("Job not found"); + } + + if (job.Result == null) + { + return Accepted("Processing not completed yet"); + } + + return Ok(job.Result); + } + + [HttpGet("jobs")] + public ActionResult> GetAllJobs() + { + var jobs = _processingJobs.Values.Select(job => new JobStatusResponse + { + JobId = job.Id, + Status = job.Progress.Status, + PercentageComplete = job.Progress.PercentageComplete, + Message = job.Progress.Message, + CreatedAt = job.CreatedAt, + CompletedAt = job.Result != null ? job.CreatedAt.AddMinutes(5) : null, // Simulated completion time + Result = job.Result + }).OrderByDescending(j => j.CreatedAt); + + return Ok(jobs); + } + + private async Task ProcessVideoAsync(string jobId) + { + if (!_processingJobs.TryGetValue(jobId, out var job)) + return; + + try + { + _logger.LogInformation("Processing video for job {JobId}", jobId); + + // Simulate video processing steps + var steps = new[] + { + ("Fetching video information", 10), + ("Analyzing audio stream", 25), + ("Downloading audio data", 60), + ("Converting to MP3", 85), + ("Finalizing", 100) + }; + + foreach (var (message, percentage) in steps) + { + job.Progress.Status = "Processing"; + 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)); + } + + // 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"; + job.Progress.PercentageComplete = 100; + + job.Result = new ProcessVideoResponse + { + Success = true, + Message = "Video processed successfully", + FileName = $"{videoDetails?.Title?.Replace(" ", "_") ?? "video"}.{job.Request.Format ?? "mp3"}", + DownloadUrl = $"/api/download/{jobId}", + VideoDetails = videoDetails + }; + + _logger.LogInformation("Successfully completed processing for job {JobId}", jobId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing video for job {JobId}", jobId); + + job.Progress.Status = "Failed"; + job.Progress.Message = $"Processing failed: {ex.Message}"; + job.Progress.PercentageComplete = 0; + + job.Result = new ProcessVideoResponse + { + Success = false, + Message = ex.Message, + FileName = null, + DownloadUrl = null, + VideoDetails = null + }; + } + } +} \ No newline at end of file diff --git a/TuneRipper.Api/Program.cs b/TuneRipper.Api/Program.cs index 666a9c5..36cfa1e 100644 --- a/TuneRipper.Api/Program.cs +++ b/TuneRipper.Api/Program.cs @@ -1,8 +1,24 @@ +using TuneRipper.Api.Services; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. - builder.Services.AddControllers(); + +// Register services +builder.Services.AddScoped(); + +// Add CORS for Blazor WebAssembly client +builder.Services.AddCors(options => +{ + options.AddPolicy("BlazorPolicy", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); @@ -16,8 +32,50 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// Enable CORS +app.UseCors("BlazorPolicy"); + app.UseAuthorization(); +// Add root endpoint with startup info +app.MapGet("/", (IWebHostEnvironment env) => new +{ + service = "TuneRipper API", + version = "1.0.0", + status = "running", + timestamp = DateTime.UtcNow, + environment = env.EnvironmentName, + urls = new + { + https = "https://localhost:7160", + http = "http://localhost:5112" + }, + endpoints = new + { + health = "/api/health", + video_details = "/api/video/details?url={youtubeUrl}", + process_video = "/api/video/process", + check_status = "/api/video/status/{jobId}", + get_result = "/api/video/result/{jobId}", + download = "/api/download/{jobId}", + openapi = "/openapi/v1.json" + }, + testing = new + { + httpFile = "Use api-tests.http to test endpoints", + startWith = "GET /api/health to verify API is running" + } +}); + app.MapControllers(); +// Log startup information +app.Logger.LogInformation("?? TuneRipper API is starting..."); +app.Logger.LogInformation("?? Environment: {Environment}", app.Environment.EnvironmentName); +app.Logger.LogInformation("?? API will be available at:"); +app.Logger.LogInformation(" • HTTPS: https://localhost:7160"); +app.Logger.LogInformation(" • HTTP: http://localhost:5112"); +app.Logger.LogInformation("?? Health check: /api/health"); +app.Logger.LogInformation("?? API info: /"); + app.Run(); diff --git a/TuneRipper.Api/README.md b/TuneRipper.Api/README.md new file mode 100644 index 0000000..668d3f8 --- /dev/null +++ b/TuneRipper.Api/README.md @@ -0,0 +1,84 @@ +# TuneRipper API + +## Quick Start + +### Starting the API + +**Option 1: Using the start script (Windows)** +```cmd +cd TuneRipper.Api +start-api.bat +``` + +**Option 2: Using the start script (Linux/Mac)** +```bash +cd TuneRipper.Api +chmod +x start-api.sh +./start-api.sh +``` + +**Option 3: Manual start** +```cmd +cd TuneRipper.Api +dotnet run +``` + +### API Endpoints + +Once running, the API will be available at: +- **HTTPS**: https://localhost:7160 +- **HTTP**: http://localhost:5112 + +### Testing the API + +1. **Start the API** using one of the methods above +2. **Open the HTTP test file**: `TuneRipper.Api/api-tests.http` +3. **Run the connectivity tests first** to verify the API is responding +4. **Use the main API tests** once connectivity is confirmed + +### Troubleshooting + +#### "This localhost page can't be found" errors: + +1. **Check if API is running**: + - Look for console output showing "Now listening on: https://localhost:7160" + - If not running, start it using the methods above + +2. **Try HTTP instead of HTTPS**: + - Use `http://localhost:5112` instead of `https://localhost:7160` + - Some development environments have HTTPS certificate issues + +3. **Trust development certificates** (for HTTPS): + ```cmd + dotnet dev-certs https --trust + ``` + +4. **Check port availability**: + - Make sure ports 7160 and 5112 aren't used by other applications + - You can change ports in `Properties/launchSettings.json` + +#### HTTP test file variables not working: + +1. **Copy job IDs manually**: + - Run a POST request to start processing + - Copy the `fileName` value from the response + - Use it in the manual test sections + +2. **Use the troubleshooting section**: + - Start with connectivity tests + - Use manual GUID replacements if variables fail + +### API Documentation + +When the API is running, you can access: +- **Root endpoint**: https://localhost:7160/ (lists available endpoints) +- **OpenAPI spec**: https://localhost:7160/openapi/v1.json +- **Health check**: https://localhost:7160/api/health + +### Development + +The API includes: +- Automatic reload on code changes (when using `dotnet run`) +- Detailed logging in the console +- CORS enabled for Blazor WebAssembly client +- OpenAPI/Swagger support for API documentation \ No newline at end of file diff --git a/TuneRipper.Api/Services/VideoService.cs b/TuneRipper.Api/Services/VideoService.cs new file mode 100644 index 0000000..c2cab3e --- /dev/null +++ b/TuneRipper.Api/Services/VideoService.cs @@ -0,0 +1,134 @@ +using TuneRipper.Shared.Models; +using System.Text.RegularExpressions; + +namespace TuneRipper.Api.Services; + +public interface IVideoService +{ + Task GetVideoDetailsAsync(string url); + bool IsValidYouTubeUrl(string url); + string ExtractVideoId(string url); +} + +public class VideoService : IVideoService +{ + private readonly ILogger _logger; + + public VideoService(ILogger logger) + { + _logger = logger; + } + + public async Task GetVideoDetailsAsync(string url) + { + try + { + if (!IsValidYouTubeUrl(url)) + { + _logger.LogWarning("Invalid YouTube URL: {Url}", url); + return null; + } + + // Simulate API call delay + await Task.Delay(Random.Shared.Next(500, 2000)); + + var videoId = ExtractVideoId(url); + + // In production, use YouTube API or yt-dlp to get actual video details + var videoDetails = new YouTubeVideoDetails + { + Id = videoId, + Title = GenerateSampleTitle(videoId), + Description = $"Video description for {videoId}. In production, this would be fetched from YouTube API.", + ThumbnailUrl = $"https://img.youtube.com/vi/{videoId}/maxresdefault.jpg", + Duration = TimeSpan.FromMinutes(Random.Shared.Next(2, 10)).Add(TimeSpan.FromSeconds(Random.Shared.Next(0, 59))), + Author = $"Channel_{Random.Shared.Next(1, 1000)}", + ViewCount = Random.Shared.Next(100, 5000000), + UploadDate = DateTime.UtcNow.AddDays(-Random.Shared.Next(1, 1000)), + Tags = GenerateSampleTags() + }; + + _logger.LogInformation("Successfully fetched details for video {VideoId}", videoId); + return videoDetails; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching video details for URL: {Url}", url); + return null; + } + } + + public bool IsValidYouTubeUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + var patterns = new[] + { + @"^https?://(www\.)?youtube\.com/watch\?v=[\w-]+", + @"^https?://(www\.)?youtu\.be/[\w-]+", + @"^https?://(www\.)?youtube\.com/embed/[\w-]+", + @"^https?://(www\.)?youtube\.com/v/[\w-]+" + }; + + return patterns.Any(pattern => Regex.IsMatch(url, pattern, RegexOptions.IgnoreCase)); + } + + public string ExtractVideoId(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return string.Empty; + + try + { + // Different YouTube URL formats + var patterns = new Dictionary + { + { @"youtu\.be/([^?&]+)", "$1" }, + { @"youtube\.com/watch\?v=([^&]+)", "$1" }, + { @"youtube\.com/embed/([^?&]+)", "$1" }, + { @"youtube\.com/v/([^?&]+)", "$1" } + }; + + foreach (var pattern in patterns) + { + var match = Regex.Match(url, pattern.Key, RegexOptions.IgnoreCase); + if (match.Success) + { + return match.Groups[1].Value; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error extracting video ID from URL: {Url}", url); + } + + return string.Empty; + } + + private static string GenerateSampleTitle(string videoId) + { + var sampleTitles = new[] + { + "Amazing Music Video", + "Best Song Ever", + "Epic Guitar Solo", + "Relaxing Piano Music", + "Dance Hit 2024", + "Acoustic Session", + "Live Performance", + "Official Music Video" + }; + + var randomTitle = sampleTitles[Random.Shared.Next(sampleTitles.Length)]; + return $"{randomTitle} - {videoId}"; + } + + private static List GenerateSampleTags() + { + var allTags = new[] { "music", "video", "song", "artist", "album", "live", "official", "acoustic", "cover", "remix" }; + var tagCount = Random.Shared.Next(3, 7); + return allTags.OrderBy(x => Random.Shared.Next()).Take(tagCount).ToList(); + } +} \ No newline at end of file diff --git a/TuneRipper.Api/TuneRipper.Api.csproj b/TuneRipper.Api/TuneRipper.Api.csproj index 1e9d092..69a8952 100644 --- a/TuneRipper.Api/TuneRipper.Api.csproj +++ b/TuneRipper.Api/TuneRipper.Api.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -14,8 +14,4 @@ - - - - diff --git a/TuneRipper.Shared/Models/ApiModels.cs b/TuneRipper.Shared/Models/ApiModels.cs new file mode 100644 index 0000000..d9d9a00 --- /dev/null +++ b/TuneRipper.Shared/Models/ApiModels.cs @@ -0,0 +1,28 @@ +namespace TuneRipper.Shared.Models; + +public class ApiResponse +{ + public bool Success { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } + public List Errors { get; set; } = new(); +} + +public class JobStatusResponse +{ + public string? JobId { get; set; } + public string? Status { get; set; } + public int PercentageComplete { get; set; } + public string? Message { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public ProcessVideoResponse? Result { get; set; } +} + +public class VideoInfo +{ + public string? Url { get; set; } + public string? VideoId { get; set; } + public bool IsValid { get; set; } + public string? ErrorMessage { get; set; } +} \ No newline at end of file