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
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
/TuneRipper.Api/wwwroot/downloads
|
/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]")]
|
[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))
|
||||||
@@ -602,4 +600,4 @@ public class DownloadController : ControllerBase
|
|||||||
}
|
}
|
||||||
return $"{len:0.##} {sizes[order]}";
|
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);
|
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,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();
|
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
|
||||||
{
|
{
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +171,43 @@ 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()
|
||||||
{
|
{
|
||||||
_cleanupTimer?.Dispose();
|
_cleanupTimer?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -463,4 +488,4 @@ public class VideoService : IVideoService
|
|||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -47,4 +49,4 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +587,11 @@ 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()
|
||||||
{
|
{
|
||||||
return Task.FromResult(_httpClient);
|
return Task.FromResult(_httpClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -13,4 +13,4 @@
|
|||||||
"Environment": "Development",
|
"Environment": "Development",
|
||||||
"EnableDetailedErrors": true
|
"EnableDetailedErrors": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user