Prepare Linux deployment with yt-dlp and persistent downloads
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user