diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ad04c83 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/bin +**/obj +.git +.vs +artifacts +README.md +DEPLOYMENT_GUIDE_IIS_Windows11.md diff --git a/.gitignore b/.gitignore index ceaf7d0..1a7e38a 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,4 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd /TuneRipper.Api/wwwroot/downloads +/data diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..901cf47 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY TuneRipper.Shared/TuneRipper.Shared.csproj TuneRipper.Shared/ +COPY TuneRipper/TuneRipper.csproj TuneRipper/ +COPY TuneRipper.Api/TuneRipper.Api.csproj TuneRipper.Api/ +RUN dotnet restore TuneRipper.Api/TuneRipper.Api.csproj \ + && dotnet restore TuneRipper/TuneRipper.csproj + +COPY . . +RUN dotnet publish TuneRipper/TuneRipper.csproj -c Release -o /out/client --no-restore \ + && dotnet publish TuneRipper.Api/TuneRipper.Api.csproj -c Release -o /out/api --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS runtime +ARG TARGETARCH +ARG YT_DLP_VERSION=2026.08.19 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg \ + && case "$TARGETARCH" in \ + amd64) asset="yt-dlp_linux" ;; \ + arm64) asset="yt-dlp_linux_aarch64" ;; \ + *) echo "Unsupported architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac \ + && cd /tmp \ + && curl -fsSLO "https://github.com/yt-dlp/yt-dlp/releases/download/${YT_DLP_VERSION}/${asset}" \ + && curl -fsSLO "https://github.com/yt-dlp/yt-dlp/releases/download/${YT_DLP_VERSION}/SHA2-256SUMS" \ + && grep " ${asset}$" SHA2-256SUMS | sha256sum -c - \ + && install -m 0755 "$asset" /usr/local/bin/yt-dlp \ + && rm -f "$asset" SHA2-256SUMS \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=build /out/api/ ./ +COPY --from=build /out/client/wwwroot/ ./wwwroot/ + +RUN mkdir -p /data/downloads \ + && chown -R app:app /app /data/downloads + +USER app +ENV ASPNETCORE_URLS=http://+:8080 \ + ASPNETCORE_ENVIRONMENT=Production \ + YtDlp__ExecutablePath=/usr/local/bin/yt-dlp \ + YtDlp__FfmpegExecutablePath=/usr/bin/ffmpeg \ + Storage__DownloadsPath=/data/downloads + +EXPOSE 8080 +ENTRYPOINT ["dotnet", "TuneRipper.Api.dll"] diff --git a/TuneRipper.Api/Configuration/DeploymentOptions.cs b/TuneRipper.Api/Configuration/DeploymentOptions.cs new file mode 100644 index 0000000..3585456 --- /dev/null +++ b/TuneRipper.Api/Configuration/DeploymentOptions.cs @@ -0,0 +1,20 @@ +namespace TuneRipper.Api.Configuration; + +public sealed class YtDlpOptions +{ + public const string SectionName = "YtDlp"; + + public string ExecutablePath { get; set; } = OperatingSystem.IsWindows() + ? "yt-dlp.exe" + : "yt-dlp"; + + public string FfmpegExecutablePath { get; set; } = "ffmpeg"; + public int ProcessTimeoutSeconds { get; set; } = 900; +} + +public sealed class StorageOptions +{ + public const string SectionName = "Storage"; + + public string DownloadsPath { get; set; } = string.Empty; +} diff --git a/TuneRipper.Api/Controllers/DownloadController.cs b/TuneRipper.Api/Controllers/DownloadController.cs index 3cac741..f02914f 100644 --- a/TuneRipper.Api/Controllers/DownloadController.cs +++ b/TuneRipper.Api/Controllers/DownloadController.cs @@ -7,13 +7,11 @@ namespace TuneRipper.Api.Controllers; [Route("api/[controller]")] public class DownloadController : ControllerBase { - private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; private readonly ISessionService _sessionService; - public DownloadController(IWebHostEnvironment environment, ILogger logger, ISessionService sessionService) + public DownloadController(ILogger logger, ISessionService sessionService) { - _environment = environment; _logger = logger; _sessionService = sessionService; } @@ -203,7 +201,7 @@ public class DownloadController : ControllerBase try { // Check in the legacy downloads directory first - var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); + var downloadsDir = _sessionService.GetDownloadsRootPath(); if (Directory.Exists(downloadsDir)) { @@ -389,7 +387,7 @@ public class DownloadController : ControllerBase try { - var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); + var downloadsDir = _sessionService.GetDownloadsRootPath(); if (!Directory.Exists(downloadsDir)) { @@ -529,7 +527,7 @@ public class DownloadController : ControllerBase try { - var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); + var downloadsDir = _sessionService.GetDownloadsRootPath(); var deletedCount = 0; if (!Directory.Exists(downloadsDir)) @@ -602,4 +600,4 @@ public class DownloadController : ControllerBase } return $"{len:0.##} {sizes[order]}"; } -} \ No newline at end of file +} diff --git a/TuneRipper.Api/Health/ExternalToolsHealthCheck.cs b/TuneRipper.Api/Health/ExternalToolsHealthCheck.cs new file mode 100644 index 0000000..70c472f --- /dev/null +++ b/TuneRipper.Api/Health/ExternalToolsHealthCheck.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using TuneRipper.Api.Configuration; + +namespace TuneRipper.Api.Health; + +public sealed class ExternalToolsHealthCheck(IOptions options) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var ytDlp = await ProbeAsync(options.Value.ExecutablePath, "--version", cancellationToken); + if (!ytDlp.Success) + { + return HealthCheckResult.Unhealthy($"yt-dlp is unavailable: {ytDlp.Message}"); + } + + var ffmpeg = await ProbeAsync(options.Value.FfmpegExecutablePath, "-version", cancellationToken); + if (!ffmpeg.Success) + { + return HealthCheckResult.Unhealthy($"ffmpeg is unavailable: {ffmpeg.Message}"); + } + + return HealthCheckResult.Healthy($"yt-dlp {ytDlp.Message}; ffmpeg available"); + } + + private static async Task<(bool Success, string Message)> ProbeAsync( + string executable, + string argument, + CancellationToken cancellationToken) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add(argument); + + using var process = Process.Start(startInfo); + if (process is null) + { + return (false, "process could not be started"); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + + var outputTask = process.StandardOutput.ReadLineAsync(timeout.Token).AsTask(); + await process.WaitForExitAsync(timeout.Token); + var output = (await outputTask)?.Trim(); + + return process.ExitCode == 0 + ? (true, string.IsNullOrWhiteSpace(output) ? "available" : output) + : (false, $"exit code {process.ExitCode}"); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } +} diff --git a/TuneRipper.Api/Program.cs b/TuneRipper.Api/Program.cs index af43f5c..2f96fd2 100644 --- a/TuneRipper.Api/Program.cs +++ b/TuneRipper.Api/Program.cs @@ -1,21 +1,29 @@ -ο»Ώusing TuneRipper.Api.Services; +using Microsoft.AspNetCore.HttpOverrides; +using TuneRipper.Api.Configuration; +using TuneRipper.Api.Health; +using TuneRipper.Api.Services; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. builder.Services.AddControllers(); - -// Add HttpClient services builder.Services.AddHttpClient(); - -// Register services +builder.Services.AddOpenApi(); +builder.Services.Configure(builder.Configuration.GetSection(YtDlpOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(StorageOptions.SectionName)); builder.Services.AddScoped(); -builder.Services.AddSingleton(); // Register session service as singleton +builder.Services.AddSingleton(); +builder.Services.AddHealthChecks().AddCheck("external_tools"); + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); +}); -// Add CORS for Blazor WebAssembly client builder.Services.AddCors(options => { - options.AddPolicy("BlazorPolicy", policy => + options.AddPolicy("DevelopmentClient", policy => { policy.AllowAnyOrigin() .AllowAnyMethod() @@ -23,42 +31,24 @@ builder.Services.AddCors(options => }); }); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - var app = builder.Build(); -// Configure the HTTP request pipeline. +app.UseForwardedHeaders(); + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.UseHttpsRedirection(); + app.UseCors("DevelopmentClient"); } -app.UseHttpsRedirection(); - -// Enable static files serving (for downloading MP3 files) app.UseStaticFiles(); - -// Enable CORS -app.UseCors("BlazorPolicy"); - app.UseAuthorization(); -// Add root endpoint with startup info -app.MapGet("/", (IWebHostEnvironment env, HttpContext context) => +app.MapGet("/api", (IWebHostEnvironment env, HttpContext context) => { - // Get the current request's base URL var request = context.Request; var baseUrl = $"{request.Scheme}://{request.Host}"; - - // Determine URLs based on environment - var httpsUrl = app.Environment.IsDevelopment() - ? "https://localhost:7160" - : baseUrl; - - var httpUrl = app.Environment.IsDevelopment() - ? "http://localhost:5112" - : baseUrl.Replace("https://", "http://"); return new { @@ -67,58 +57,26 @@ app.MapGet("/", (IWebHostEnvironment env, HttpContext context) => status = "running", timestamp = DateTime.UtcNow, environment = env.EnvironmentName, - urls = new - { - https = httpsUrl, - http = httpUrl - }, + url = baseUrl, endpoints = new { - health = "/api/health", - video_details = "/api/video/details?url={youtubeUrl}", + health = "/health", + api_health = "/api/health", process_video = "/api/video/process", check_status = "/api/video/status/{jobId}", get_result = "/api/video/result/{jobId}", - download = "/api/download/{jobId}", - download_by_session = "/api/download/{sessionId}/{jobId}", + download = "/api/download/{sessionId}/{jobId}", download_session_zip = "/api/download/zip/{sessionId}", - session_zip_info = "/api/download/zip/info/{sessionId}", - session_create = "/api/session/create", - session_validate = "/api/session/validate/{sessionId}", - static_files = "/downloads/{fileName}", - openapi = "/openapi/v1.json" - }, - features = new - { - session_management = true, - automatic_zip_creation = true, - session_based_organization = true, - backward_compatibility = true - }, - testing = new - { - httpFile = "Use api-tests.http to test endpoints", - startWith = "GET /api/health to verify API is running" + session_create = "/api/session/create" } }; }); app.MapControllers(); +app.MapHealthChecks("/health"); +app.MapFallbackToFile("index.html"); -// Log startup information with dynamic URLs -var isDevelopment = app.Environment.IsDevelopment(); -var httpsLogUrl = isDevelopment ? "https://localhost:7160" : "[Production HTTPS URL]"; -var httpLogUrl = isDevelopment ? "http://localhost:5112" : "[Production HTTP URL]"; - -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: {HttpsUrl}", httpsLogUrl); -app.Logger.LogInformation(" πŸ”“ HTTP: {HttpUrl}", httpLogUrl); -app.Logger.LogInformation("❀️ Health check: /api/health"); -app.Logger.LogInformation("ℹ️ API info: /"); -app.Logger.LogInformation("πŸ“ Static files served from: /downloads/*"); -app.Logger.LogInformation("πŸ”— Session-based downloads supported"); -app.Logger.LogInformation("πŸ“¦ Automatic ZIP file creation enabled"); +app.Logger.LogInformation("TuneRipper is starting in {Environment}", app.Environment.EnvironmentName); +app.Logger.LogInformation("Health check: /health"); app.Run(); diff --git a/TuneRipper.Api/Services/ISessionService.cs b/TuneRipper.Api/Services/ISessionService.cs index 1695b04..dfaeb20 100644 --- a/TuneRipper.Api/Services/ISessionService.cs +++ b/TuneRipper.Api/Services/ISessionService.cs @@ -6,6 +6,7 @@ public interface ISessionService string GetOrCreateSession(string? sessionId); bool IsValidSession(string sessionId); void CleanupExpiredSessions(); + string GetDownloadsRootPath(); string GetSessionDownloadsPath(string sessionId); void EnsureSessionDirectoryExists(string sessionId); -} \ No newline at end of file +} diff --git a/TuneRipper.Api/Services/SessionService.cs b/TuneRipper.Api/Services/SessionService.cs index 6a99063..ef856ec 100644 --- a/TuneRipper.Api/Services/SessionService.cs +++ b/TuneRipper.Api/Services/SessionService.cs @@ -1,21 +1,32 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using TuneRipper.Api.Configuration; namespace TuneRipper.Api.Services; -public class SessionService : ISessionService +public class SessionService : ISessionService, IDisposable { - private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; + private readonly string _downloadsRoot; private readonly ConcurrentDictionary _sessions = new(); private readonly Timer _cleanupTimer; // Session expires after 24 hours of inactivity private readonly TimeSpan _sessionTimeout = TimeSpan.FromHours(24); - public SessionService(IWebHostEnvironment environment, ILogger logger) + public SessionService( + IWebHostEnvironment environment, + IOptions options, + ILogger logger) { - _environment = environment; _logger = logger; + _downloadsRoot = Path.GetFullPath( + string.IsNullOrWhiteSpace(options.Value.DownloadsPath) + ? Path.Combine(environment.WebRootPath, "downloads") + : options.Value.DownloadsPath); + + Directory.CreateDirectory(_downloadsRoot); + RestoreSessionsFromDisk(); // Clean up expired sessions every hour _cleanupTimer = new Timer( @@ -46,8 +57,7 @@ public class SessionService : ISessionService { if (!string.IsNullOrWhiteSpace(sessionId) && IsValidSession(sessionId)) { - // Update last activity time - _sessions[sessionId] = DateTime.UtcNow; + TouchSession(sessionId); return sessionId; } @@ -59,7 +69,17 @@ public class SessionService : ISessionService if (string.IsNullOrWhiteSpace(sessionId)) return false; - if (_sessions.TryGetValue(sessionId, out var lastActivity)) + if (!_sessions.TryGetValue(sessionId, out var lastActivity)) + { + var sessionPath = GetSessionDownloadsPath(sessionId); + if (Directory.Exists(sessionPath)) + { + lastActivity = Directory.GetLastWriteTimeUtc(sessionPath); + _sessions.TryAdd(sessionId, lastActivity); + } + } + + if (_sessions.TryGetValue(sessionId, out lastActivity)) { // Check if session has expired if (DateTime.UtcNow - lastActivity > _sessionTimeout) @@ -118,10 +138,11 @@ public class SessionService : ISessionService } } + public string GetDownloadsRootPath() => _downloadsRoot; + public string GetSessionDownloadsPath(string sessionId) { - var downloadsRoot = Path.Combine(_environment.WebRootPath, "downloads"); - return Path.Combine(downloadsRoot, sessionId); + return Path.Combine(_downloadsRoot, sessionId); } /// @@ -150,10 +171,43 @@ public class SessionService : ISessionService Directory.CreateDirectory(sessionPath); _logger.LogInformation("Created session directory for session: {SessionId} at {Path}", sessionId, sessionPath); } + + TouchSession(sessionId); + } + + private void RestoreSessionsFromDisk() + { + foreach (var sessionPath in Directory.EnumerateDirectories(_downloadsRoot)) + { + var sessionId = Path.GetFileName(sessionPath); + var lastActivity = Directory.GetLastWriteTimeUtc(sessionPath); + + if (DateTime.UtcNow - lastActivity <= _sessionTimeout) + { + _sessions[sessionId] = lastActivity; + } + } + + _logger.LogInformation( + "Restored {Count} active sessions from {DownloadsRoot}", + _sessions.Count, + _downloadsRoot); + } + + private void TouchSession(string sessionId) + { + var now = DateTime.UtcNow; + _sessions[sessionId] = now; + + var sessionPath = GetSessionDownloadsPath(sessionId); + if (Directory.Exists(sessionPath)) + { + Directory.SetLastWriteTimeUtc(sessionPath, now); + } } public void Dispose() { _cleanupTimer?.Dispose(); } -} \ No newline at end of file +} diff --git a/TuneRipper.Api/Services/VideoService.cs b/TuneRipper.Api/Services/VideoService.cs index a2dd0a5..b0291d5 100644 --- a/TuneRipper.Api/Services/VideoService.cs +++ b/TuneRipper.Api/Services/VideoService.cs @@ -1,7 +1,6 @@ using TuneRipper.Shared.Models; -using System.Text.RegularExpressions; -using System.Net.Http; -using System.Threading.Tasks; +using TuneRipper.Api.Configuration; +using Microsoft.Extensions.Options; using System.Text.RegularExpressions; using System.Diagnostics; using System.Text; @@ -21,15 +20,19 @@ public class VideoService : IVideoService { private readonly ILogger _logger; private readonly HttpClient _httpClient; - private readonly IWebHostEnvironment _environment; private readonly ISessionService _sessionService; + private readonly YtDlpOptions _options; - public VideoService(ILogger logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory, ISessionService sessionService) + public VideoService( + ILogger logger, + IHttpClientFactory httpClientFactory, + ISessionService sessionService, + IOptions options) { _logger = logger; _httpClient = httpClientFactory.CreateClient(); - _environment = environment; _sessionService = sessionService; + _options = options.Value; } public async Task GetVideoDetailsAsync(string url) @@ -97,11 +100,20 @@ public class VideoService : IVideoService { try { - var ytDlpPath = Path.Combine(_environment.WebRootPath, "yt-dlp.exe"); - - if (!File.Exists(ytDlpPath)) + var ytDlpPath = _options.ExecutablePath; + + if (Path.IsPathRooted(ytDlpPath) && !File.Exists(ytDlpPath)) { - throw new FileNotFoundException($"yt-dlp.exe not found at {ytDlpPath}"); + throw new FileNotFoundException($"yt-dlp not found at {ytDlpPath}"); + } + + var supportedFormats = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "mp3", "m4a", "opus", "wav", "flac" + }; + if (!supportedFormats.Contains(format)) + { + throw new ArgumentException($"Unsupported audio format: {format}", nameof(format)); } // Ensure session directory exists only when we're about to save the first MP3 file @@ -120,29 +132,33 @@ public class VideoService : IVideoService _logger.LogInformation("Processing video with filename pattern: {FileName} in session: {SessionId}", customFileName, sessionId); - // Build yt-dlp arguments - var arguments = new StringBuilder(); - arguments.Append($"--extract-audio "); - arguments.Append($"--audio-format {format} "); - arguments.Append($"--audio-quality 0 "); - arguments.Append("--no-playlist "); - arguments.Append("--no-warnings "); - arguments.Append("--newline "); - arguments.Append($"--output \"{outputTemplate}\" "); - arguments.Append($"\"{url}\""); - - _logger.LogInformation("Starting yt-dlp process with arguments: {Args}", arguments.ToString()); - var processInfo = new ProcessStartInfo { FileName = ytDlpPath, - Arguments = arguments.ToString(), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WorkingDirectory = downloadDir }; + processInfo.ArgumentList.Add("--extract-audio"); + processInfo.ArgumentList.Add("--audio-format"); + processInfo.ArgumentList.Add(format); + processInfo.ArgumentList.Add("--audio-quality"); + processInfo.ArgumentList.Add("0"); + processInfo.ArgumentList.Add("--no-playlist"); + processInfo.ArgumentList.Add("--no-warnings"); + processInfo.ArgumentList.Add("--newline"); + processInfo.ArgumentList.Add("--ffmpeg-location"); + processInfo.ArgumentList.Add(_options.FfmpegExecutablePath); + processInfo.ArgumentList.Add("--output"); + processInfo.ArgumentList.Add(outputTemplate); + processInfo.ArgumentList.Add(url); + + _logger.LogInformation( + "Starting yt-dlp for job {JobId} with format {Format}", + jobId, + format); using var process = Process.Start(processInfo); if (process == null) @@ -178,13 +194,22 @@ public class VideoService : IVideoService process.BeginOutputReadLine(); process.BeginErrorReadLine(); - // Wait for process completion with timeout - var completed = await Task.Run(() => process.WaitForExit(300000)); // 5 minute timeout - - if (!completed) + using var timeout = new CancellationTokenSource( + TimeSpan.FromSeconds(Math.Max(30, _options.ProcessTimeoutSeconds))); + + try { - process.Kill(); - throw new TimeoutException("yt-dlp process timed out after 5 minutes"); + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + throw new TimeoutException( + $"yt-dlp process timed out after {_options.ProcessTimeoutSeconds} seconds"); } if (process.ExitCode != 0) @@ -463,4 +488,4 @@ public class VideoService : IVideoService return string.Empty; } -} \ No newline at end of file +} diff --git a/TuneRipper.Api/TuneRipper.Api.csproj b/TuneRipper.Api/TuneRipper.Api.csproj index 69a8952..01fe21b 100644 --- a/TuneRipper.Api/TuneRipper.Api.csproj +++ b/TuneRipper.Api/TuneRipper.Api.csproj @@ -7,7 +7,7 @@ - + diff --git a/TuneRipper.Api/appsettings.json b/TuneRipper.Api/appsettings.json index 10f68b8..96d2480 100644 --- a/TuneRipper.Api/appsettings.json +++ b/TuneRipper.Api/appsettings.json @@ -5,5 +5,13 @@ "Microsoft.AspNetCore": "Warning" } }, + "YtDlp": { + "ExecutablePath": "yt-dlp", + "FfmpegExecutablePath": "ffmpeg", + "ProcessTimeoutSeconds": 900 + }, + "Storage": { + "DownloadsPath": "" + }, "AllowedHosts": "*" } diff --git a/TuneRipper/Extensions/ServiceCollectionExtensions.cs b/TuneRipper/Extensions/ServiceCollectionExtensions.cs index 3039d6c..68e5bda 100644 --- a/TuneRipper/Extensions/ServiceCollectionExtensions.cs +++ b/TuneRipper/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Components; using TuneRipper.Services; using TuneRipper.Shared.Configuration; @@ -17,21 +18,22 @@ public static class ServiceCollectionExtensions services.AddScoped(sp => { var apiSettings = configuration.GetSection(ApiSettings.SectionName).Get(); + var navigation = sp.GetRequiredService(); var client = new HttpClient(); - - // Try HTTPS first, fallback to HTTP if configured - if (!string.IsNullOrEmpty(apiSettings?.BaseUrl)) + + if (Uri.TryCreate(apiSettings?.BaseUrl, UriKind.Absolute, out var absoluteBaseUrl)) { - client.BaseAddress = new Uri(apiSettings.BaseUrl); + client.BaseAddress = absoluteBaseUrl; } - else if (!string.IsNullOrEmpty(apiSettings?.HttpBaseUrl)) + else if (Uri.TryCreate(apiSettings?.HttpBaseUrl, UriKind.Absolute, out var absoluteHttpBaseUrl)) { - client.BaseAddress = new Uri(apiSettings.HttpBaseUrl); + client.BaseAddress = absoluteHttpBaseUrl; } else { - // Default fallback - client.BaseAddress = new Uri("https://localhost:7160"); + client.BaseAddress = new Uri( + new Uri(navigation.BaseUri), + string.IsNullOrWhiteSpace(apiSettings?.BaseUrl) ? "/" : apiSettings.BaseUrl); } client.Timeout = TimeSpan.FromSeconds(apiSettings?.TimeoutSeconds ?? 30); @@ -47,4 +49,4 @@ public static class ServiceCollectionExtensions return services; } -} \ No newline at end of file +} diff --git a/TuneRipper/Services/TuneRipperApiService.cs b/TuneRipper/Services/TuneRipperApiService.cs index 2efffbf..0c3c54c 100644 --- a/TuneRipper/Services/TuneRipperApiService.cs +++ b/TuneRipper/Services/TuneRipperApiService.cs @@ -63,8 +63,8 @@ public class TuneRipperApiService : ITuneRipperApiService PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - // Configure HttpClient - _httpClient.BaseAddress = new Uri(_apiSettings.BaseUrl ?? "https://localhost:7160"); + // HttpClient base address is resolved by the WASM host. In production this is + // the same origin that serves both the client and the API. _httpClient.Timeout = TimeSpan.FromSeconds(_apiSettings.TimeoutSeconds); if (_apiSettings.EnableLogging) @@ -587,11 +587,11 @@ public class TuneRipperApiService : ITuneRipperApiService public string GetBaseUrl() { - return _httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "https://localhost:7160"; + return _httpClient.BaseAddress?.ToString().TrimEnd('/') ?? string.Empty; } public Task GetHttpClientAsync() { return Task.FromResult(_httpClient); } -} \ No newline at end of file +} diff --git a/TuneRipper/wwwroot/appsettings.json b/TuneRipper/wwwroot/appsettings.json index c55d058..48dd55d 100644 --- a/TuneRipper/wwwroot/appsettings.json +++ b/TuneRipper/wwwroot/appsettings.json @@ -1,7 +1,7 @@ { "ApiSettings": { - "BaseUrl": "https://localhost:7160", - "HttpBaseUrl": "http://localhost:5112", + "BaseUrl": "/", + "HttpBaseUrl": null, "TimeoutSeconds": 30, "EnableLogging": true, "RetryAttempts": 3, @@ -13,4 +13,4 @@ "Environment": "Development", "EnableDetailedErrors": true } -} \ No newline at end of file +}