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
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<VideoController> _logger;
|
||||
|
||||
// In-memory storage for processing status (in production, use database or Redis)
|
||||
private static readonly ConcurrentDictionary<string, ProcessingJob> _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<VideoController> logger)
|
||||
{
|
||||
_videoService = videoService;
|
||||
_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)
|
||||
{
|
||||
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<DownloadProgress> 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<ProcessVideoResponse> 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<IEnumerable<JobStatusResponse>> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IVideoService, VideoService>();
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,134 @@
|
||||
using TuneRipper.Shared.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace TuneRipper.Api.Services;
|
||||
|
||||
public interface IVideoService
|
||||
{
|
||||
Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url);
|
||||
bool IsValidYouTubeUrl(string url);
|
||||
string ExtractVideoId(string url);
|
||||
}
|
||||
|
||||
public class VideoService : IVideoService
|
||||
{
|
||||
private readonly ILogger<VideoService> _logger;
|
||||
|
||||
public VideoService(ILogger<VideoService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<YouTubeVideoDetails?> 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<string, string>
|
||||
{
|
||||
{ @"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<string> 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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -14,8 +14,4 @@
|
||||
<ProjectReference Include="..\TuneRipper.Shared\TuneRipper.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Controllers\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user