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

This commit is contained in:
2026-04-26 17:35:51 +08:00
parent 85f24f8a84
commit d2b2f714e0
38 changed files with 2371 additions and 116 deletions
@@ -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<LiveRoomDto> 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<ImportLiveRoomsResultDto> 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<LiveRoomDto> 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<BatchLiveRoomsResultDto> 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<LiveRoomDto> 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;
@@ -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
};
@@ -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,
@@ -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);