Prepare Linux deployment with yt-dlp and persistent downloads
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
**/bin
|
||||
**/obj
|
||||
.git
|
||||
.vs
|
||||
artifacts
|
||||
README.md
|
||||
DEPLOYMENT_GUIDE_IIS_Windows11.md
|
||||
@@ -362,3 +362,4 @@ MigrationBackup/
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
/TuneRipper.Api/wwwroot/downloads
|
||||
/data
|
||||
|
||||
+50
@@ -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]")]
|
||||
public class DownloadController : ControllerBase
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<DownloadController> _logger;
|
||||
private readonly ISessionService _sessionService;
|
||||
|
||||
public DownloadController(IWebHostEnvironment environment, ILogger<DownloadController> logger, ISessionService sessionService)
|
||||
public DownloadController(ILogger<DownloadController> 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]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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<YtDlpOptions>(builder.Configuration.GetSection(YtDlpOptions.SectionName));
|
||||
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection(StorageOptions.SectionName));
|
||||
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 =>
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SessionService> _logger;
|
||||
private readonly string _downloadsRoot;
|
||||
private readonly ConcurrentDictionary<string, DateTime> _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<SessionService> logger)
|
||||
public SessionService(
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<StorageOptions> options,
|
||||
ILogger<SessionService> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<VideoService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
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;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_environment = environment;
|
||||
_sessionService = sessionService;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<YouTubeVideoDetails?> 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<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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"YtDlp": {
|
||||
"ExecutablePath": "yt-dlp",
|
||||
"FfmpegExecutablePath": "ffmpeg",
|
||||
"ProcessTimeoutSeconds": 900
|
||||
},
|
||||
"Storage": {
|
||||
"DownloadsPath": ""
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -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<ApiSettings>();
|
||||
var navigation = sp.GetRequiredService<NavigationManager>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HttpClient> GetHttpClientAsync()
|
||||
{
|
||||
return Task.FromResult(_httpClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user