From d2b2f714e0acf8c3bbed9f0a613eead9375291b5 Mon Sep 17 00:00:00 2001 From: nanxun Date: Sun, 26 Apr 2026 17:35:51 +0800 Subject: [PATCH] feat: add live room metadata, uploads, proxies and backups --- frontend/src/components/layout/MainLayout.vue | 177 ++++- frontend/src/types.ts | 144 +++- frontend/src/views/DailyReviewsView.vue | 28 +- frontend/src/views/LiveRoomsView.vue | 175 ++++- frontend/src/views/LogsView.vue | 6 +- .../src/views/RecordSessionDetailView.vue | 19 + frontend/src/views/RecordTaskDetailView.vue | 42 +- frontend/src/views/RecordTasksView.vue | 64 +- frontend/src/views/RecoveryView.vue | 2 +- frontend/src/views/SettingsView.vue | 299 ++++++++- .../Persistence/PersistenceContracts.cs | 2 + .../Common/AutoStartDecisionCodes.cs | 2 + .../Models/LiveRooms/LiveRoomModels.cs | 27 + .../Models/RecordTasks/RecordTaskModels.cs | 42 ++ .../Models/Settings/SettingsModels.cs | 80 +++ .../Services/LiveRoomService.cs | 84 ++- .../Services/RecordModelMapper.cs | 7 + .../Services/RecordService.cs | 45 +- .../Services/SystemSettingsService.cs | 88 +++ src/LiveRecorder.Domain/Entities/LiveRoom.cs | 35 + .../Entities/RecordResult.cs | 42 ++ .../Enums/LiveRoomCurrentRecordingState.cs | 8 + .../Enums/RecordArtifactUploadStatus.cs | 8 + .../Enums/UploadTargetType.cs | 8 + .../Persistence/DatabaseInitializer.cs | 35 + .../Persistence/LiveRecorderDbContext.cs | 9 + .../Persistence/Repositories/Repositories.cs | 10 + .../Platforms/Bilibili/BilibiliHttpClient.cs | 50 +- .../Platforms/Douyin/DouyinHttpClient.cs | 59 +- .../Services/FfmpegService.Runtime.cs | 8 + .../LiveRoomPollingBackgroundService.cs | 122 +++- .../Services/PlatformHttpClientFactory.cs | 78 +++ .../Services/RecordUploadService.cs | 622 ++++++++++++++++++ .../Controllers/LiveRoomsController.cs | 7 + .../Controllers/RecordSessionsController.cs | 11 +- .../Controllers/RecordTasksController.cs | 12 +- .../Controllers/SettingsController.cs | 28 + src/LiveRecorder.WebApi/Program.cs | 2 + 38 files changed, 2371 insertions(+), 116 deletions(-) create mode 100644 src/LiveRecorder.Domain/Enums/LiveRoomCurrentRecordingState.cs create mode 100644 src/LiveRecorder.Domain/Enums/RecordArtifactUploadStatus.cs create mode 100644 src/LiveRecorder.Domain/Enums/UploadTargetType.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/PlatformHttpClientFactory.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/RecordUploadService.cs diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index f9bdf7d..d848a70 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -31,7 +31,7 @@ const { themeMode, density, resolvedTheme, sidebarCollapsed, cycleThemeMode, tog const mobileNavVisible = ref(false); -const displayName = computed(() => authStore.user?.displayName ?? "Operator"); +const displayName = computed(() => authStore.user?.displayName ?? "管理员"); const navigationGroups = [ { @@ -133,6 +133,12 @@ const currentThemeIcon = computed(() => { return Monitor; }); +const shouldShowGlobalBackendAlert = computed(() => backendUnavailable.value && route.name !== "record-tasks"); + +function isNavItemActive(index: string) { + return route.path === index || route.path.startsWith(`${index}/`); +} + watch( () => route.fullPath, () => { @@ -166,7 +172,25 @@ async function handleLogout() {
{{ group.title }}
- +
+ + + +
+ @@ -175,6 +199,50 @@ async function handleLogout() {
+
+ + + +
+ + +
@@ -664,6 +782,181 @@ onMounted(loadSettings); + +

上传与归档

+

自动或手动把视频和对应弹幕 XML 上传到单一目标端,并按开关决定是否在成功后删除本地文件。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

WebDAV 目标

+

按录制相对路径创建远端目录并上传视频与弹幕文件。

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+

S3 目标

+

支持自定义 Endpoint、Bucket、Region 和路径前缀,适合对象存储兼容服务。

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ 自动上传固定处理“视频文件 + 对应弹幕 XML”。只有两者都上传成功并且你打开“上传后删本地”时,系统才会清理本地文件。 +
+
+ + +

平台代理

+

代理仅作用于平台状态查询、取流和平台侧请求,不影响邮件、Webhook 和文件上传。

+ +
+
+
+
+

Douyin

+

适合单独给抖音状态查询和取流请求走代理。

+
+ +
+ + + +
+ +
+
+
+

Bilibili

+

适合单独给 Bilibili 平台请求启用或关闭代理。

+
+ +
+ + + +
+ +
+
+
+

Huya

+

虎牙平台单独配置,不会和其它平台共用代理状态。

+
+ +
+ + + +
+
+
+

事件脚本

@@ -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();