Prepare Linux deployment with yt-dlp and persistent downloads

This commit is contained in:
Michal Vrba
2026-10-19 17:23:30 +02:00
parent 471010c536
commit a26c584462
15 changed files with 331 additions and 139 deletions
+7
View File
@@ -0,0 +1,7 @@
**/bin
**/obj
.git
.vs
artifacts
README.md
DEPLOYMENT_GUIDE_IIS_Windows11.md
+1
View File
@@ -362,3 +362,4 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
/TuneRipper.Api/wwwroot/downloads /TuneRipper.Api/wwwroot/downloads
/data
+50
View File
@@ -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"]
@@ -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;
}
@@ -7,13 +7,11 @@ namespace TuneRipper.Api.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
public class DownloadController : ControllerBase public class DownloadController : ControllerBase
{ {
private readonly IWebHostEnvironment _environment;
private readonly ILogger<DownloadController> _logger; private readonly ILogger<DownloadController> _logger;
private readonly ISessionService _sessionService; private readonly ISessionService _sessionService;
public DownloadController(IWebHostEnvironment environment, ILogger<DownloadController> logger, ISessionService sessionService) public DownloadController(ILogger<DownloadController> logger, ISessionService sessionService)
{ {
_environment = environment;
_logger = logger; _logger = logger;
_sessionService = sessionService; _sessionService = sessionService;
} }
@@ -203,7 +201,7 @@ public class DownloadController : ControllerBase
try try
{ {
// Check in the legacy downloads directory first // Check in the legacy downloads directory first
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); var downloadsDir = _sessionService.GetDownloadsRootPath();
if (Directory.Exists(downloadsDir)) if (Directory.Exists(downloadsDir))
{ {
@@ -389,7 +387,7 @@ public class DownloadController : ControllerBase
try try
{ {
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); var downloadsDir = _sessionService.GetDownloadsRootPath();
if (!Directory.Exists(downloadsDir)) if (!Directory.Exists(downloadsDir))
{ {
@@ -529,7 +527,7 @@ public class DownloadController : ControllerBase
try try
{ {
var downloadsDir = Path.Combine(_environment.WebRootPath, "downloads"); var downloadsDir = _sessionService.GetDownloadsRootPath();
var deletedCount = 0; var deletedCount = 0;
if (!Directory.Exists(downloadsDir)) if (!Directory.Exists(downloadsDir))
@@ -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<YtDlpOptions> options) : IHealthCheck
{
public async Task<HealthCheckResult> 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);
}
}
}
+31 -73
View File
@@ -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); var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllers();
// Add HttpClient services
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddOpenApi();
// Register services builder.Services.Configure<YtDlpOptions>(builder.Configuration.GetSection(YtDlpOptions.SectionName));
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection(StorageOptions.SectionName));
builder.Services.AddScoped<IVideoService, VideoService>(); builder.Services.AddScoped<IVideoService, VideoService>();
builder.Services.AddSingleton<ISessionService, SessionService>(); // Register session service as singleton builder.Services.AddSingleton<ISessionService, SessionService>();
builder.Services.AddHealthChecks().AddCheck<ExternalToolsHealthCheck>("external_tools");
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// Add CORS for Blazor WebAssembly client
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("BlazorPolicy", policy => options.AddPolicy("DevelopmentClient", policy =>
{ {
policy.AllowAnyOrigin() policy.AllowAnyOrigin()
.AllowAnyMethod() .AllowAnyMethod()
@@ -23,43 +31,25 @@ builder.Services.AddCors(options =>
}); });
}); });
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. app.UseForwardedHeaders();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();
app.UseHttpsRedirection();
app.UseCors("DevelopmentClient");
} }
app.UseHttpsRedirection();
// Enable static files serving (for downloading MP3 files)
app.UseStaticFiles(); app.UseStaticFiles();
// Enable CORS
app.UseCors("BlazorPolicy");
app.UseAuthorization(); app.UseAuthorization();
// Add root endpoint with startup info app.MapGet("/api", (IWebHostEnvironment env, HttpContext context) =>
app.MapGet("/", (IWebHostEnvironment env, HttpContext context) =>
{ {
// Get the current request's base URL
var request = context.Request; var request = context.Request;
var baseUrl = $"{request.Scheme}://{request.Host}"; 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 return new
{ {
service = "TuneRipper API", service = "TuneRipper API",
@@ -67,58 +57,26 @@ app.MapGet("/", (IWebHostEnvironment env, HttpContext context) =>
status = "running", status = "running",
timestamp = DateTime.UtcNow, timestamp = DateTime.UtcNow,
environment = env.EnvironmentName, environment = env.EnvironmentName,
urls = new url = baseUrl,
{
https = httpsUrl,
http = httpUrl
},
endpoints = new endpoints = new
{ {
health = "/api/health", health = "/health",
video_details = "/api/video/details?url={youtubeUrl}", api_health = "/api/health",
process_video = "/api/video/process", process_video = "/api/video/process",
check_status = "/api/video/status/{jobId}", check_status = "/api/video/status/{jobId}",
get_result = "/api/video/result/{jobId}", get_result = "/api/video/result/{jobId}",
download = "/api/download/{jobId}", download = "/api/download/{sessionId}/{jobId}",
download_by_session = "/api/download/{sessionId}/{jobId}",
download_session_zip = "/api/download/zip/{sessionId}", download_session_zip = "/api/download/zip/{sessionId}",
session_zip_info = "/api/download/zip/info/{sessionId}", session_create = "/api/session/create"
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"
} }
}; };
}); });
app.MapControllers(); app.MapControllers();
app.MapHealthChecks("/health");
app.MapFallbackToFile("index.html");
// Log startup information with dynamic URLs app.Logger.LogInformation("TuneRipper is starting in {Environment}", app.Environment.EnvironmentName);
var isDevelopment = app.Environment.IsDevelopment(); app.Logger.LogInformation("Health check: /health");
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.Run(); app.Run();
@@ -6,6 +6,7 @@ public interface ISessionService
string GetOrCreateSession(string? sessionId); string GetOrCreateSession(string? sessionId);
bool IsValidSession(string sessionId); bool IsValidSession(string sessionId);
void CleanupExpiredSessions(); void CleanupExpiredSessions();
string GetDownloadsRootPath();
string GetSessionDownloadsPath(string sessionId); string GetSessionDownloadsPath(string sessionId);
void EnsureSessionDirectoryExists(string sessionId); void EnsureSessionDirectoryExists(string sessionId);
} }
+63 -9
View File
@@ -1,21 +1,32 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using TuneRipper.Api.Configuration;
namespace TuneRipper.Api.Services; namespace TuneRipper.Api.Services;
public class SessionService : ISessionService public class SessionService : ISessionService, IDisposable
{ {
private readonly IWebHostEnvironment _environment;
private readonly ILogger<SessionService> _logger; private readonly ILogger<SessionService> _logger;
private readonly string _downloadsRoot;
private readonly ConcurrentDictionary<string, DateTime> _sessions = new(); private readonly ConcurrentDictionary<string, DateTime> _sessions = new();
private readonly Timer _cleanupTimer; private readonly Timer _cleanupTimer;
// Session expires after 24 hours of inactivity // Session expires after 24 hours of inactivity
private readonly TimeSpan _sessionTimeout = TimeSpan.FromHours(24); private readonly TimeSpan _sessionTimeout = TimeSpan.FromHours(24);
public SessionService(IWebHostEnvironment environment, ILogger<SessionService> logger) public SessionService(
IWebHostEnvironment environment,
IOptions<StorageOptions> options,
ILogger<SessionService> logger)
{ {
_environment = environment;
_logger = logger; _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 // Clean up expired sessions every hour
_cleanupTimer = new Timer( _cleanupTimer = new Timer(
@@ -46,8 +57,7 @@ public class SessionService : ISessionService
{ {
if (!string.IsNullOrWhiteSpace(sessionId) && IsValidSession(sessionId)) if (!string.IsNullOrWhiteSpace(sessionId) && IsValidSession(sessionId))
{ {
// Update last activity time TouchSession(sessionId);
_sessions[sessionId] = DateTime.UtcNow;
return sessionId; return sessionId;
} }
@@ -59,7 +69,17 @@ public class SessionService : ISessionService
if (string.IsNullOrWhiteSpace(sessionId)) if (string.IsNullOrWhiteSpace(sessionId))
return false; 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 // Check if session has expired
if (DateTime.UtcNow - lastActivity > _sessionTimeout) if (DateTime.UtcNow - lastActivity > _sessionTimeout)
@@ -118,10 +138,11 @@ public class SessionService : ISessionService
} }
} }
public string GetDownloadsRootPath() => _downloadsRoot;
public string GetSessionDownloadsPath(string sessionId) public string GetSessionDownloadsPath(string sessionId)
{ {
var downloadsRoot = Path.Combine(_environment.WebRootPath, "downloads"); return Path.Combine(_downloadsRoot, sessionId);
return Path.Combine(downloadsRoot, sessionId);
} }
/// <summary> /// <summary>
@@ -150,6 +171,39 @@ public class SessionService : ISessionService
Directory.CreateDirectory(sessionPath); Directory.CreateDirectory(sessionPath);
_logger.LogInformation("Created session directory for session: {SessionId} at {Path}", sessionId, 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() public void Dispose()
+53 -28
View File
@@ -1,7 +1,6 @@
using TuneRipper.Shared.Models; using TuneRipper.Shared.Models;
using System.Text.RegularExpressions; using TuneRipper.Api.Configuration;
using System.Net.Http; using Microsoft.Extensions.Options;
using System.Threading.Tasks;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Diagnostics; using System.Diagnostics;
using System.Text; using System.Text;
@@ -21,15 +20,19 @@ public class VideoService : IVideoService
{ {
private readonly ILogger<VideoService> _logger; private readonly ILogger<VideoService> _logger;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IWebHostEnvironment _environment;
private readonly ISessionService _sessionService; private readonly ISessionService _sessionService;
private readonly YtDlpOptions _options;
public VideoService(ILogger<VideoService> logger, IWebHostEnvironment environment, IHttpClientFactory httpClientFactory, ISessionService sessionService) public VideoService(
ILogger<VideoService> logger,
IHttpClientFactory httpClientFactory,
ISessionService sessionService,
IOptions<YtDlpOptions> options)
{ {
_logger = logger; _logger = logger;
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_environment = environment;
_sessionService = sessionService; _sessionService = sessionService;
_options = options.Value;
} }
public async Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url) public async Task<YouTubeVideoDetails?> GetVideoDetailsAsync(string url)
@@ -97,11 +100,20 @@ public class VideoService : IVideoService
{ {
try try
{ {
var ytDlpPath = Path.Combine(_environment.WebRootPath, "yt-dlp.exe"); var ytDlpPath = _options.ExecutablePath;
if (!File.Exists(ytDlpPath)) 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<string>(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 // 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); _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 var processInfo = new ProcessStartInfo
{ {
FileName = ytDlpPath, FileName = ytDlpPath,
Arguments = arguments.ToString(),
UseShellExecute = false, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
CreateNoWindow = true, CreateNoWindow = true,
WorkingDirectory = downloadDir 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); using var process = Process.Start(processInfo);
if (process == null) if (process == null)
@@ -178,13 +194,22 @@ public class VideoService : IVideoService
process.BeginOutputReadLine(); process.BeginOutputReadLine();
process.BeginErrorReadLine(); process.BeginErrorReadLine();
// Wait for process completion with timeout using var timeout = new CancellationTokenSource(
var completed = await Task.Run(() => process.WaitForExit(300000)); // 5 minute timeout TimeSpan.FromSeconds(Math.Max(30, _options.ProcessTimeoutSeconds)));
if (!completed) try
{ {
process.Kill(); await process.WaitForExitAsync(timeout.Token);
throw new TimeoutException("yt-dlp process timed out after 5 minutes"); }
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) if (process.ExitCode != 0)
+1 -1
View File
@@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+8
View File
@@ -5,5 +5,13 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"YtDlp": {
"ExecutablePath": "yt-dlp",
"FfmpegExecutablePath": "ffmpeg",
"ProcessTimeoutSeconds": 900
},
"Storage": {
"DownloadsPath": ""
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Components;
using TuneRipper.Services; using TuneRipper.Services;
using TuneRipper.Shared.Configuration; using TuneRipper.Shared.Configuration;
@@ -17,21 +18,22 @@ public static class ServiceCollectionExtensions
services.AddScoped(sp => services.AddScoped(sp =>
{ {
var apiSettings = configuration.GetSection(ApiSettings.SectionName).Get<ApiSettings>(); var apiSettings = configuration.GetSection(ApiSettings.SectionName).Get<ApiSettings>();
var navigation = sp.GetRequiredService<NavigationManager>();
var client = new HttpClient(); var client = new HttpClient();
// Try HTTPS first, fallback to HTTP if configured if (Uri.TryCreate(apiSettings?.BaseUrl, UriKind.Absolute, out var absoluteBaseUrl))
if (!string.IsNullOrEmpty(apiSettings?.BaseUrl))
{ {
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 else
{ {
// Default fallback client.BaseAddress = new Uri(
client.BaseAddress = new Uri("https://localhost:7160"); new Uri(navigation.BaseUri),
string.IsNullOrWhiteSpace(apiSettings?.BaseUrl) ? "/" : apiSettings.BaseUrl);
} }
client.Timeout = TimeSpan.FromSeconds(apiSettings?.TimeoutSeconds ?? 30); client.Timeout = TimeSpan.FromSeconds(apiSettings?.TimeoutSeconds ?? 30);
+3 -3
View File
@@ -63,8 +63,8 @@ public class TuneRipperApiService : ITuneRipperApiService
PropertyNamingPolicy = JsonNamingPolicy.CamelCase PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}; };
// Configure HttpClient // HttpClient base address is resolved by the WASM host. In production this is
_httpClient.BaseAddress = new Uri(_apiSettings.BaseUrl ?? "https://localhost:7160"); // the same origin that serves both the client and the API.
_httpClient.Timeout = TimeSpan.FromSeconds(_apiSettings.TimeoutSeconds); _httpClient.Timeout = TimeSpan.FromSeconds(_apiSettings.TimeoutSeconds);
if (_apiSettings.EnableLogging) if (_apiSettings.EnableLogging)
@@ -587,7 +587,7 @@ public class TuneRipperApiService : ITuneRipperApiService
public string GetBaseUrl() public string GetBaseUrl()
{ {
return _httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "https://localhost:7160"; return _httpClient.BaseAddress?.ToString().TrimEnd('/') ?? string.Empty;
} }
public Task<HttpClient> GetHttpClientAsync() public Task<HttpClient> GetHttpClientAsync()
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"ApiSettings": { "ApiSettings": {
"BaseUrl": "https://localhost:7160", "BaseUrl": "/",
"HttpBaseUrl": "http://localhost:5112", "HttpBaseUrl": null,
"TimeoutSeconds": 30, "TimeoutSeconds": 30,
"EnableLogging": true, "EnableLogging": true,
"RetryAttempts": 3, "RetryAttempts": 3,