feat: add live room metadata, uploads, proxies and backups

This commit is contained in:
2026-04-26 17:35:51 +08:00
parent 85f24f8a84
commit d2b2f714e0
38 changed files with 2371 additions and 116 deletions
@@ -418,6 +418,8 @@ public sealed partial class FfmpegService
previousResult,
previousEffectiveOutputPath,
now);
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
await recordUploadService.TryAutoUploadTaskAsync(previousTask.Id, CancellationToken.None);
}
}
catch (Exception ex)
@@ -761,6 +763,8 @@ public sealed partial class FfmpegService
recordResult,
effectiveOutputPath,
endedAt);
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
await recordUploadService.TryAutoUploadTaskAsync(currentTask.Id, CancellationToken.None);
}
await logService.WriteAsync(
@@ -894,6 +898,8 @@ public sealed partial class FfmpegService
recordResult,
effectiveOutputPath,
endedAt);
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
await recordUploadService.TryAutoUploadTaskAsync(recordTask.Id, CancellationToken.None);
}
if (!string.IsNullOrWhiteSpace(finalizationError))
@@ -1283,6 +1289,8 @@ public sealed partial class FfmpegService
recordResult,
effectiveOutputPath,
endedAt);
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
await recordUploadService.TryAutoUploadTaskAsync(recordTask.Id, CancellationToken.None);
}
await logService.WriteAsync(
@@ -25,11 +25,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
private static readonly TimeSpan OfflineForcedStopTimeout = TimeSpan.FromSeconds(8);
private static readonly TimeSpan ExceptionEmailCooldown = TimeSpan.FromHours(6);
private static readonly TimeSpan PollDispatchSpacing = TimeSpan.FromMilliseconds(400);
private static readonly TimeSpan MinimumIdleDelay = TimeSpan.FromSeconds(2);
private const int MaxConcurrentLiveRoomPolls = 2;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<LiveRoomPollingBackgroundService> _logger;
private readonly ConcurrentDictionary<string, DateTimeOffset> _exceptionEmailSentAt = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _nextPollDueAt = new();
public LiveRoomPollingBackgroundService(
IServiceScopeFactory serviceScopeFactory,
@@ -43,15 +45,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
{
while (!stoppingToken.IsCancellationRequested)
{
var delaySeconds = 60;
var delay = TimeSpan.FromSeconds(60);
try
{
using var scope = _serviceScopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(stoppingToken);
delaySeconds = settings.PollingIntervalSeconds;
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
if (storageGuardService.CheckCanStartOrResume(settings).HasEnoughSpace)
@@ -61,21 +61,64 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
if (!settings.EnableBackgroundPolling)
{
await DelayAsync(delaySeconds, stoppingToken);
await DelayAsync(TimeSpan.FromSeconds(Math.Clamp(settings.PollingIntervalSeconds, 10, 3600)), stoppingToken);
continue;
}
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var liveRoomIds = (await dbContext.LiveRooms
var now = DateTimeOffset.UtcNow;
var candidates = (await dbContext.LiveRooms
.AsNoTracking()
.Where(static item => item.IsEnabled)
.Select(static item => new { item.Id, item.UpdatedAt })
.Select(static item => new
{
item.Id,
item.UpdatedAt,
item.LastCheckedAt,
item.IsPriority,
item.PollingIntervalSecondsOverride
})
.ToListAsync(stoppingToken))
.OrderBy(static item => item.UpdatedAt)
.Select(static item => item.Id)
.ToList();
await PollLiveRoomsAsync(liveRoomIds, settings, stoppingToken);
var pollCandidates = candidates
.Select(item =>
{
var intervalSeconds = GetEffectivePollingIntervalSeconds(settings.PollingIntervalSeconds, item.PollingIntervalSecondsOverride);
var baselineDueAt = item.LastCheckedAt?.AddSeconds(intervalSeconds) ?? DateTimeOffset.MinValue;
var dueAt = _nextPollDueAt.TryGetValue(item.Id, out var scheduledDueAt) && scheduledDueAt > baselineDueAt
? scheduledDueAt
: baselineDueAt;
return new PollCandidate(item.Id, dueAt, item.IsPriority, item.UpdatedAt, intervalSeconds);
})
.ToList();
if (pollCandidates.Count == 0)
{
delay = TimeSpan.FromSeconds(Math.Clamp(settings.PollingIntervalSeconds, 10, 3600));
}
else
{
var dueCandidates = pollCandidates
.Where(item => item.DueAt <= now)
.OrderByDescending(static item => item.IsPriority)
.ThenBy(static item => item.DueAt)
.ThenBy(static item => item.UpdatedAt)
.ToList();
if (dueCandidates.Count == 0)
{
var nextDueAt = pollCandidates.Min(static item => item.DueAt);
delay = nextDueAt <= now
? MinimumIdleDelay
: ClampDelay(nextDueAt - now);
}
else
{
await PollLiveRoomsAsync(dueCandidates, settings, stoppingToken);
delay = MinimumIdleDelay;
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -117,35 +160,52 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
}
await DelayAsync(delaySeconds, stoppingToken);
await DelayAsync(delay, stoppingToken);
}
}
private static Task DelayAsync(int delaySeconds, CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delaySeconds, 10, 3600)), cancellationToken);
private static Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(ClampDelay(delay), cancellationToken);
private static TimeSpan ClampDelay(TimeSpan delay)
{
if (delay <= TimeSpan.Zero)
{
return MinimumIdleDelay;
}
return delay < MinimumIdleDelay
? MinimumIdleDelay
: delay > TimeSpan.FromHours(1)
? TimeSpan.FromHours(1)
: delay;
}
private static int GetEffectivePollingIntervalSeconds(int globalIntervalSeconds, int? overrideIntervalSeconds) =>
Math.Clamp(overrideIntervalSeconds ?? globalIntervalSeconds, 10, 3600);
private async Task PollLiveRoomsAsync(
IReadOnlyList<Guid> liveRoomIds,
IReadOnlyList<PollCandidate> liveRooms,
SystemSettingsDto settings,
CancellationToken cancellationToken)
{
if (liveRoomIds.Count == 0)
if (liveRooms.Count == 0)
{
return;
}
using var semaphore = new SemaphoreSlim(Math.Min(MaxConcurrentLiveRoomPolls, liveRoomIds.Count));
var tasks = new List<Task>(liveRoomIds.Count);
using var semaphore = new SemaphoreSlim(Math.Min(MaxConcurrentLiveRoomPolls, liveRooms.Count));
var tasks = new List<Task>(liveRooms.Count);
for (var index = 0; index < liveRoomIds.Count; index++)
for (var index = 0; index < liveRooms.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
await semaphore.WaitAsync(cancellationToken);
var liveRoomId = liveRoomIds[index];
tasks.Add(PollLiveRoomWithReleaseAsync(liveRoomId, settings, semaphore, cancellationToken));
var liveRoom = liveRooms[index];
tasks.Add(PollLiveRoomWithReleaseAsync(liveRoom, settings, semaphore, cancellationToken));
if (index < liveRoomIds.Count - 1)
if (index < liveRooms.Count - 1)
{
await Task.Delay(PollDispatchSpacing, cancellationToken);
}
@@ -155,14 +215,14 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
private async Task PollLiveRoomWithReleaseAsync(
Guid liveRoomId,
PollCandidate liveRoom,
SystemSettingsDto settings,
SemaphoreSlim semaphore,
CancellationToken cancellationToken)
{
try
{
await PollLiveRoomAsync(liveRoomId, settings, cancellationToken);
await PollLiveRoomAsync(liveRoom.LiveRoomId, liveRoom.IntervalSeconds, settings, cancellationToken);
}
finally
{
@@ -170,7 +230,11 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
}
private async Task PollLiveRoomAsync(Guid liveRoomId, SystemSettingsDto settings, CancellationToken cancellationToken)
private async Task PollLiveRoomAsync(
Guid liveRoomId,
int intervalSeconds,
SystemSettingsDto settings,
CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
@@ -186,6 +250,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
var liveRoom = await dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
if (liveRoom is null || !liveRoom.IsEnabled)
{
_nextPollDueAt.TryRemove(liveRoomId, out _);
return;
}
@@ -341,6 +406,10 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
cancellationToken: cancellationToken);
}
}
finally
{
_nextPollDueAt[liveRoomId] = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(intervalSeconds, 10, 3600));
}
}
private static async Task CompleteActiveSessionsForOfflineRoomAsync(
@@ -642,4 +711,11 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
var trimmed = value.Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private sealed record PollCandidate(
Guid LiveRoomId,
DateTimeOffset DueAt,
bool IsPriority,
DateTimeOffset UpdatedAt,
int IntervalSeconds);
}
@@ -0,0 +1,78 @@
using System.Net;
using System.Net.Security;
using System.Security.Authentication;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Infrastructure.Services;
public sealed class PlatformHttpClientFactory
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
private readonly ISystemSettingsService _systemSettingsService;
public PlatformHttpClientFactory(ISystemSettingsService systemSettingsService)
{
_systemSettingsService = systemSettingsService;
}
public async Task<HttpClient> CreateAsync(
LivePlatformType platform,
bool forceDirectConnection,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var proxy = forceDirectConnection ? null : BuildProxy(platform, settings);
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = platform == LivePlatformType.Douyin
? TimeSpan.FromMinutes(2)
: TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 8,
ConnectTimeout = DefaultConnectTimeout,
UseCookies = false,
UseProxy = proxy is not null,
Proxy = proxy,
SslOptions = new SslClientAuthenticationOptions
{
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
}
};
return new HttpClient(handler, disposeHandler: true)
{
Timeout = DefaultTimeout,
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
}
private static IWebProxy? BuildProxy(LivePlatformType platform, SystemSettingsDto settings)
{
var proxySettings = platform switch
{
LivePlatformType.Douyin => settings.DouyinProxy,
LivePlatformType.Bilibili => settings.BilibiliProxy,
LivePlatformType.Huya => settings.HuyaProxy,
_ => null
};
if (proxySettings is null || !proxySettings.Enabled || string.IsNullOrWhiteSpace(proxySettings.ProxyUrl))
{
return null;
}
if (!Uri.TryCreate(proxySettings.ProxyUrl.Trim(), UriKind.Absolute, out var proxyUri) ||
(proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException($"The configured proxy URL for {platform} is invalid: {proxySettings.ProxyUrl}");
}
return new WebProxy(proxyUri);
}
}
@@ -0,0 +1,622 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Infrastructure.Services;
public sealed class RecordUploadService
{
private readonly LiveRecorderDbContext _dbContext;
private readonly ISystemSettingsService _systemSettingsService;
private readonly ISystemLogService _systemLogService;
public RecordUploadService(
LiveRecorderDbContext dbContext,
ISystemSettingsService systemSettingsService,
ISystemLogService systemLogService)
{
_dbContext = dbContext;
_systemSettingsService = systemSettingsService;
_systemLogService = systemLogService;
}
public async Task<RecordArtifactUploadItemResultDto?> TryAutoUploadTaskAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableFileUpload || !settings.EnableAutoUpload || settings.UploadTarget == UploadTargetType.None)
{
return null;
}
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: true, cancellationToken);
}
public async Task<RecordArtifactUploadItemResultDto> UploadTaskAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: false, cancellationToken);
}
public async Task<RecordArtifactUploadBatchResultDto> UploadSessionAsync(
Guid recordSessionId,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var session = await _dbContext.RecordSessions
.AsNoTracking()
.Include(item => item.RecordTasks)
.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
if (session is null)
{
return new RecordArtifactUploadBatchResultDto
{
RequestedCount = 0,
SuccessCount = 0,
FailedCount = 1,
Items =
[
new RecordArtifactUploadItemResultDto
{
RecordTaskId = Guid.Empty,
Success = false,
Message = "Recording session was not found."
}
]
};
}
var taskIds = session.RecordTasks
.OrderBy(static item => item.SegmentIndex)
.ThenBy(static item => item.CreatedAt)
.Select(static item => item.Id)
.ToArray();
var items = new List<RecordArtifactUploadItemResultDto>(taskIds.Length);
foreach (var taskId in taskIds)
{
items.Add(await UploadTaskInternalAsync(taskId, settings, automatic: false, cancellationToken));
}
return new RecordArtifactUploadBatchResultDto
{
RequestedCount = taskIds.Length,
SuccessCount = items.Count(static item => item.Success),
FailedCount = items.Count(static item => !item.Success),
Items = items
};
}
private async Task<RecordArtifactUploadItemResultDto> UploadTaskInternalAsync(
Guid recordTaskId,
SystemSettingsDto settings,
bool automatic,
CancellationToken cancellationToken)
{
var recordTask = await _dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.RecordSession)
.Include(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
if (recordTask?.LiveRoom is null || recordTask.RecordSession is null || recordTask.Result is null)
{
return CreateFailureResult(recordTaskId, "Recording result is not ready for upload.");
}
if (!settings.EnableFileUpload || settings.UploadTarget == UploadTargetType.None)
{
return CreateFailureResult(recordTaskId, "File upload is disabled or no upload target is configured.");
}
var recordResult = recordTask.Result;
var absoluteVideoPath = NormalizeAbsolutePath(recordResult.FilePath);
var absoluteDanmakuPath = NormalizeNullablePath(recordResult.DanmakuFilePath);
var hasVideoArtifact = !string.IsNullOrWhiteSpace(absoluteVideoPath) && File.Exists(absoluteVideoPath);
var hasDanmakuArtifact = !string.IsNullOrWhiteSpace(absoluteDanmakuPath) && File.Exists(absoluteDanmakuPath);
if (!hasVideoArtifact && !hasDanmakuArtifact)
{
if (recordResult.UploadStatus == RecordArtifactUploadStatus.Succeeded)
{
return new RecordArtifactUploadItemResultDto
{
RecordTaskId = recordTaskId,
Success = true,
Message = "Artifacts were uploaded previously and local files are no longer available.",
Provider = recordResult.LastUploadProvider,
RemoteVideoPath = recordResult.RemoteVideoPath,
RemoteDanmakuPath = recordResult.RemoteDanmakuPath,
DeletedLocalFilesAfterUpload = recordResult.DeletedLocalFilesAfterUpload
};
}
return CreateFailureResult(recordTaskId, "No local recording artifacts are available for upload.");
}
var uploader = CreateUploader(settings);
try
{
var remoteVideoPath = recordResult.RemoteVideoPath;
var remoteDanmakuPath = recordResult.RemoteDanmakuPath;
if (hasVideoArtifact)
{
var relativeVideoPath = BuildRelativeRemotePath(settings.OutputRoot, absoluteVideoPath!);
remoteVideoPath = await uploader.UploadFileAsync(absoluteVideoPath!, relativeVideoPath, cancellationToken);
}
if (hasDanmakuArtifact)
{
var relativeDanmakuPath = BuildRelativeRemotePath(settings.OutputRoot, absoluteDanmakuPath!);
remoteDanmakuPath = await uploader.UploadFileAsync(absoluteDanmakuPath!, relativeDanmakuPath, cancellationToken);
}
var deletedLocalFiles = false;
string? deletionWarning = null;
if (settings.DeleteLocalFilesAfterUpload)
{
try
{
deletedLocalFiles = TryDeleteUploadedArtifacts(absoluteVideoPath, hasVideoArtifact, absoluteDanmakuPath, hasDanmakuArtifact);
}
catch (Exception ex)
{
deletionWarning = ex.Message;
}
}
recordResult.MarkUploadSucceeded(
uploader.ProviderName,
remoteVideoPath,
remoteDanmakuPath,
deletedLocalFiles,
DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
deletionWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
"Upload",
automatic ? "Automatic artifact upload completed." : "Artifact upload completed.",
BuildUploadLogDetail(uploader.ProviderName, remoteVideoPath, remoteDanmakuPath, deletedLocalFiles, deletionWarning),
liveRoomId: recordTask.LiveRoomId,
recordSessionId: recordTask.RecordSessionId,
recordTaskId: recordTask.Id,
cancellationToken: cancellationToken);
return new RecordArtifactUploadItemResultDto
{
RecordTaskId = recordTask.Id,
Success = true,
Message = deletionWarning is null
? "Upload completed successfully."
: $"Upload completed, but local cleanup was not fully successful: {deletionWarning}",
Provider = uploader.ProviderName,
RemoteVideoPath = remoteVideoPath,
RemoteDanmakuPath = remoteDanmakuPath,
DeletedLocalFilesAfterUpload = deletedLocalFiles
};
}
catch (Exception ex)
{
recordResult.MarkUploadFailed(uploader.ProviderName, ex.Message, DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
automatic ? SystemLogLevel.Warning : SystemLogLevel.Error,
"Upload",
automatic ? "Automatic artifact upload failed." : "Artifact upload failed.",
ex.ToString(),
liveRoomId: recordTask.LiveRoomId,
recordSessionId: recordTask.RecordSessionId,
recordTaskId: recordTask.Id,
cancellationToken: cancellationToken);
return CreateFailureResult(recordTask.Id, ex.Message, uploader.ProviderName);
}
}
private static string BuildUploadLogDetail(
string provider,
string? remoteVideoPath,
string? remoteDanmakuPath,
bool deletedLocalFiles,
string? deletionWarning)
{
var builder = new StringBuilder();
builder.Append("provider=").Append(provider);
if (!string.IsNullOrWhiteSpace(remoteVideoPath))
{
builder.Append("; video=").Append(remoteVideoPath);
}
if (!string.IsNullOrWhiteSpace(remoteDanmakuPath))
{
builder.Append("; danmaku=").Append(remoteDanmakuPath);
}
builder.Append("; deletedLocalFiles=").Append(deletedLocalFiles);
if (!string.IsNullOrWhiteSpace(deletionWarning))
{
builder.Append("; cleanupWarning=").Append(deletionWarning);
}
return builder.ToString();
}
private static bool TryDeleteUploadedArtifacts(
string? absoluteVideoPath,
bool hasVideoArtifact,
string? absoluteDanmakuPath,
bool hasDanmakuArtifact)
{
var deletedAny = false;
if (hasVideoArtifact && !string.IsNullOrWhiteSpace(absoluteVideoPath) && File.Exists(absoluteVideoPath))
{
File.Delete(absoluteVideoPath);
deletedAny = true;
}
if (hasDanmakuArtifact && !string.IsNullOrWhiteSpace(absoluteDanmakuPath) && File.Exists(absoluteDanmakuPath))
{
File.Delete(absoluteDanmakuPath);
deletedAny = true;
}
return deletedAny;
}
private static string BuildRelativeRemotePath(string outputRoot, string absolutePath)
{
var absoluteOutputRoot = Path.GetFullPath(outputRoot, AppContext.BaseDirectory);
var relativePath = Path.GetRelativePath(absoluteOutputRoot, absolutePath);
if (relativePath.StartsWith("..", StringComparison.Ordinal))
{
relativePath = Path.GetFileName(absolutePath);
}
return relativePath
.Replace('\\', '/')
.TrimStart('/');
}
private static string NormalizeAbsolutePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
return Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, AppContext.BaseDirectory);
}
private static string? NormalizeNullablePath(string? path) =>
string.IsNullOrWhiteSpace(path) ? null : NormalizeAbsolutePath(path);
private static RecordArtifactUploadItemResultDto CreateFailureResult(
Guid recordTaskId,
string message,
string? provider = null) =>
new()
{
RecordTaskId = recordTaskId,
Success = false,
Message = message,
Provider = provider
};
private static IRecordArtifactUploader CreateUploader(SystemSettingsDto settings) =>
settings.UploadTarget switch
{
UploadTargetType.WebDav => new WebDavRecordArtifactUploader(settings.WebDavUpload),
UploadTargetType.S3 => new S3RecordArtifactUploader(settings.S3Upload),
_ => throw new InvalidOperationException("No supported upload target is configured.")
};
}
internal interface IRecordArtifactUploader
{
string ProviderName { get; }
Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken);
}
internal sealed class WebDavRecordArtifactUploader : IRecordArtifactUploader
{
private readonly WebDavUploadSettingsDto _settings;
public WebDavRecordArtifactUploader(WebDavUploadSettingsDto settings)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string ProviderName => "webdav";
public async Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.Endpoint))
{
throw new InvalidOperationException("WebDAV endpoint is not configured.");
}
if (!Uri.TryCreate(EnsureTrailingSlash(_settings.Endpoint.Trim()), UriKind.Absolute, out var endpointUri))
{
throw new InvalidOperationException("WebDAV endpoint is invalid.");
}
var remotePath = CombineRemotePath(_settings.BasePath, relativeRemotePath);
var fileUri = BuildWebDavUri(endpointUri, remotePath);
using var client = new HttpClient
{
Timeout = TimeSpan.FromMinutes(10)
};
var authHeader = BuildBasicAuthorization(_settings.Username, _settings.Password);
await EnsureCollectionsAsync(client, endpointUri, remotePath, authHeader, cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Put, fileUri);
if (!string.IsNullOrWhiteSpace(authHeader))
{
request.Headers.TryAddWithoutValidation("Authorization", authHeader);
}
request.Content = new StreamContent(File.OpenRead(localPath));
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Created and not HttpStatusCode.NoContent and not HttpStatusCode.OK)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
throw new InvalidOperationException($"WebDAV upload failed with status {(int)response.StatusCode}: {errorBody}");
}
return fileUri.ToString();
}
private static async Task EnsureCollectionsAsync(
HttpClient client,
Uri endpointUri,
string remotePath,
string? authorization,
CancellationToken cancellationToken)
{
var segments = remotePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length <= 1)
{
return;
}
var builder = new List<string>(segments.Length);
for (var index = 0; index < segments.Length - 1; index++)
{
builder.Add(segments[index]);
var collectionUri = BuildWebDavUri(endpointUri, string.Join('/', builder) + "/");
using var request = new HttpRequestMessage(new HttpMethod("MKCOL"), collectionUri);
if (!string.IsNullOrWhiteSpace(authorization))
{
request.Headers.TryAddWithoutValidation("Authorization", authorization);
}
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode is HttpStatusCode.Created or HttpStatusCode.MethodNotAllowed or HttpStatusCode.Conflict or HttpStatusCode.OK or HttpStatusCode.NoContent)
{
continue;
}
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new InvalidOperationException($"WebDAV MKCOL failed with status {(int)response.StatusCode}: {body}");
}
}
private static Uri BuildWebDavUri(Uri endpointUri, string remotePath)
{
var encodedPath = string.Join(
'/',
remotePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Uri.EscapeDataString));
return new Uri(endpointUri, encodedPath);
}
private static string CombineRemotePath(string? basePath, string relativeRemotePath)
{
var parts = new[]
{
basePath?.Trim(),
relativeRemotePath.Trim()
}
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item!.Trim('/'))
.ToArray();
return string.Join('/', parts);
}
private static string? BuildBasicAuthorization(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) && string.IsNullOrWhiteSpace(password))
{
return null;
}
var raw = $"{username}:{password}";
return $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))}";
}
private static string EnsureTrailingSlash(string value) =>
value.EndsWith("/", StringComparison.Ordinal) ? value : value + "/";
}
internal sealed class S3RecordArtifactUploader : IRecordArtifactUploader
{
private readonly S3UploadSettingsDto _settings;
public S3RecordArtifactUploader(S3UploadSettingsDto settings)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string ProviderName => "s3";
public async Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken)
{
ValidateSettings();
var objectKey = BuildObjectKey(_settings.Prefix, relativeRemotePath);
var requestUri = BuildRequestUri(_settings, objectKey);
var now = DateTimeOffset.UtcNow;
var amzDate = now.ToString("yyyyMMdd'T'HHmmss'Z'");
var dateStamp = now.ToString("yyyyMMdd");
var region = string.IsNullOrWhiteSpace(_settings.Region) ? "us-east-1" : _settings.Region.Trim();
var payloadHash = await ComputeFileHashAsync(localPath, cancellationToken);
var canonicalUri = BuildCanonicalUri(_settings, objectKey);
var hostHeader = requestUri.IsDefaultPort ? requestUri.Host : $"{requestUri.Host}:{requestUri.Port}";
const string signedHeaders = "host;x-amz-content-sha256;x-amz-date";
var canonicalHeaders = $"host:{hostHeader}\n" +
$"x-amz-content-sha256:{payloadHash}\n" +
$"x-amz-date:{amzDate}\n";
var canonicalRequest = $"PUT\n{canonicalUri}\n\n{canonicalHeaders}\n{signedHeaders}\n{payloadHash}";
var credentialScope = $"{dateStamp}/{region}/s3/aws4_request";
var stringToSign = "AWS4-HMAC-SHA256\n" +
$"{amzDate}\n" +
$"{credentialScope}\n" +
$"{ComputeSha256Hex(canonicalRequest)}";
var signature = ComputeAwsSignature(_settings.SecretKey, dateStamp, region, stringToSign);
var authorization = $"AWS4-HMAC-SHA256 Credential={_settings.AccessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}";
using var client = new HttpClient
{
Timeout = TimeSpan.FromMinutes(10)
};
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
request.Headers.TryAddWithoutValidation("x-amz-content-sha256", payloadHash);
request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
request.Headers.TryAddWithoutValidation("Authorization", authorization);
request.Content = new StreamContent(File.OpenRead(localPath));
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.OK and not HttpStatusCode.Created and not HttpStatusCode.NoContent)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
throw new InvalidOperationException($"S3 upload failed with status {(int)response.StatusCode}: {errorBody}");
}
return requestUri.ToString();
}
private void ValidateSettings()
{
if (string.IsNullOrWhiteSpace(_settings.Endpoint))
{
throw new InvalidOperationException("S3 endpoint is not configured.");
}
if (string.IsNullOrWhiteSpace(_settings.Bucket))
{
throw new InvalidOperationException("S3 bucket is not configured.");
}
if (string.IsNullOrWhiteSpace(_settings.AccessKey) || string.IsNullOrWhiteSpace(_settings.SecretKey))
{
throw new InvalidOperationException("S3 access key or secret key is not configured.");
}
}
private static Uri BuildRequestUri(S3UploadSettingsDto settings, string objectKey)
{
if (!Uri.TryCreate(settings.Endpoint.Trim(), UriKind.Absolute, out var endpointUri))
{
throw new InvalidOperationException("S3 endpoint is invalid.");
}
var encodedKey = string.Join('/', objectKey.Split('/').Select(Uri.EscapeDataString));
if (settings.ForcePathStyle)
{
var basePath = endpointUri.AbsolutePath.TrimEnd('/');
var combinedPath = $"{basePath}/{Uri.EscapeDataString(settings.Bucket.Trim())}/{encodedKey}".Replace("//", "/", StringComparison.Ordinal);
return new UriBuilder(endpointUri)
{
Path = combinedPath
}.Uri;
}
return new UriBuilder(endpointUri)
{
Host = $"{settings.Bucket.Trim()}.{endpointUri.Host}",
Path = encodedKey
}.Uri;
}
private static string BuildCanonicalUri(S3UploadSettingsDto settings, string objectKey)
{
var encodedKey = string.Join('/', objectKey.Split('/').Select(Uri.EscapeDataString));
if (settings.ForcePathStyle)
{
return "/" + Uri.EscapeDataString(settings.Bucket.Trim()) + "/" + encodedKey;
}
return "/" + encodedKey;
}
private static string BuildObjectKey(string? prefix, string relativeRemotePath)
{
var parts = new[]
{
prefix?.Trim(),
relativeRemotePath.Trim()
}
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item!.Trim('/'))
.ToArray();
return string.Join('/', parts);
}
private static async Task<string> ComputeFileHashAsync(string localPath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(localPath);
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
return ConvertToHex(hash);
}
private static string ComputeSha256Hex(string content)
{
using var sha256 = SHA256.Create();
return ConvertToHex(sha256.ComputeHash(Encoding.UTF8.GetBytes(content)));
}
private static string ComputeAwsSignature(string secretKey, string dateStamp, string region, string stringToSign)
{
var secret = Encoding.UTF8.GetBytes("AWS4" + secretKey);
var dateKey = ComputeHmac(secret, dateStamp);
var regionKey = ComputeHmac(dateKey, region);
var serviceKey = ComputeHmac(regionKey, "s3");
var signingKey = ComputeHmac(serviceKey, "aws4_request");
return ConvertToHex(ComputeHmac(signingKey, stringToSign));
}
private static byte[] ComputeHmac(byte[] key, string value)
{
using var hmac = new HMACSHA256(key);
return hmac.ComputeHash(Encoding.UTF8.GetBytes(value));
}
private static string ConvertToHex(byte[] bytes) =>
Convert.ToHexString(bytes).ToLowerInvariant();
}