事件脚本
@@ -1092,6 +1385,10 @@ onMounted(loadSettings);
border-radius: 14px;
}
+.settings-import-input {
+ display: none;
+}
+
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
diff --git a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs
index c4308e4..03f308f 100644
--- a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs
+++ b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs
@@ -55,6 +55,8 @@ public interface IRecordSessionRepository
Task> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
+ Task> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default);
+
Task GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
diff --git a/src/LiveRecorder.Application/Common/AutoStartDecisionCodes.cs b/src/LiveRecorder.Application/Common/AutoStartDecisionCodes.cs
index 41309ea..26a8c2c 100644
--- a/src/LiveRecorder.Application/Common/AutoStartDecisionCodes.cs
+++ b/src/LiveRecorder.Application/Common/AutoStartDecisionCodes.cs
@@ -12,5 +12,7 @@ public static class AutoStartDecisionCodes
public const string SkippedOffline = "skipped_offline";
+ public const string SkippedDebounce = "skipped_debounce";
+
public const string FailedStartup = "failed_startup";
}
diff --git a/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs b/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs
index 361161e..8fc836c 100644
--- a/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs
+++ b/src/LiveRecorder.Application/Models/LiveRooms/LiveRoomModels.cs
@@ -108,6 +108,18 @@ public sealed class LiveRoomDto
public string? CoverUrl { get; init; }
+ public string? Remark { get; init; }
+
+ public bool IsPinned { get; init; }
+
+ public string? Alias { get; init; }
+
+ public bool IsPriority { get; init; }
+
+ public int? PollingIntervalSecondsOverride { get; init; }
+
+ public required string OriginalLiveRoomUrl { get; init; }
+
public required LiveRoomSettingsOverridesDto Overrides { get; init; }
public required LiveRoomEffectiveSettingsDto EffectiveSettings { get; init; }
@@ -116,6 +128,8 @@ public sealed class LiveRoomDto
public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; }
+ public required LiveRoomCurrentRecordingState CurrentRecordingState { get; init; }
+
public string? LastAutoStartDecisionCode { get; init; }
public string? LastAutoStartDecisionSummary { get; init; }
@@ -131,6 +145,19 @@ public sealed class LiveRoomDto
public DateTimeOffset UpdatedAt { get; init; }
}
+public sealed class UpdateLiveRoomMetadataRequest
+{
+ public string? Remark { get; set; }
+
+ public bool IsPinned { get; set; }
+
+ public string? Alias { get; set; }
+
+ public bool IsPriority { get; set; }
+
+ public int? PollingIntervalSecondsOverride { get; set; }
+}
+
public sealed class SetLiveRoomEnabledRequest
{
public bool IsEnabled { get; set; }
diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs
index 78d7eca..54f3d4e 100644
--- a/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs
+++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs
@@ -30,6 +30,20 @@ public sealed class RecordResultDto
public string? ErrorMessage { get; init; }
+ public required RecordArtifactUploadStatus UploadStatus { get; init; }
+
+ public string? LastUploadProvider { get; init; }
+
+ public string? RemoteVideoPath { get; init; }
+
+ public string? RemoteDanmakuPath { get; init; }
+
+ public DateTimeOffset? LastUploadedAt { get; init; }
+
+ public string? UploadErrorMessage { get; init; }
+
+ public bool DeletedLocalFilesAfterUpload { get; init; }
+
public DateTimeOffset CreatedAt { get; init; }
}
@@ -113,3 +127,31 @@ public sealed class RecordPreviewTicketDto
public DateTimeOffset ExpiresAt { get; init; }
}
+
+public sealed class RecordArtifactUploadItemResultDto
+{
+ public Guid RecordTaskId { get; init; }
+
+ public bool Success { get; init; }
+
+ public required string Message { get; init; }
+
+ public string? Provider { get; init; }
+
+ public string? RemoteVideoPath { get; init; }
+
+ public string? RemoteDanmakuPath { get; init; }
+
+ public bool DeletedLocalFilesAfterUpload { get; init; }
+}
+
+public sealed class RecordArtifactUploadBatchResultDto
+{
+ public int RequestedCount { get; init; }
+
+ public int SuccessCount { get; init; }
+
+ public int FailedCount { get; init; }
+
+ public required IReadOnlyList Items { get; init; }
+}
diff --git a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs
index d223f56..65e38e5 100644
--- a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs
+++ b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs
@@ -9,6 +9,41 @@ public static class EventScriptSourceModes
public const string Inline = "inline";
}
+public sealed class PlatformProxySettingsDto
+{
+ public bool Enabled { get; set; }
+
+ public string ProxyUrl { get; set; } = string.Empty;
+}
+
+public sealed class WebDavUploadSettingsDto
+{
+ public string Endpoint { get; set; } = string.Empty;
+
+ public string BasePath { get; set; } = string.Empty;
+
+ public string Username { get; set; } = string.Empty;
+
+ public string Password { get; set; } = string.Empty;
+}
+
+public sealed class S3UploadSettingsDto
+{
+ public string Endpoint { get; set; } = string.Empty;
+
+ public string Bucket { get; set; } = string.Empty;
+
+ public string Region { get; set; } = string.Empty;
+
+ public string AccessKey { get; set; } = string.Empty;
+
+ public string SecretKey { get; set; } = string.Empty;
+
+ public string Prefix { get; set; } = string.Empty;
+
+ public bool ForcePathStyle { get; set; }
+}
+
public sealed class SystemSettingsDto
{
public string FfmpegPath { get; set; } = "ffmpeg";
@@ -59,6 +94,26 @@ public sealed class SystemSettingsDto
public int PollingIntervalSeconds { get; set; } = 60;
+ public bool UseAliasForStorage { get; set; }
+
+ public bool EnableFileUpload { get; set; }
+
+ public bool EnableAutoUpload { get; set; }
+
+ public bool DeleteLocalFilesAfterUpload { get; set; }
+
+ public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
+
+ public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
+
+ public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
+
+ public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
+
+ public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
+
+ public S3UploadSettingsDto S3Upload { get; set; } = new();
+
public bool EnableEventScripts { get; set; } = false;
public string LiveStartedScriptMode { get; set; } = EventScriptSourceModes.Path;
@@ -214,6 +269,26 @@ public sealed class UpdateSystemSettingsRequest
public int PollingIntervalSeconds { get; set; } = 60;
+ public bool UseAliasForStorage { get; set; }
+
+ public bool EnableFileUpload { get; set; }
+
+ public bool EnableAutoUpload { get; set; }
+
+ public bool DeleteLocalFilesAfterUpload { get; set; }
+
+ public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
+
+ public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
+
+ public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
+
+ public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
+
+ public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
+
+ public S3UploadSettingsDto S3Upload { get; set; } = new();
+
public bool EnableEventScripts { get; set; } = false;
public string LiveStartedScriptMode { get; set; } = EventScriptSourceModes.Path;
@@ -431,3 +506,8 @@ public sealed class RetentionCleanupResultDto
public required IReadOnlyList Warnings { get; init; }
}
+
+public sealed class ImportSystemSettingsRequest
+{
+ public SystemSettingsDto? Settings { get; set; }
+}
diff --git a/src/LiveRecorder.Application/Services/LiveRoomService.cs b/src/LiveRecorder.Application/Services/LiveRoomService.cs
index 3fea3b4..edfb31b 100644
--- a/src/LiveRecorder.Application/Services/LiveRoomService.cs
+++ b/src/LiveRecorder.Application/Services/LiveRoomService.cs
@@ -14,6 +14,7 @@ namespace LiveRecorder.Application.Services;
public sealed class LiveRoomService
{
private readonly ILiveRoomRepository _liveRoomRepository;
+ private readonly IRecordSessionRepository _recordSessionRepository;
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
private readonly LiveRoomStatusService _liveRoomStatusService;
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
@@ -25,6 +26,7 @@ public sealed class LiveRoomService
public LiveRoomService(
ILiveRoomRepository liveRoomRepository,
+ IRecordSessionRepository recordSessionRepository,
ILivePlatformAdapterFactory livePlatformAdapterFactory,
LiveRoomStatusService liveRoomStatusService,
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
@@ -35,6 +37,7 @@ public sealed class LiveRoomService
ISystemLogService systemLogService)
{
_liveRoomRepository = liveRoomRepository;
+ _recordSessionRepository = recordSessionRepository;
_livePlatformAdapterFactory = livePlatformAdapterFactory;
_liveRoomStatusService = liveRoomStatusService;
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
@@ -49,9 +52,13 @@ public sealed class LiveRoomService
{
var rooms = await _liveRoomRepository.ListAsync(cancellationToken);
var effectiveSettings = await BuildEffectiveSettingsLookupAsync(rooms, cancellationToken);
+ var activeLiveRoomIds = await _recordSessionRepository.ListActiveLiveRoomIdsAsync(cancellationToken);
return rooms
- .OrderByDescending(static item => item.UpdatedAt)
- .Select(item => Map(item, effectiveSettings[item.Id]))
+ .OrderByDescending(static item => item.IsPinned)
+ .ThenByDescending(static item => item.IsPriority)
+ .ThenByDescending(item => GetCurrentRecordingState(item, activeLiveRoomIds.Contains(item.Id)))
+ .ThenByDescending(static item => item.UpdatedAt)
+ .Select(item => Map(item, effectiveSettings[item.Id], activeLiveRoomIds.Contains(item.Id)))
.ToList();
}
@@ -64,7 +71,8 @@ public sealed class LiveRoomService
}
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
- return Map(room, effectiveSettings);
+ var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
+ return Map(room, effectiveSettings, hasActiveSession);
}
public async Task CreateAsync(CreateLiveRoomRequest request, CancellationToken cancellationToken = default)
@@ -77,7 +85,8 @@ public sealed class LiveRoomService
request.AnchorName,
cancellationToken);
- return Map(liveRoom, effectiveSettings);
+ var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null;
+ return Map(liveRoom, effectiveSettings, hasActiveSession);
}
public async Task ImportAsync(
@@ -130,7 +139,10 @@ public sealed class LiveRoomService
AnchorName = anchorName,
Success = true,
Created = created,
- LiveRoom = Map(liveRoom, effectiveSettings)
+ LiveRoom = Map(
+ liveRoom,
+ effectiveSettings,
+ await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null)
});
}
catch (Exception ex)
@@ -247,7 +259,8 @@ public sealed class LiveRoomService
await TryAutoStartRecordingAsync(room, liveStatus, cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
- return Map(room, effectiveSettings);
+ var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
+ return Map(room, effectiveSettings, hasActiveSession);
}
public async Task SetEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default)
@@ -268,7 +281,8 @@ public sealed class LiveRoomService
cancellationToken: cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
- return Map(room, effectiveSettings);
+ var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
+ return Map(room, effectiveSettings, hasActiveSession);
}
public async Task SetEnabledBatchAsync(
@@ -340,7 +354,40 @@ public sealed class LiveRoomService
cancellationToken: cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
- return Map(room, effectiveSettings);
+ var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
+ return Map(room, effectiveSettings, hasActiveSession);
+ }
+
+ public async Task UpdateMetadataAsync(
+ Guid id,
+ UpdateLiveRoomMetadataRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken)
+ ?? throw new KeyNotFoundException("Live room was not found.");
+
+ room.UpdateManagementMetadata(
+ request.Remark,
+ request.IsPinned,
+ request.Alias,
+ request.IsPriority,
+ ClampNullable(request.PollingIntervalSecondsOverride, 10, 3600),
+ DateTimeOffset.UtcNow);
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ await _systemLogService.WriteAsync(
+ SystemLogLevel.Info,
+ "LiveRoom",
+ $"Live room metadata updated for room {room.RoomId}.",
+ liveRoomId: room.Id,
+ cancellationToken: cancellationToken);
+
+ var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
+ var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
+ return Map(room, effectiveSettings, hasActiveSession);
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
@@ -470,7 +517,7 @@ public sealed class LiveRoomService
}
}
- private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings) => new()
+ private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings, bool hasActiveSession) => new()
{
Id = room.Id,
Platform = room.Platform,
@@ -483,10 +530,17 @@ public sealed class LiveRoomService
AnchorId = room.AnchorId,
AvatarUrl = room.AvatarUrl,
CoverUrl = room.CoverUrl,
+ Remark = room.Remark,
+ IsPinned = room.IsPinned,
+ Alias = room.Alias,
+ IsPriority = room.IsPriority,
+ PollingIntervalSecondsOverride = room.PollingIntervalSecondsOverride,
+ OriginalLiveRoomUrl = string.IsNullOrWhiteSpace(room.NormalizedUrl) ? room.SourceUrl : room.NormalizedUrl,
Overrides = _liveRoomRecordingSettingsResolver.BuildOverridesDto(room),
EffectiveSettings = _liveRoomRecordingSettingsResolver.BuildEffectiveDto(effectiveSettings),
IsEnabled = room.IsEnabled,
AvailabilityStatus = room.AvailabilityStatus,
+ CurrentRecordingState = GetCurrentRecordingState(room, hasActiveSession),
LastAutoStartDecisionCode = room.LastAutoStartDecisionCode,
LastAutoStartDecisionSummary = room.LastAutoStartDecisionSummary,
LastAutoStartDecisionDetail = room.LastAutoStartDecisionDetail,
@@ -496,6 +550,18 @@ public sealed class LiveRoomService
UpdatedAt = room.UpdatedAt
};
+ private static LiveRoomCurrentRecordingState GetCurrentRecordingState(LiveRoom room, bool hasActiveSession)
+ {
+ if (room.AvailabilityStatus != LiveRoomAvailabilityStatus.Live)
+ {
+ return LiveRoomCurrentRecordingState.Offline;
+ }
+
+ return hasActiveSession
+ ? LiveRoomCurrentRecordingState.Recording
+ : LiveRoomCurrentRecordingState.Live;
+ }
+
private static int? ClampNullable(int? value, int min, int max) =>
value.HasValue ? Math.Clamp(value.Value, min, max) : null;
diff --git a/src/LiveRecorder.Application/Services/RecordModelMapper.cs b/src/LiveRecorder.Application/Services/RecordModelMapper.cs
index fd1bfb5..8b9bf56 100644
--- a/src/LiveRecorder.Application/Services/RecordModelMapper.cs
+++ b/src/LiveRecorder.Application/Services/RecordModelMapper.cs
@@ -42,6 +42,13 @@ internal static class RecordModelMapper
DanmakuMessageCount = recordResult.DanmakuMessageCount,
FinalStatus = recordResult.FinalStatus,
ErrorMessage = recordResult.ErrorMessage,
+ UploadStatus = recordResult.UploadStatus,
+ LastUploadProvider = recordResult.LastUploadProvider,
+ RemoteVideoPath = recordResult.RemoteVideoPath,
+ RemoteDanmakuPath = recordResult.RemoteDanmakuPath,
+ LastUploadedAt = recordResult.LastUploadedAt,
+ UploadErrorMessage = recordResult.UploadErrorMessage,
+ DeletedLocalFilesAfterUpload = recordResult.DeletedLocalFilesAfterUpload,
CreatedAt = recordResult.CreatedAt
};
diff --git a/src/LiveRecorder.Application/Services/RecordService.cs b/src/LiveRecorder.Application/Services/RecordService.cs
index a5f9e33..2a88dc6 100644
--- a/src/LiveRecorder.Application/Services/RecordService.cs
+++ b/src/LiveRecorder.Application/Services/RecordService.cs
@@ -15,6 +15,8 @@ namespace LiveRecorder.Application.Services;
public sealed class RecordService
{
+ private static readonly TimeSpan StartRecordingDebounceWindow = TimeSpan.FromSeconds(10);
+
private readonly ILiveRoomRepository _liveRoomRepository;
private readonly IRecordSessionRepository _recordSessionRepository;
private readonly IRecordTaskRepository _recordTaskRepository;
@@ -146,6 +148,34 @@ public sealed class RecordService
throw new InvalidOperationException("The live room is disabled. Enable it before starting a recording.");
}
+ var debounceTriggeredAt = DateTimeOffset.UtcNow;
+ if (liveRoom.LastStartRecordingTriggeredAt.HasValue &&
+ debounceTriggeredAt - liveRoom.LastStartRecordingTriggeredAt.Value < StartRecordingDebounceWindow)
+ {
+ if (trackAutoStartDecision)
+ {
+ await UpdateAutoStartDecisionAsync(
+ liveRoom,
+ AutoStartDecisionCodes.SkippedDebounce,
+ "Auto-start skipped because start recording debounce is active.",
+ $"lastTriggeredAt={liveRoom.LastStartRecordingTriggeredAt:O}",
+ cancellationToken);
+ }
+
+ await _systemLogService.WriteAsync(
+ SystemLogLevel.Info,
+ "RecordSession",
+ "Recording start skipped because the debounce window is still active.",
+ $"windowSeconds={(int)StartRecordingDebounceWindow.TotalSeconds}",
+ liveRoomId: liveRoom.Id,
+ cancellationToken: cancellationToken);
+
+ throw new InvalidOperationException("Recording start was triggered too recently. Please wait a few seconds and try again.");
+ }
+
+ liveRoom.MarkStartRecordingTriggered(debounceTriggeredAt);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
await ReconcileActiveSessionsAsync(liveRoom.Id, cancellationToken);
var activeSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken);
if (activeSession is not null)
@@ -204,6 +234,7 @@ public sealed class RecordService
var outputFormat = request.OutputFormat ?? effectiveSettings.OutputFormat;
var saveMode = effectiveSettings.SaveMode;
var now = DateTimeOffset.UtcNow;
+ var storageAnchorName = GetStorageAnchorName(liveRoom, settings);
var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
await _recordSessionRepository.AddAsync(recordSession, cancellationToken);
@@ -251,7 +282,7 @@ public sealed class RecordService
settings.OutputFileNameTemplate,
liveRoom.Platform,
liveRoom.RoomId,
- liveRoom.AnchorName,
+ storageAnchorName,
liveRoom.Title,
outputFormat,
saveMode,
@@ -774,6 +805,18 @@ public sealed class RecordService
return Path.Combine(folder, $"{fileNameStem}.{extension}");
}
+ private static string? GetStorageAnchorName(LiveRoom liveRoom, Application.Models.Settings.SystemSettingsDto settings)
+ {
+ if (!settings.UseAliasForStorage)
+ {
+ return liveRoom.AnchorName;
+ }
+
+ return string.IsNullOrWhiteSpace(liveRoom.Alias)
+ ? liveRoom.AnchorName
+ : liveRoom.Alias;
+ }
+
internal static string ResolveSegmentOutputPath(
string outputPathPattern,
RecordOutputFormat outputFormat,
diff --git a/src/LiveRecorder.Application/Services/SystemSettingsService.cs b/src/LiveRecorder.Application/Services/SystemSettingsService.cs
index b5c80d1..4ea1aa1 100644
--- a/src/LiveRecorder.Application/Services/SystemSettingsService.cs
+++ b/src/LiveRecorder.Application/Services/SystemSettingsService.cs
@@ -32,6 +32,28 @@ public sealed class SystemSettingsService : ISystemSettingsService
private const string EnableBackgroundPollingKey = "scheduler.enable_background_polling";
private const string AutoStartRecordingOnLiveKey = "scheduler.auto_start_recording_on_live";
private const string PollingIntervalSecondsKey = "scheduler.polling_interval_seconds";
+ private const string UseAliasForStorageKey = "recording.use_alias_for_storage";
+ private const string EnableFileUploadKey = "upload.enabled";
+ private const string EnableAutoUploadKey = "upload.auto_upload";
+ private const string DeleteLocalFilesAfterUploadKey = "upload.delete_local_files_after_upload";
+ private const string UploadTargetKey = "upload.target";
+ private const string WebDavEndpointKey = "upload.webdav.endpoint";
+ private const string WebDavBasePathKey = "upload.webdav.base_path";
+ private const string WebDavUsernameKey = "upload.webdav.username";
+ private const string WebDavPasswordKey = "upload.webdav.password";
+ private const string S3EndpointKey = "upload.s3.endpoint";
+ private const string S3BucketKey = "upload.s3.bucket";
+ private const string S3RegionKey = "upload.s3.region";
+ private const string S3AccessKeyKey = "upload.s3.access_key";
+ private const string S3SecretKeyKey = "upload.s3.secret_key";
+ private const string S3PrefixKey = "upload.s3.prefix";
+ private const string S3ForcePathStyleKey = "upload.s3.force_path_style";
+ private const string DouyinProxyEnabledKey = "platform_proxy.douyin.enabled";
+ private const string DouyinProxyUrlKey = "platform_proxy.douyin.url";
+ private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled";
+ private const string BilibiliProxyUrlKey = "platform_proxy.bilibili.url";
+ private const string HuyaProxyEnabledKey = "platform_proxy.huya.enabled";
+ private const string HuyaProxyUrlKey = "platform_proxy.huya.url";
private const string EnableEventScriptsKey = "event_scripts.enabled";
private const string LiveStartedScriptModeKey = "event_scripts.live_started.mode";
private const string LiveStartedScriptPathKey = "event_scripts.live_started.path";
@@ -117,6 +139,45 @@ public sealed class SystemSettingsService : ISystemSettingsService
EnableBackgroundPolling = bool.TryParse(GetValue(lookup, EnableBackgroundPollingKey, "true"), out var enableBackgroundPolling) && enableBackgroundPolling,
AutoStartRecordingOnLive = bool.TryParse(GetValue(lookup, AutoStartRecordingOnLiveKey, "true"), out var autoStartRecordingOnLive) && autoStartRecordingOnLive,
PollingIntervalSeconds = GetIntValue(lookup, PollingIntervalSecondsKey, 60, 10, 3600),
+ UseAliasForStorage = bool.TryParse(GetValue(lookup, UseAliasForStorageKey, "false"), out var useAliasForStorage) && useAliasForStorage,
+ EnableFileUpload = bool.TryParse(GetValue(lookup, EnableFileUploadKey, "false"), out var enableFileUpload) && enableFileUpload,
+ EnableAutoUpload = bool.TryParse(GetValue(lookup, EnableAutoUploadKey, "false"), out var enableAutoUpload) && enableAutoUpload,
+ DeleteLocalFilesAfterUpload = bool.TryParse(GetValue(lookup, DeleteLocalFilesAfterUploadKey, "false"), out var deleteLocalFilesAfterUpload) && deleteLocalFilesAfterUpload,
+ UploadTarget = Enum.TryParse(GetValue(lookup, UploadTargetKey, "None"), true, out UploadTargetType uploadTarget)
+ ? uploadTarget
+ : UploadTargetType.None,
+ DouyinProxy = new PlatformProxySettingsDto
+ {
+ Enabled = bool.TryParse(GetValue(lookup, DouyinProxyEnabledKey, "false"), out var douyinProxyEnabled) && douyinProxyEnabled,
+ ProxyUrl = GetValue(lookup, DouyinProxyUrlKey, string.Empty)
+ },
+ BilibiliProxy = new PlatformProxySettingsDto
+ {
+ Enabled = bool.TryParse(GetValue(lookup, BilibiliProxyEnabledKey, "false"), out var bilibiliProxyEnabled) && bilibiliProxyEnabled,
+ ProxyUrl = GetValue(lookup, BilibiliProxyUrlKey, string.Empty)
+ },
+ HuyaProxy = new PlatformProxySettingsDto
+ {
+ Enabled = bool.TryParse(GetValue(lookup, HuyaProxyEnabledKey, "false"), out var huyaProxyEnabled) && huyaProxyEnabled,
+ ProxyUrl = GetValue(lookup, HuyaProxyUrlKey, string.Empty)
+ },
+ WebDavUpload = new WebDavUploadSettingsDto
+ {
+ Endpoint = GetValue(lookup, WebDavEndpointKey, string.Empty),
+ BasePath = GetValue(lookup, WebDavBasePathKey, string.Empty),
+ Username = GetValue(lookup, WebDavUsernameKey, string.Empty),
+ Password = GetValue(lookup, WebDavPasswordKey, string.Empty)
+ },
+ S3Upload = new S3UploadSettingsDto
+ {
+ Endpoint = GetValue(lookup, S3EndpointKey, string.Empty),
+ Bucket = GetValue(lookup, S3BucketKey, string.Empty),
+ Region = GetValue(lookup, S3RegionKey, string.Empty),
+ AccessKey = GetValue(lookup, S3AccessKeyKey, string.Empty),
+ SecretKey = GetValue(lookup, S3SecretKeyKey, string.Empty),
+ Prefix = GetValue(lookup, S3PrefixKey, string.Empty),
+ ForcePathStyle = bool.TryParse(GetValue(lookup, S3ForcePathStyleKey, "false"), out var s3ForcePathStyle) && s3ForcePathStyle
+ },
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
LiveStartedScriptMode = GetEventScriptMode(lookup, LiveStartedScriptModeKey, LiveStartedScriptPathKey, LiveStartedScriptContentKey),
LiveStartedScriptPath = GetValue(lookup, LiveStartedScriptPathKey, string.Empty),
@@ -199,6 +260,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
ArgumentNullException.ThrowIfNull(request);
var now = DateTimeOffset.UtcNow;
+ var douyinProxy = request.DouyinProxy ?? new PlatformProxySettingsDto();
+ var bilibiliProxy = request.BilibiliProxy ?? new PlatformProxySettingsDto();
+ var huyaProxy = request.HuyaProxy ?? new PlatformProxySettingsDto();
+ var webDavUpload = request.WebDavUpload ?? new WebDavUploadSettingsDto();
+ var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
await UpsertAsync(FfmpegPathKey, request.FfmpegPath.Trim(), now, cancellationToken);
await UpsertAsync(OutputRootKey, request.OutputRoot.Trim(), now, cancellationToken);
@@ -240,6 +306,28 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(EnableBackgroundPollingKey, request.EnableBackgroundPolling.ToString(), now, cancellationToken);
await UpsertAsync(AutoStartRecordingOnLiveKey, request.AutoStartRecordingOnLive.ToString(), now, cancellationToken);
await UpsertAsync(PollingIntervalSecondsKey, request.PollingIntervalSeconds.ToString(), now, cancellationToken);
+ await UpsertAsync(UseAliasForStorageKey, request.UseAliasForStorage.ToString(), now, cancellationToken);
+ await UpsertAsync(EnableFileUploadKey, request.EnableFileUpload.ToString(), now, cancellationToken);
+ await UpsertAsync(EnableAutoUploadKey, request.EnableAutoUpload.ToString(), now, cancellationToken);
+ await UpsertAsync(DeleteLocalFilesAfterUploadKey, request.DeleteLocalFilesAfterUpload.ToString(), now, cancellationToken);
+ await UpsertAsync(UploadTargetKey, request.UploadTarget.ToString(), now, cancellationToken);
+ await UpsertAsync(WebDavEndpointKey, webDavUpload.Endpoint.Trim(), now, cancellationToken);
+ await UpsertAsync(WebDavBasePathKey, webDavUpload.BasePath.Trim(), now, cancellationToken);
+ await UpsertAsync(WebDavUsernameKey, webDavUpload.Username.Trim(), now, cancellationToken);
+ await UpsertAsync(WebDavPasswordKey, webDavUpload.Password, now, cancellationToken);
+ await UpsertAsync(S3EndpointKey, s3Upload.Endpoint.Trim(), now, cancellationToken);
+ await UpsertAsync(S3BucketKey, s3Upload.Bucket.Trim(), now, cancellationToken);
+ await UpsertAsync(S3RegionKey, s3Upload.Region.Trim(), now, cancellationToken);
+ await UpsertAsync(S3AccessKeyKey, s3Upload.AccessKey.Trim(), now, cancellationToken);
+ await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
+ await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
+ await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
+ await UpsertAsync(DouyinProxyEnabledKey, douyinProxy.Enabled.ToString(), now, cancellationToken);
+ await UpsertAsync(DouyinProxyUrlKey, douyinProxy.ProxyUrl.Trim(), now, cancellationToken);
+ await UpsertAsync(BilibiliProxyEnabledKey, bilibiliProxy.Enabled.ToString(), now, cancellationToken);
+ await UpsertAsync(BilibiliProxyUrlKey, bilibiliProxy.ProxyUrl.Trim(), now, cancellationToken);
+ await UpsertAsync(HuyaProxyEnabledKey, huyaProxy.Enabled.ToString(), now, cancellationToken);
+ await UpsertAsync(HuyaProxyUrlKey, huyaProxy.ProxyUrl.Trim(), now, cancellationToken);
await UpsertAsync(EnableEventScriptsKey, request.EnableEventScripts.ToString(), now, cancellationToken);
await UpsertAsync(LiveStartedScriptModeKey, NormalizeEventScriptMode(request.LiveStartedScriptMode), now, cancellationToken);
await UpsertAsync(LiveStartedScriptPathKey, request.LiveStartedScriptPath.Trim(), now, cancellationToken);
diff --git a/src/LiveRecorder.Domain/Entities/LiveRoom.cs b/src/LiveRecorder.Domain/Entities/LiveRoom.cs
index eabdc45..f228935 100644
--- a/src/LiveRecorder.Domain/Entities/LiveRoom.cs
+++ b/src/LiveRecorder.Domain/Entities/LiveRoom.cs
@@ -46,6 +46,16 @@ public class LiveRoom
public string? CoverUrl { get; private set; }
+ public string? Remark { get; private set; }
+
+ public bool IsPinned { get; private set; }
+
+ public string? Alias { get; private set; }
+
+ public bool IsPriority { get; private set; }
+
+ public int? PollingIntervalSecondsOverride { get; private set; }
+
public string? PreferredQualityOverride { get; private set; }
public RecordOutputFormat? OutputFormatOverride { get; private set; }
@@ -90,6 +100,8 @@ public class LiveRoom
public DateTimeOffset? LastCheckedAt { get; private set; }
+ public DateTimeOffset? LastStartRecordingTriggeredAt { get; private set; }
+
public ICollection RecordTasks { get; private set; } = new List();
public void UpdateSource(string sourceUrl, string normalizedUrl, DateTimeOffset updatedAt)
@@ -112,6 +124,7 @@ public class LiveRoom
AnchorId = PreferIncomingValue(anchorId, AnchorId);
AvatarUrl = PreferIncomingValue(avatarUrl, AvatarUrl);
CoverUrl = PreferIncomingValue(coverUrl, CoverUrl);
+ Alias ??= NormalizeNullable(anchorName);
UpdatedAt = updatedAt;
}
@@ -151,6 +164,28 @@ public class LiveRoom
LastAutoStartDecisionAt = decidedAt;
}
+ public void UpdateManagementMetadata(
+ string? remark,
+ bool isPinned,
+ string? alias,
+ bool isPriority,
+ int? pollingIntervalSecondsOverride,
+ DateTimeOffset updatedAt)
+ {
+ Remark = NormalizeNullable(remark);
+ IsPinned = isPinned;
+ Alias = NormalizeNullable(alias) ?? NormalizeNullable(AnchorName);
+ IsPriority = isPriority;
+ PollingIntervalSecondsOverride = pollingIntervalSecondsOverride;
+ UpdatedAt = updatedAt;
+ }
+
+ public void MarkStartRecordingTriggered(DateTimeOffset triggeredAt)
+ {
+ LastStartRecordingTriggeredAt = triggeredAt;
+ UpdatedAt = triggeredAt;
+ }
+
public void UpdateRecordingSettingsOverrides(
string? preferredQualityOverride,
RecordOutputFormat? outputFormatOverride,
diff --git a/src/LiveRecorder.Domain/Entities/RecordResult.cs b/src/LiveRecorder.Domain/Entities/RecordResult.cs
index 71c8f25..7723da0 100644
--- a/src/LiveRecorder.Domain/Entities/RecordResult.cs
+++ b/src/LiveRecorder.Domain/Entities/RecordResult.cs
@@ -51,6 +51,20 @@ public class RecordResult
public string? ErrorMessage { get; private set; }
+ public RecordArtifactUploadStatus UploadStatus { get; private set; }
+
+ public string? LastUploadProvider { get; private set; }
+
+ public string? RemoteVideoPath { get; private set; }
+
+ public string? RemoteDanmakuPath { get; private set; }
+
+ public DateTimeOffset? LastUploadedAt { get; private set; }
+
+ public string? UploadErrorMessage { get; private set; }
+
+ public bool DeletedLocalFilesAfterUpload { get; private set; }
+
public DateTimeOffset CreatedAt { get; private set; }
public void Update(
@@ -70,4 +84,32 @@ public class RecordResult
FinalStatus = finalStatus;
ErrorMessage = errorMessage;
}
+
+ public void MarkUploadSucceeded(
+ string provider,
+ string? remoteVideoPath,
+ string? remoteDanmakuPath,
+ bool deletedLocalFilesAfterUpload,
+ DateTimeOffset uploadedAt)
+ {
+ UploadStatus = RecordArtifactUploadStatus.Succeeded;
+ LastUploadProvider = NormalizeNullable(provider);
+ RemoteVideoPath = NormalizeNullable(remoteVideoPath);
+ RemoteDanmakuPath = NormalizeNullable(remoteDanmakuPath);
+ LastUploadedAt = uploadedAt;
+ UploadErrorMessage = null;
+ DeletedLocalFilesAfterUpload = deletedLocalFilesAfterUpload;
+ }
+
+ public void MarkUploadFailed(string provider, string? errorMessage, DateTimeOffset uploadedAt)
+ {
+ UploadStatus = RecordArtifactUploadStatus.Failed;
+ LastUploadProvider = NormalizeNullable(provider);
+ LastUploadedAt = uploadedAt;
+ UploadErrorMessage = NormalizeNullable(errorMessage);
+ DeletedLocalFilesAfterUpload = false;
+ }
+
+ private static string? NormalizeNullable(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
diff --git a/src/LiveRecorder.Domain/Enums/LiveRoomCurrentRecordingState.cs b/src/LiveRecorder.Domain/Enums/LiveRoomCurrentRecordingState.cs
new file mode 100644
index 0000000..e74a1a6
--- /dev/null
+++ b/src/LiveRecorder.Domain/Enums/LiveRoomCurrentRecordingState.cs
@@ -0,0 +1,8 @@
+namespace LiveRecorder.Domain.Enums;
+
+public enum LiveRoomCurrentRecordingState
+{
+ Offline = 0,
+ Live = 1,
+ Recording = 2
+}
diff --git a/src/LiveRecorder.Domain/Enums/RecordArtifactUploadStatus.cs b/src/LiveRecorder.Domain/Enums/RecordArtifactUploadStatus.cs
new file mode 100644
index 0000000..5b997e0
--- /dev/null
+++ b/src/LiveRecorder.Domain/Enums/RecordArtifactUploadStatus.cs
@@ -0,0 +1,8 @@
+namespace LiveRecorder.Domain.Enums;
+
+public enum RecordArtifactUploadStatus
+{
+ NotUploaded = 0,
+ Succeeded = 1,
+ Failed = 2
+}
diff --git a/src/LiveRecorder.Domain/Enums/UploadTargetType.cs b/src/LiveRecorder.Domain/Enums/UploadTargetType.cs
new file mode 100644
index 0000000..ba051df
--- /dev/null
+++ b/src/LiveRecorder.Domain/Enums/UploadTargetType.cs
@@ -0,0 +1,8 @@
+namespace LiveRecorder.Domain.Enums;
+
+public enum UploadTargetType
+{
+ None = 0,
+ WebDav = 1,
+ S3 = 2
+}
diff --git a/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs b/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs
index f5d88e8..9943218 100644
--- a/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs
+++ b/src/LiveRecorder.Infrastructure/Persistence/DatabaseInitializer.cs
@@ -56,6 +56,28 @@ public sealed class DatabaseInitializer
["scheduler.enable_background_polling"] = "True",
["scheduler.auto_start_recording_on_live"] = "True",
["scheduler.polling_interval_seconds"] = "60",
+ ["recording.use_alias_for_storage"] = "False",
+ ["upload.enabled"] = "False",
+ ["upload.auto_upload"] = "False",
+ ["upload.delete_local_files_after_upload"] = "False",
+ ["upload.target"] = "None",
+ ["upload.webdav.endpoint"] = string.Empty,
+ ["upload.webdav.base_path"] = string.Empty,
+ ["upload.webdav.username"] = string.Empty,
+ ["upload.webdav.password"] = string.Empty,
+ ["upload.s3.endpoint"] = string.Empty,
+ ["upload.s3.bucket"] = string.Empty,
+ ["upload.s3.region"] = string.Empty,
+ ["upload.s3.access_key"] = string.Empty,
+ ["upload.s3.secret_key"] = string.Empty,
+ ["upload.s3.prefix"] = string.Empty,
+ ["upload.s3.force_path_style"] = "False",
+ ["platform_proxy.douyin.enabled"] = "False",
+ ["platform_proxy.douyin.url"] = string.Empty,
+ ["platform_proxy.bilibili.enabled"] = "False",
+ ["platform_proxy.bilibili.url"] = string.Empty,
+ ["platform_proxy.huya.enabled"] = "False",
+ ["platform_proxy.huya.url"] = string.Empty,
["event_scripts.enabled"] = "False",
["event_scripts.live_started.path"] = string.Empty,
["event_scripts.live_ended.path"] = string.Empty,
@@ -171,6 +193,12 @@ public sealed class DatabaseInitializer
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionSummary TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionDetail TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionAt TEXT NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN Remark TEXT NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN IsPinned INTEGER NOT NULL DEFAULT 0;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN Alias TEXT NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN IsPriority INTEGER NOT NULL DEFAULT 0;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN PollingIntervalSecondsOverride INTEGER NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastStartRecordingTriggeredAt TEXT NULL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"""
@@ -199,6 +227,13 @@ public sealed class DatabaseInitializer
await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN SegmentIndex INTEGER NOT NULL DEFAULT 1;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuFilePath TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuMessageCount INTEGER NOT NULL DEFAULT 0;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN UploadStatus INTEGER NOT NULL DEFAULT 0;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN LastUploadProvider TEXT NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN RemoteVideoPath TEXT NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN RemoteDanmakuPath TEXT NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN LastUploadedAt TEXT NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN UploadErrorMessage TEXT NULL;", cancellationToken);
+ await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DeletedLocalFilesAfterUpload INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE SystemLogEntries ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
diff --git a/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs b/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs
index c7a91ae..421b17a 100644
--- a/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs
+++ b/src/LiveRecorder.Infrastructure/Persistence/LiveRecorderDbContext.cs
@@ -44,6 +44,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.Property(static x => x.AnchorId).HasMaxLength(128);
builder.Property(static x => x.AvatarUrl).HasMaxLength(512);
builder.Property(static x => x.CoverUrl).HasMaxLength(512);
+ builder.Property(static x => x.Remark).HasMaxLength(512);
+ builder.Property(static x => x.Alias).HasMaxLength(128);
builder.Property(static x => x.PreferredQualityOverride).HasMaxLength(64);
builder.Property(static x => x.OutputFormatOverride).HasConversion();
builder.Property(static x => x.SaveModeOverride).HasConversion();
@@ -53,6 +55,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.Property(static x => x.LastAutoStartDecisionDetail).HasMaxLength(2048);
builder.Property(static x => x.IsEnabled).HasDefaultValue(true);
builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false);
+ builder.Property(static x => x.IsPinned).HasDefaultValue(false);
+ builder.Property(static x => x.IsPriority).HasDefaultValue(false);
});
modelBuilder.Entity(builder =>
@@ -98,9 +102,14 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.ToTable("RecordResults");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.FinalStatus).HasConversion();
+ builder.Property(static x => x.UploadStatus).HasConversion();
builder.Property(static x => x.FilePath).HasMaxLength(2048);
builder.Property(static x => x.DanmakuFilePath).HasMaxLength(2048);
builder.Property(static x => x.ErrorMessage).HasMaxLength(2048);
+ builder.Property(static x => x.LastUploadProvider).HasMaxLength(32);
+ builder.Property(static x => x.RemoteVideoPath).HasMaxLength(2048);
+ builder.Property(static x => x.RemoteDanmakuPath).HasMaxLength(2048);
+ builder.Property(static x => x.UploadErrorMessage).HasMaxLength(2048);
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
builder.HasOne(static x => x.RecordTask)
.WithOne(static x => x.Result)
diff --git a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs
index 9cda474..8c5784d 100644
--- a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs
+++ b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs
@@ -173,6 +173,16 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
.ToList();
}
+ public async Task> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default) =>
+ await _dbContext.RecordSessions
+ .AsNoTracking()
+ .Where(item => item.Status == RecordSessionStatus.Starting ||
+ item.Status == RecordSessionStatus.Running ||
+ item.Status == RecordSessionStatus.Stopping)
+ .Select(item => item.LiveRoomId)
+ .Distinct()
+ .ToListAsync(cancellationToken);
+
public Task GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) =>
_dbContext.RecordSessions
.Include(item => item.LiveRoom)
diff --git a/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliHttpClient.cs b/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliHttpClient.cs
index c35270d..f243bc4 100644
--- a/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliHttpClient.cs
+++ b/src/LiveRecorder.Infrastructure/Platforms/Bilibili/BilibiliHttpClient.cs
@@ -2,6 +2,8 @@ using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Platforms;
+using LiveRecorder.Domain.Enums;
+using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
@@ -13,17 +15,17 @@ public sealed class BilibiliHttpClient
"""^(?:(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?)?(?\d+)\/?(?:[#\?].*)?$""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
- private readonly IHttpClientFactory _httpClientFactory;
+ private readonly PlatformHttpClientFactory _platformHttpClientFactory;
private readonly BilibiliWbiSigner _wbiSigner;
private readonly ILogger _logger;
private readonly string _buvid3 = BilibiliRequestDefaults.GenerateBuvid3();
public BilibiliHttpClient(
- IHttpClientFactory httpClientFactory,
+ PlatformHttpClientFactory platformHttpClientFactory,
BilibiliWbiSigner wbiSigner,
ILogger logger)
{
- _httpClientFactory = httpClientFactory;
+ _platformHttpClientFactory = platformHttpClientFactory;
_wbiSigner = wbiSigner;
_logger = logger;
}
@@ -350,9 +352,11 @@ public sealed class BilibiliHttpClient
{
try
{
- var response = await _httpClientFactory
- .CreateClient(BilibiliRequestDefaults.ClientName)
- .SendAsync(requestFactory(), completionOption, cancellationToken);
+ using var client = await _platformHttpClientFactory.CreateAsync(
+ LivePlatformType.Bilibili,
+ forceDirectConnection: false,
+ cancellationToken);
+ var response = await client.SendAsync(requestFactory(), completionOption, cancellationToken);
if (attempt < 3 && IsTransientStatusCode(response.StatusCode))
{
@@ -362,7 +366,7 @@ public sealed class BilibiliHttpClient
}
response.EnsureSuccessStatusCode();
- return response;
+ return await BufferResponseAsync(response, cancellationToken);
}
catch (Exception ex) when (attempt < 3 && IsTransientException(ex, cancellationToken))
{
@@ -375,6 +379,38 @@ public sealed class BilibiliHttpClient
throw lastException ?? new InvalidOperationException("Bilibili request failed.");
}
+ private static async Task BufferResponseAsync(
+ HttpResponseMessage response,
+ CancellationToken cancellationToken)
+ {
+ await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ await using var buffer = new MemoryStream();
+ await responseStream.CopyToAsync(buffer, cancellationToken);
+
+ var clone = new HttpResponseMessage(response.StatusCode)
+ {
+ ReasonPhrase = response.ReasonPhrase,
+ Version = response.Version,
+ RequestMessage = response.RequestMessage is null
+ ? null
+ : new HttpRequestMessage(response.RequestMessage.Method, response.RequestMessage.RequestUri),
+ Content = new ByteArrayContent(buffer.ToArray())
+ };
+
+ foreach (var header in response.Headers)
+ {
+ clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ foreach (var header in response.Content.Headers)
+ {
+ clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ response.Dispose();
+ return clone;
+ }
+
private static string? ExtractRoomReference(string input)
{
var match = RoomIdRegex.Match(input);
diff --git a/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs
index 2246dfd..c0c2e67 100644
--- a/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs
+++ b/src/LiveRecorder.Infrastructure/Platforms/Douyin/DouyinHttpClient.cs
@@ -5,8 +5,10 @@ using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
+using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
+using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -15,8 +17,6 @@ namespace LiveRecorder.Infrastructure.Platforms.Douyin;
public sealed class DouyinHttpClient
{
- private const string DefaultClientName = "douyin";
- private const string DirectClientName = "douyin-direct";
private const int MaxAttempts = 3;
private const int MsTokenLength = 184;
private static readonly Regex PaceStateRegex = new(
@@ -61,20 +61,20 @@ public sealed class DouyinHttpClient
"\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?[\\s\\S]*?)\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
- private readonly IHttpClientFactory _httpClientFactory;
+ private readonly PlatformHttpClientFactory _platformHttpClientFactory;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly DouyinXBogusSigner _xBogusSigner;
private readonly DouyinLiveWsSignatureSigner _liveWsSignatureSigner;
private readonly ILogger _logger;
public DouyinHttpClient(
- IHttpClientFactory httpClientFactory,
+ PlatformHttpClientFactory platformHttpClientFactory,
IServiceScopeFactory serviceScopeFactory,
DouyinXBogusSigner xBogusSigner,
DouyinLiveWsSignatureSigner liveWsSignatureSigner,
ILogger logger)
{
- _httpClientFactory = httpClientFactory;
+ _platformHttpClientFactory = platformHttpClientFactory;
_serviceScopeFactory = serviceScopeFactory;
_xBogusSigner = xBogusSigner;
_liveWsSignatureSigner = liveWsSignatureSigner;
@@ -523,7 +523,10 @@ BootstrapResolved:
{
using var request = requestFactory();
var useDirectConnection = preferDirectConnection;
- var client = _httpClientFactory.CreateClient(useDirectConnection ? DirectClientName : DefaultClientName);
+ using var client = await _platformHttpClientFactory.CreateAsync(
+ LivePlatformType.Douyin,
+ forceDirectConnection: useDirectConnection,
+ cancellationToken);
try
{
@@ -537,7 +540,7 @@ BootstrapResolved:
attempt);
}
- return response;
+ return await BufferResponseAsync(response, cancellationToken);
}
if (attempt < MaxAttempts && IsTransientStatusCode(response.StatusCode))
@@ -548,7 +551,7 @@ BootstrapResolved:
}
response.EnsureSuccessStatusCode();
- return response;
+ return await BufferResponseAsync(response, cancellationToken);
}
catch (Exception ex) when (attempt < MaxAttempts && IsTransientTransportException(ex, cancellationToken))
{
@@ -573,8 +576,44 @@ BootstrapResolved:
}
using var lastRequest = requestFactory();
- var finalClient = _httpClientFactory.CreateClient(preferDirectConnection ? DirectClientName : DefaultClientName);
- return await finalClient.SendAsync(lastRequest, completionOption, cancellationToken);
+ using var finalClient = await _platformHttpClientFactory.CreateAsync(
+ LivePlatformType.Douyin,
+ forceDirectConnection: preferDirectConnection,
+ cancellationToken);
+ var finalResponse = await finalClient.SendAsync(lastRequest, completionOption, cancellationToken);
+ return await BufferResponseAsync(finalResponse, cancellationToken);
+ }
+
+ private static async Task BufferResponseAsync(
+ HttpResponseMessage response,
+ CancellationToken cancellationToken)
+ {
+ await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ await using var buffer = new MemoryStream();
+ await responseStream.CopyToAsync(buffer, cancellationToken);
+
+ var clone = new HttpResponseMessage(response.StatusCode)
+ {
+ ReasonPhrase = response.ReasonPhrase,
+ Version = response.Version,
+ RequestMessage = response.RequestMessage is null
+ ? null
+ : new HttpRequestMessage(response.RequestMessage.Method, response.RequestMessage.RequestUri),
+ Content = new ByteArrayContent(buffer.ToArray())
+ };
+
+ foreach (var header in response.Headers)
+ {
+ clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ foreach (var header in response.Content.Headers)
+ {
+ clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ response.Dispose();
+ return clone;
}
private static void ApplyDefaultHeaders(HttpRequestMessage request, SystemSettingsDto settings, string? refererRoomId)
diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs
index 9170e8a..eeca281 100644
--- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs
+++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs
@@ -418,6 +418,8 @@ public sealed partial class FfmpegService
previousResult,
previousEffectiveOutputPath,
now);
+ var recordUploadService = scope.ServiceProvider.GetRequiredService();
+ 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();
+ 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();
+ 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();
+ await recordUploadService.TryAutoUploadTaskAsync(recordTask.Id, CancellationToken.None);
}
await logService.WriteAsync(
diff --git a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs
index 19c94f6..b88b7f6 100644
--- a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs
+++ b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs
@@ -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 _logger;
private readonly ConcurrentDictionary _exceptionEmailSentAt = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary _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();
var settings = await settingsService.GetAsync(stoppingToken);
-
- delaySeconds = settings.PollingIntervalSeconds;
var ffmpegService = scope.ServiceProvider.GetRequiredService();
var storageGuardService = scope.ServiceProvider.GetRequiredService();
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();
- 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 liveRoomIds,
+ IReadOnlyList 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(liveRoomIds.Count);
+ using var semaphore = new SemaphoreSlim(Math.Min(MaxConcurrentLiveRoomPolls, liveRooms.Count));
+ var tasks = new List(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();
@@ -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);
}
diff --git a/src/LiveRecorder.Infrastructure/Services/PlatformHttpClientFactory.cs b/src/LiveRecorder.Infrastructure/Services/PlatformHttpClientFactory.cs
new file mode 100644
index 0000000..ced06a9
--- /dev/null
+++ b/src/LiveRecorder.Infrastructure/Services/PlatformHttpClientFactory.cs
@@ -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 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);
+ }
+}
diff --git a/src/LiveRecorder.Infrastructure/Services/RecordUploadService.cs b/src/LiveRecorder.Infrastructure/Services/RecordUploadService.cs
new file mode 100644
index 0000000..6419c17
--- /dev/null
+++ b/src/LiveRecorder.Infrastructure/Services/RecordUploadService.cs
@@ -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 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 UploadTaskAsync(
+ Guid recordTaskId,
+ CancellationToken cancellationToken = default)
+ {
+ var settings = await _systemSettingsService.GetAsync(cancellationToken);
+ return await UploadTaskInternalAsync(recordTaskId, settings, automatic: false, cancellationToken);
+ }
+
+ public async Task 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(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 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 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 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(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 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 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();
+}
diff --git a/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs b/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs
index 23727cb..bcce9a5 100644
--- a/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs
+++ b/src/LiveRecorder.WebApi/Controllers/LiveRoomsController.cs
@@ -78,6 +78,13 @@ public sealed class LiveRoomsController : ControllerBase
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.UpdateSettingsAsync(id, request, cancellationToken));
+ [HttpPut("{id:guid}/metadata")]
+ public async Task> UpdateMetadata(
+ Guid id,
+ [FromBody] UpdateLiveRoomMetadataRequest request,
+ CancellationToken cancellationToken) =>
+ Ok(await _liveRoomService.UpdateMetadataAsync(id, request, cancellationToken));
+
[HttpDelete("{id:guid}")]
public async Task Delete(Guid id, CancellationToken cancellationToken)
{
diff --git a/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs b/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs
index 75826b4..40e2a77 100644
--- a/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs
+++ b/src/LiveRecorder.WebApi/Controllers/RecordSessionsController.cs
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
+using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
@@ -12,10 +13,14 @@ public sealed class RecordSessionsController : ControllerBase
private static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(2);
private static readonly JsonSerializerOptions StreamJsonOptions = new(JsonSerializerDefaults.Web);
private readonly RecordSessionService _recordSessionService;
+ private readonly RecordUploadService _recordUploadService;
- public RecordSessionsController(RecordSessionService recordSessionService)
+ public RecordSessionsController(
+ RecordSessionService recordSessionService,
+ RecordUploadService recordUploadService)
{
_recordSessionService = recordSessionService;
+ _recordUploadService = recordUploadService;
}
[HttpGet]
@@ -75,4 +80,8 @@ public sealed class RecordSessionsController : ControllerBase
[FromBody] DeleteRecordSessionsRequest request,
CancellationToken cancellationToken) =>
Ok(await _recordSessionService.DeleteAsync(request, cancellationToken));
+
+ [HttpPost("{id:guid}/upload")]
+ public async Task> Upload(Guid id, CancellationToken cancellationToken) =>
+ Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
}
diff --git a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs
index 187679a..afd03b8 100644
--- a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs
+++ b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
+using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
@@ -9,11 +10,16 @@ namespace LiveRecorder.WebApi.Controllers;
public sealed class RecordTasksController : ControllerBase
{
private readonly RecordService _recordService;
+ private readonly RecordUploadService _recordUploadService;
private readonly LinkGenerator _linkGenerator;
- public RecordTasksController(RecordService recordService, LinkGenerator linkGenerator)
+ public RecordTasksController(
+ RecordService recordService,
+ RecordUploadService recordUploadService,
+ LinkGenerator linkGenerator)
{
_recordService = recordService;
+ _recordUploadService = recordUploadService;
_linkGenerator = linkGenerator;
}
@@ -65,4 +71,8 @@ public sealed class RecordTasksController : ControllerBase
[HttpPost("{id:guid}/transcode")]
public async Task> StartManualTranscode(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordService.StartManualTranscodeAsync(id, cancellationToken));
+
+ [HttpPost("{id:guid}/upload")]
+ public async Task> Upload(Guid id, CancellationToken cancellationToken) =>
+ Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
}
diff --git a/src/LiveRecorder.WebApi/Controllers/SettingsController.cs b/src/LiveRecorder.WebApi/Controllers/SettingsController.cs
index 71f80f4..926f24d 100644
--- a/src/LiveRecorder.WebApi/Controllers/SettingsController.cs
+++ b/src/LiveRecorder.WebApi/Controllers/SettingsController.cs
@@ -4,6 +4,8 @@ using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
+using System.Text;
+using System.Text.Json;
namespace LiveRecorder.WebApi.Controllers;
@@ -41,6 +43,32 @@ public sealed class SettingsController : ControllerBase
CancellationToken cancellationToken) =>
Ok(await _systemSettingsService.UpdateAsync(request, cancellationToken));
+ [HttpGet("export")]
+ public async Task Export(CancellationToken cancellationToken)
+ {
+ var settings = await _systemSettingsService.GetAsync(cancellationToken);
+ var payload = JsonSerializer.Serialize(settings, new JsonSerializerOptions(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = true
+ });
+ var fileName = $"live-recorder-settings-{DateTimeOffset.Now:yyyyMMddHHmmss}.json";
+ return File(
+ new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(payload),
+ "application/json; charset=utf-8",
+ fileName);
+ }
+
+ [HttpPost("import")]
+ public async Task> Import(
+ [FromBody] SystemSettingsDto request,
+ CancellationToken cancellationToken)
+ {
+ var payload = JsonSerializer.Serialize(request);
+ var updateRequest = JsonSerializer.Deserialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web))
+ ?? throw new InvalidOperationException("Unable to deserialize imported settings.");
+ return Ok(await _systemSettingsService.UpdateAsync(updateRequest, cancellationToken));
+ }
+
[HttpPost("test-email")]
public async Task SendTestEmail(
[FromBody] SendTestEmailRequest request,
diff --git a/src/LiveRecorder.WebApi/Program.cs b/src/LiveRecorder.WebApi/Program.cs
index a70a584..effa8bd 100644
--- a/src/LiveRecorder.WebApi/Program.cs
+++ b/src/LiveRecorder.WebApi/Program.cs
@@ -147,6 +147,8 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton();