feat: improve recording automation and task workflows
This commit is contained in:
@@ -19,6 +19,7 @@ public interface ISystemLogService
|
||||
Guid? liveRoomId = null,
|
||||
Guid? recordSessionId = null,
|
||||
Guid? recordTaskId = null,
|
||||
SystemLogLevel? level = null,
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ public interface ISystemLogRepository
|
||||
Guid? liveRoomId = null,
|
||||
Guid? recordSessionId = null,
|
||||
Guid? recordTaskId = null,
|
||||
SystemLogLevel? level = null,
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ public sealed record LiveStatusSnapshot(
|
||||
bool IsLive,
|
||||
string? Title,
|
||||
string? AnchorName,
|
||||
string? AnchorId,
|
||||
string? AvatarUrl,
|
||||
string? CoverUrl,
|
||||
int? StatusCode,
|
||||
string? RawStatus);
|
||||
@@ -57,4 +59,5 @@ public sealed record StreamUrlResult(
|
||||
string SelectedProtocol,
|
||||
string SelectedUrl,
|
||||
StreamInputHeaders? InputHeaders,
|
||||
IReadOnlyList<StreamQualityOption> AvailableQualities);
|
||||
IReadOnlyList<StreamQualityOption> AvailableQualities,
|
||||
string? SelectedVideoCodec = null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
@@ -9,6 +10,7 @@ public interface IFfmpegService
|
||||
RecordSession recordSession,
|
||||
RecordTask initialTask,
|
||||
StreamUrlResult streamUrlResult,
|
||||
RecordingExecutionSettings recordingSettings,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
|
||||
@@ -28,5 +30,31 @@ public interface IFfmpegService
|
||||
|
||||
Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
bool IsRunning(Guid recordSessionId);
|
||||
|
||||
IReadOnlyDictionary<Guid, RecordTaskRuntimeState> GetTaskRuntimeStates(IReadOnlyCollection<Guid> recordTaskIds);
|
||||
}
|
||||
|
||||
public sealed record RecordTaskRuntimeState(
|
||||
RecordTaskStatus Status,
|
||||
string Stage,
|
||||
double? ProgressPercent,
|
||||
string? Detail);
|
||||
|
||||
public sealed record RecordingExecutionSettings(
|
||||
string PreferredQuality,
|
||||
RecordOutputFormat OutputFormat,
|
||||
RecordSaveMode SaveMode,
|
||||
RecordingTemplateType RecordingTemplate,
|
||||
int SegmentDurationMinutes,
|
||||
bool EnableAutoReconnect,
|
||||
int ReconnectDelayMaxSeconds,
|
||||
int ReadWriteTimeoutMilliseconds,
|
||||
bool EnableDanmakuRecording,
|
||||
bool DanmakuIncludeNonChatEvents,
|
||||
int DanmakuMinPollIntervalMilliseconds,
|
||||
int DanmakuRetryDelayMaxSeconds);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using LiveRecorder.Domain.Entities;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Scripting;
|
||||
|
||||
public interface IEventScriptService
|
||||
{
|
||||
Task RunLiveStartedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RunLiveEndedAsync(LiveRoom liveRoom, DateTimeOffset occurredAt, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RunSegmentCompletedAsync(
|
||||
LiveRoom? liveRoom,
|
||||
RecordSession recordSession,
|
||||
RecordTask recordTask,
|
||||
RecordResult? recordResult,
|
||||
string segmentFilePath,
|
||||
DateTimeOffset occurredAt,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Storage;
|
||||
|
||||
public interface IStorageGuardService
|
||||
{
|
||||
StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0);
|
||||
|
||||
StorageGuardResult CheckShouldPause(SystemSettingsDto settings);
|
||||
}
|
||||
|
||||
public sealed record StorageGuardResult(
|
||||
bool IsEnabled,
|
||||
bool HasEnoughSpace,
|
||||
string CheckedPath,
|
||||
long AvailableBytes,
|
||||
long RequiredBytes,
|
||||
string Message);
|
||||
|
||||
@@ -7,6 +7,81 @@ public sealed class CreateLiveRoomRequest
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public LivePlatformType? PlatformOverride { get; set; }
|
||||
|
||||
public string? AnchorName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportLiveRoomsRequest
|
||||
{
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
public LivePlatformType? PlatformOverride { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportLiveRoomsResultDto
|
||||
{
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
public int FailedCount { get; init; }
|
||||
|
||||
public int CreatedCount { get; init; }
|
||||
|
||||
public int UpdatedCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<ImportLiveRoomItemResultDto> Items { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ImportLiveRoomItemResultDto
|
||||
{
|
||||
public int LineNumber { get; init; }
|
||||
|
||||
public required string RawLine { get; init; }
|
||||
|
||||
public string? Url { get; init; }
|
||||
|
||||
public string? AnchorName { get; init; }
|
||||
|
||||
public bool Success { get; init; }
|
||||
|
||||
public bool Created { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public LiveRoomDto? LiveRoom { get; init; }
|
||||
}
|
||||
|
||||
public sealed class BatchSetLiveRoomsEnabledRequest
|
||||
{
|
||||
public IReadOnlyList<Guid> LiveRoomIds { get; set; } = [];
|
||||
|
||||
public bool IsEnabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BatchDeleteLiveRoomsRequest
|
||||
{
|
||||
public IReadOnlyList<Guid> LiveRoomIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class BatchLiveRoomsResultDto
|
||||
{
|
||||
public int RequestedCount { get; init; }
|
||||
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
public int FailedCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<BatchLiveRoomItemResultDto> Items { get; init; }
|
||||
}
|
||||
|
||||
public sealed class BatchLiveRoomItemResultDto
|
||||
{
|
||||
public Guid LiveRoomId { get; init; }
|
||||
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
public sealed class LiveRoomDto
|
||||
@@ -27,8 +102,16 @@ public sealed class LiveRoomDto
|
||||
|
||||
public string? AnchorName { get; init; }
|
||||
|
||||
public string? AnchorId { get; init; }
|
||||
|
||||
public string? AvatarUrl { get; init; }
|
||||
|
||||
public string? CoverUrl { get; init; }
|
||||
|
||||
public required LiveRoomSettingsOverridesDto Overrides { get; init; }
|
||||
|
||||
public required LiveRoomEffectiveSettingsDto EffectiveSettings { get; init; }
|
||||
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; }
|
||||
@@ -44,3 +127,84 @@ public sealed class SetLiveRoomEnabledRequest
|
||||
{
|
||||
public bool IsEnabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateLiveRoomSettingsRequest
|
||||
{
|
||||
public string? PreferredQualityOverride { get; set; }
|
||||
|
||||
public RecordOutputFormat? OutputFormatOverride { get; set; }
|
||||
|
||||
public RecordSaveMode? SaveModeOverride { get; set; }
|
||||
|
||||
public RecordingTemplateType? RecordingTemplateOverride { get; set; }
|
||||
|
||||
public int? SegmentDurationMinutesOverride { get; set; }
|
||||
|
||||
public bool? EnableAutoReconnectOverride { get; set; }
|
||||
|
||||
public int? ReconnectDelayMaxSecondsOverride { get; set; }
|
||||
|
||||
public int? ReadWriteTimeoutMillisecondsOverride { get; set; }
|
||||
|
||||
public bool? EnableDanmakuRecordingOverride { get; set; }
|
||||
|
||||
public bool? DanmakuIncludeNonChatEventsOverride { get; set; }
|
||||
|
||||
public int? DanmakuMinPollIntervalMillisecondsOverride { get; set; }
|
||||
|
||||
public int? DanmakuRetryDelayMaxSecondsOverride { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LiveRoomSettingsOverridesDto
|
||||
{
|
||||
public string? PreferredQuality { get; init; }
|
||||
|
||||
public RecordOutputFormat? OutputFormat { get; init; }
|
||||
|
||||
public RecordSaveMode? SaveMode { get; init; }
|
||||
|
||||
public RecordingTemplateType? RecordingTemplate { get; init; }
|
||||
|
||||
public int? SegmentDurationMinutes { get; init; }
|
||||
|
||||
public bool? EnableAutoReconnect { get; init; }
|
||||
|
||||
public int? ReconnectDelayMaxSeconds { get; init; }
|
||||
|
||||
public int? ReadWriteTimeoutMilliseconds { get; init; }
|
||||
|
||||
public bool? EnableDanmakuRecording { get; init; }
|
||||
|
||||
public bool? DanmakuIncludeNonChatEvents { get; init; }
|
||||
|
||||
public int? DanmakuMinPollIntervalMilliseconds { get; init; }
|
||||
|
||||
public int? DanmakuRetryDelayMaxSeconds { get; init; }
|
||||
}
|
||||
|
||||
public sealed class LiveRoomEffectiveSettingsDto
|
||||
{
|
||||
public required string PreferredQuality { get; init; }
|
||||
|
||||
public required RecordOutputFormat OutputFormat { get; init; }
|
||||
|
||||
public required RecordSaveMode SaveMode { get; init; }
|
||||
|
||||
public required RecordingTemplateType RecordingTemplate { get; init; }
|
||||
|
||||
public required int SegmentDurationMinutes { get; init; }
|
||||
|
||||
public required bool EnableAutoReconnect { get; init; }
|
||||
|
||||
public required int ReconnectDelayMaxSeconds { get; init; }
|
||||
|
||||
public required int ReadWriteTimeoutMilliseconds { get; init; }
|
||||
|
||||
public required bool EnableDanmakuRecording { get; init; }
|
||||
|
||||
public required bool DanmakuIncludeNonChatEvents { get; init; }
|
||||
|
||||
public required int DanmakuMinPollIntervalMilliseconds { get; init; }
|
||||
|
||||
public required int DanmakuRetryDelayMaxSeconds { get; init; }
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ public sealed class RecordTaskDto
|
||||
public DateTimeOffset? EndedAt { get; init; }
|
||||
|
||||
public double? DurationSeconds { get; init; }
|
||||
|
||||
public string? PostProcessStage { get; init; }
|
||||
|
||||
public double? PostProcessProgressPercent { get; init; }
|
||||
|
||||
public string? PostProcessDetail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecordTaskDetailDto
|
||||
|
||||
@@ -22,6 +22,16 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public int SegmentDurationMinutes { get; set; } = 30;
|
||||
|
||||
public int MaxConcurrentFfmpegTranscodeTasks { get; set; } = 1;
|
||||
|
||||
public int Mp4FinalizeTimeoutMinutes { get; set; } = 60;
|
||||
|
||||
public bool EnableStorageGuard { get; set; } = true;
|
||||
|
||||
public int PauseRecordingWhenFreeSpaceBelowMegabytes { get; set; } = 1024;
|
||||
|
||||
public int ResumeRecordingWhenFreeSpaceAboveMegabytes { get; set; } = 4096;
|
||||
|
||||
public bool EnableAutoReconnect { get; set; } = true;
|
||||
|
||||
public int ReconnectDelayMaxSeconds { get; set; } = 5;
|
||||
@@ -42,6 +52,16 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public int PollingIntervalSeconds { get; set; } = 60;
|
||||
|
||||
public bool EnableEventScripts { get; set; } = false;
|
||||
|
||||
public string LiveStartedScriptPath { get; set; } = string.Empty;
|
||||
|
||||
public string LiveEndedScriptPath { get; set; } = string.Empty;
|
||||
|
||||
public string SegmentCompletedScriptPath { get; set; } = string.Empty;
|
||||
|
||||
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
public bool EnableEmailNotification { get; set; } = false;
|
||||
|
||||
public string EmailSmtpHost { get; set; } = string.Empty;
|
||||
@@ -127,6 +147,16 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public int SegmentDurationMinutes { get; set; } = 30;
|
||||
|
||||
public int MaxConcurrentFfmpegTranscodeTasks { get; set; } = 1;
|
||||
|
||||
public int Mp4FinalizeTimeoutMinutes { get; set; } = 60;
|
||||
|
||||
public bool EnableStorageGuard { get; set; } = true;
|
||||
|
||||
public int PauseRecordingWhenFreeSpaceBelowMegabytes { get; set; } = 1024;
|
||||
|
||||
public int ResumeRecordingWhenFreeSpaceAboveMegabytes { get; set; } = 4096;
|
||||
|
||||
public bool EnableAutoReconnect { get; set; } = true;
|
||||
|
||||
public int ReconnectDelayMaxSeconds { get; set; } = 5;
|
||||
@@ -147,6 +177,16 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public int PollingIntervalSeconds { get; set; } = 60;
|
||||
|
||||
public bool EnableEventScripts { get; set; } = false;
|
||||
|
||||
public string LiveStartedScriptPath { get; set; } = string.Empty;
|
||||
|
||||
public string LiveEndedScriptPath { get; set; } = string.Empty;
|
||||
|
||||
public string SegmentCompletedScriptPath { get; set; } = string.Empty;
|
||||
|
||||
public int EventScriptTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
public bool EnableEmailNotification { get; set; } = false;
|
||||
|
||||
public string EmailSmtpHost { get; set; } = string.Empty;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.LiveRooms;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
|
||||
public sealed class LiveRoomRecordingSettingsResolver
|
||||
{
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
|
||||
public LiveRoomRecordingSettingsResolver(ISystemSettingsService systemSettingsService)
|
||||
{
|
||||
_systemSettingsService = systemSettingsService;
|
||||
}
|
||||
|
||||
public async Task<RecordingExecutionSettings> ResolveAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(liveRoom);
|
||||
|
||||
var systemSettings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
return Resolve(liveRoom, systemSettings);
|
||||
}
|
||||
|
||||
public RecordingExecutionSettings Resolve(LiveRoom liveRoom, SystemSettingsDto systemSettings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(liveRoom);
|
||||
ArgumentNullException.ThrowIfNull(systemSettings);
|
||||
|
||||
return new RecordingExecutionSettings(
|
||||
PreferredQuality: string.IsNullOrWhiteSpace(liveRoom.PreferredQualityOverride)
|
||||
? systemSettings.DefaultQuality
|
||||
: liveRoom.PreferredQualityOverride.Trim(),
|
||||
OutputFormat: liveRoom.OutputFormatOverride ?? systemSettings.DefaultOutputFormat,
|
||||
SaveMode: liveRoom.SaveModeOverride ?? systemSettings.SaveMode,
|
||||
RecordingTemplate: liveRoom.RecordingTemplateOverride ?? systemSettings.RecordingTemplate,
|
||||
SegmentDurationMinutes: liveRoom.SegmentDurationMinutesOverride ?? systemSettings.SegmentDurationMinutes,
|
||||
EnableAutoReconnect: liveRoom.EnableAutoReconnectOverride ?? systemSettings.EnableAutoReconnect,
|
||||
ReconnectDelayMaxSeconds: liveRoom.ReconnectDelayMaxSecondsOverride ?? systemSettings.ReconnectDelayMaxSeconds,
|
||||
ReadWriteTimeoutMilliseconds: liveRoom.ReadWriteTimeoutMillisecondsOverride ?? systemSettings.ReadWriteTimeoutMilliseconds,
|
||||
EnableDanmakuRecording: liveRoom.EnableDanmakuRecordingOverride ?? systemSettings.EnableDanmakuRecording,
|
||||
DanmakuIncludeNonChatEvents: liveRoom.DanmakuIncludeNonChatEventsOverride ?? systemSettings.DanmakuIncludeNonChatEvents,
|
||||
DanmakuMinPollIntervalMilliseconds: liveRoom.DanmakuMinPollIntervalMillisecondsOverride ?? systemSettings.DanmakuMinPollIntervalMilliseconds,
|
||||
DanmakuRetryDelayMaxSeconds: liveRoom.DanmakuRetryDelayMaxSecondsOverride ?? systemSettings.DanmakuRetryDelayMaxSeconds);
|
||||
}
|
||||
|
||||
public LiveRoomSettingsOverridesDto BuildOverridesDto(LiveRoom liveRoom)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(liveRoom);
|
||||
|
||||
return new LiveRoomSettingsOverridesDto
|
||||
{
|
||||
PreferredQuality = liveRoom.PreferredQualityOverride,
|
||||
OutputFormat = liveRoom.OutputFormatOverride,
|
||||
SaveMode = liveRoom.SaveModeOverride,
|
||||
RecordingTemplate = liveRoom.RecordingTemplateOverride,
|
||||
SegmentDurationMinutes = liveRoom.SegmentDurationMinutesOverride,
|
||||
EnableAutoReconnect = liveRoom.EnableAutoReconnectOverride,
|
||||
ReconnectDelayMaxSeconds = liveRoom.ReconnectDelayMaxSecondsOverride,
|
||||
ReadWriteTimeoutMilliseconds = liveRoom.ReadWriteTimeoutMillisecondsOverride,
|
||||
EnableDanmakuRecording = liveRoom.EnableDanmakuRecordingOverride,
|
||||
DanmakuIncludeNonChatEvents = liveRoom.DanmakuIncludeNonChatEventsOverride,
|
||||
DanmakuMinPollIntervalMilliseconds = liveRoom.DanmakuMinPollIntervalMillisecondsOverride,
|
||||
DanmakuRetryDelayMaxSeconds = liveRoom.DanmakuRetryDelayMaxSecondsOverride
|
||||
};
|
||||
}
|
||||
|
||||
public LiveRoomEffectiveSettingsDto BuildEffectiveDto(RecordingExecutionSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
return new LiveRoomEffectiveSettingsDto
|
||||
{
|
||||
PreferredQuality = settings.PreferredQuality,
|
||||
OutputFormat = settings.OutputFormat,
|
||||
SaveMode = settings.SaveMode,
|
||||
RecordingTemplate = settings.RecordingTemplate,
|
||||
SegmentDurationMinutes = settings.SegmentDurationMinutes,
|
||||
EnableAutoReconnect = settings.EnableAutoReconnect,
|
||||
ReconnectDelayMaxSeconds = settings.ReconnectDelayMaxSeconds,
|
||||
ReadWriteTimeoutMilliseconds = settings.ReadWriteTimeoutMilliseconds,
|
||||
EnableDanmakuRecording = settings.EnableDanmakuRecording,
|
||||
DanmakuIncludeNonChatEvents = settings.DanmakuIncludeNonChatEvents,
|
||||
DanmakuMinPollIntervalMilliseconds = settings.DanmakuMinPollIntervalMilliseconds,
|
||||
DanmakuRetryDelayMaxSeconds = settings.DanmakuRetryDelayMaxSeconds
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.LiveRooms;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
@@ -12,6 +14,8 @@ public sealed class LiveRoomService
|
||||
private readonly ILiveRoomRepository _liveRoomRepository;
|
||||
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
|
||||
private readonly LiveRoomStatusService _liveRoomStatusService;
|
||||
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
@@ -20,6 +24,8 @@ public sealed class LiveRoomService
|
||||
ILiveRoomRepository liveRoomRepository,
|
||||
ILivePlatformAdapterFactory livePlatformAdapterFactory,
|
||||
LiveRoomStatusService liveRoomStatusService,
|
||||
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
|
||||
ISystemSettingsService systemSettingsService,
|
||||
StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService,
|
||||
IUnitOfWork unitOfWork,
|
||||
ISystemLogService systemLogService)
|
||||
@@ -27,6 +33,8 @@ public sealed class LiveRoomService
|
||||
_liveRoomRepository = liveRoomRepository;
|
||||
_livePlatformAdapterFactory = livePlatformAdapterFactory;
|
||||
_liveRoomStatusService = liveRoomStatusService;
|
||||
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_systemLogService = systemLogService;
|
||||
@@ -35,30 +43,146 @@ public sealed class LiveRoomService
|
||||
public async Task<IReadOnlyList<LiveRoomDto>> ListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rooms = await _liveRoomRepository.ListAsync(cancellationToken);
|
||||
var effectiveSettings = await BuildEffectiveSettingsLookupAsync(rooms, cancellationToken);
|
||||
return rooms
|
||||
.OrderByDescending(static item => item.UpdatedAt)
|
||||
.Select(Map)
|
||||
.Select(item => Map(item, effectiveSettings[item.Id]))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<LiveRoomDto?> GetAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken);
|
||||
return room is null ? null : Map(room);
|
||||
if (room is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
return Map(room, effectiveSettings);
|
||||
}
|
||||
|
||||
public async Task<LiveRoomDto> CreateAsync(CreateLiveRoomRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var input = request.Url.Trim();
|
||||
var (liveRoom, effectiveSettings, _) = await CreateOrUpdateAsync(
|
||||
request.Url,
|
||||
request.PlatformOverride,
|
||||
request.AnchorName,
|
||||
cancellationToken);
|
||||
|
||||
return Map(liveRoom, effectiveSettings);
|
||||
}
|
||||
|
||||
public async Task<ImportLiveRoomsResultDto> ImportAsync(
|
||||
ImportLiveRoomsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var results = new List<ImportLiveRoomItemResultDto>();
|
||||
var lines = request.Content
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.Select((line, index) => new { RawLine = line, LineNumber = index + 1 })
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.RawLine))
|
||||
.ToArray();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!TryParseImportLine(line.RawLine, out var url, out var anchorName, out var parseError))
|
||||
{
|
||||
results.Add(new ImportLiveRoomItemResultDto
|
||||
{
|
||||
LineNumber = line.LineNumber,
|
||||
RawLine = line.RawLine,
|
||||
Url = url,
|
||||
AnchorName = anchorName,
|
||||
Success = false,
|
||||
Created = false,
|
||||
ErrorMessage = parseError
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (liveRoom, effectiveSettings, created) = await CreateOrUpdateAsync(
|
||||
url,
|
||||
request.PlatformOverride,
|
||||
anchorName,
|
||||
cancellationToken);
|
||||
|
||||
results.Add(new ImportLiveRoomItemResultDto
|
||||
{
|
||||
LineNumber = line.LineNumber,
|
||||
RawLine = line.RawLine,
|
||||
Url = url,
|
||||
AnchorName = anchorName,
|
||||
Success = true,
|
||||
Created = created,
|
||||
LiveRoom = Map(liveRoom, effectiveSettings)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new ImportLiveRoomItemResultDto
|
||||
{
|
||||
LineNumber = line.LineNumber,
|
||||
RawLine = line.RawLine,
|
||||
Url = url,
|
||||
AnchorName = anchorName,
|
||||
Success = false,
|
||||
Created = false,
|
||||
ErrorMessage = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var successCount = results.Count(static item => item.Success);
|
||||
var createdCount = results.Count(static item => item.Success && item.Created);
|
||||
return new ImportLiveRoomsResultDto
|
||||
{
|
||||
TotalCount = results.Count,
|
||||
SuccessCount = successCount,
|
||||
FailedCount = results.Count - successCount,
|
||||
CreatedCount = createdCount,
|
||||
UpdatedCount = successCount - createdCount,
|
||||
Items = results
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<string> ExportAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rooms = await _liveRoomRepository.ListAsync(cancellationToken);
|
||||
var lines = rooms
|
||||
.OrderBy(static item => item.Platform)
|
||||
.ThenBy(static item => item.AnchorName)
|
||||
.ThenBy(static item => item.RoomId)
|
||||
.Select(FormatExportLine)
|
||||
.ToArray();
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private async Task<(LiveRoom LiveRoom, RecordingExecutionSettings EffectiveSettings, bool Created)> CreateOrUpdateAsync(
|
||||
string rawInput,
|
||||
LivePlatformType? platformOverride,
|
||||
string? fallbackAnchorName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var input = rawInput.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
throw new InvalidOperationException("Live room URL is required.");
|
||||
}
|
||||
|
||||
var adapter = request.PlatformOverride.HasValue && request.PlatformOverride.Value != LivePlatformType.Unknown
|
||||
? _livePlatformAdapterFactory.GetByPlatform(request.PlatformOverride.Value)
|
||||
var adapter = platformOverride.HasValue && platformOverride.Value != LivePlatformType.Unknown
|
||||
? _livePlatformAdapterFactory.GetByPlatform(platformOverride.Value)
|
||||
: _livePlatformAdapterFactory.GetByInput(input);
|
||||
|
||||
var parsedRoom = await adapter.ParseRoomAsync(input, cancellationToken);
|
||||
@@ -66,10 +190,12 @@ public sealed class LiveRoomService
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var liveRoom = await _liveRoomRepository.GetByPlatformRoomIdAsync(parsedRoom.PlatformType, parsedRoom.RoomId, cancellationToken);
|
||||
var created = liveRoom is null;
|
||||
if (liveRoom is null)
|
||||
{
|
||||
liveRoom = new LiveRoom(parsedRoom.PlatformType, parsedRoom.SourceUrl, parsedRoom.RoomId, parsedRoom.NormalizedUrl, now);
|
||||
await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken);
|
||||
liveRoom.UpdateMetadata(null, fallbackAnchorName, null, null, null, now);
|
||||
|
||||
await _liveRoomRepository.AddAsync(liveRoom, cancellationToken);
|
||||
}
|
||||
@@ -78,6 +204,7 @@ public sealed class LiveRoomService
|
||||
liveRoom.UpdateSource(parsedRoom.SourceUrl, parsedRoom.NormalizedUrl, now);
|
||||
liveRoom.UpdateRoomId(parsedRoom.RoomId, now);
|
||||
await _liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken);
|
||||
liveRoom.UpdateMetadata(null, fallbackAnchorName, null, null, null, now);
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
@@ -88,7 +215,8 @@ public sealed class LiveRoomService
|
||||
liveRoomId: liveRoom.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return Map(liveRoom);
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(liveRoom, cancellationToken);
|
||||
return (liveRoom, effectiveSettings, created);
|
||||
}
|
||||
|
||||
public async Task<LiveRoomDto> RefreshStatusAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -111,7 +239,8 @@ public sealed class LiveRoomService
|
||||
liveRoomId: room.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return Map(room);
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
return Map(room, effectiveSettings);
|
||||
}
|
||||
|
||||
public async Task<LiveRoomDto> SetEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default)
|
||||
@@ -131,7 +260,80 @@ public sealed class LiveRoomService
|
||||
liveRoomId: room.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return Map(room);
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
return Map(room, effectiveSettings);
|
||||
}
|
||||
|
||||
public async Task<BatchLiveRoomsResultDto> SetEnabledBatchAsync(
|
||||
BatchSetLiveRoomsEnabledRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var results = new List<BatchLiveRoomItemResultDto>();
|
||||
foreach (var liveRoomId in request.LiveRoomIds.Distinct())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await SetEnabledAsync(liveRoomId, request.IsEnabled, cancellationToken);
|
||||
results.Add(new BatchLiveRoomItemResultDto
|
||||
{
|
||||
LiveRoomId = liveRoomId,
|
||||
Success = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new BatchLiveRoomItemResultDto
|
||||
{
|
||||
LiveRoomId = liveRoomId,
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return BuildBatchResult(request.LiveRoomIds.Count, results);
|
||||
}
|
||||
|
||||
public async Task<LiveRoomDto> UpdateSettingsAsync(
|
||||
Guid id,
|
||||
UpdateLiveRoomSettingsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken)
|
||||
?? throw new KeyNotFoundException("Live room was not found.");
|
||||
|
||||
room.UpdateRecordingSettingsOverrides(
|
||||
request.PreferredQualityOverride,
|
||||
request.OutputFormatOverride,
|
||||
request.SaveModeOverride,
|
||||
request.RecordingTemplateOverride,
|
||||
ClampNullable(request.SegmentDurationMinutesOverride, 1, 720),
|
||||
request.EnableAutoReconnectOverride,
|
||||
ClampNullable(request.ReconnectDelayMaxSecondsOverride, 1, 300),
|
||||
ClampNullable(request.ReadWriteTimeoutMillisecondsOverride, 1000, 60000000),
|
||||
request.EnableDanmakuRecordingOverride,
|
||||
request.DanmakuIncludeNonChatEventsOverride,
|
||||
ClampNullable(request.DanmakuMinPollIntervalMillisecondsOverride, 100, 60000),
|
||||
ClampNullable(request.DanmakuRetryDelayMaxSecondsOverride, 1, 300),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"LiveRoom",
|
||||
$"Recording settings updated for room {room.RoomId}.",
|
||||
liveRoomId: room.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
|
||||
return Map(room, effectiveSettings);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -155,7 +357,55 @@ public sealed class LiveRoomService
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static LiveRoomDto Map(LiveRoom room) => new()
|
||||
public async Task<BatchLiveRoomsResultDto> DeleteBatchAsync(
|
||||
BatchDeleteLiveRoomsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var results = new List<BatchLiveRoomItemResultDto>();
|
||||
foreach (var liveRoomId in request.LiveRoomIds.Distinct())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await DeleteAsync(liveRoomId, cancellationToken);
|
||||
results.Add(new BatchLiveRoomItemResultDto
|
||||
{
|
||||
LiveRoomId = liveRoomId,
|
||||
Success = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new BatchLiveRoomItemResultDto
|
||||
{
|
||||
LiveRoomId = liveRoomId,
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return BuildBatchResult(request.LiveRoomIds.Count, results);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, RecordingExecutionSettings>> BuildEffectiveSettingsLookupAsync(
|
||||
IReadOnlyCollection<LiveRoom> rooms,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (rooms.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var systemSettings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
|
||||
return rooms.ToDictionary(item => item.Id, item => _liveRoomRecordingSettingsResolver.Resolve(item, systemSettings));
|
||||
}
|
||||
|
||||
private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings) => new()
|
||||
{
|
||||
Id = room.Id,
|
||||
Platform = room.Platform,
|
||||
@@ -165,11 +415,109 @@ public sealed class LiveRoomService
|
||||
NormalizedUrl = room.NormalizedUrl,
|
||||
Title = room.Title,
|
||||
AnchorName = room.AnchorName,
|
||||
AnchorId = room.AnchorId,
|
||||
AvatarUrl = room.AvatarUrl,
|
||||
CoverUrl = room.CoverUrl,
|
||||
Overrides = _liveRoomRecordingSettingsResolver.BuildOverridesDto(room),
|
||||
EffectiveSettings = _liveRoomRecordingSettingsResolver.BuildEffectiveDto(effectiveSettings),
|
||||
IsEnabled = room.IsEnabled,
|
||||
AvailabilityStatus = room.AvailabilityStatus,
|
||||
LastCheckedAt = room.LastCheckedAt,
|
||||
CreatedAt = room.CreatedAt,
|
||||
UpdatedAt = room.UpdatedAt
|
||||
};
|
||||
|
||||
private static int? ClampNullable(int? value, int min, int max) =>
|
||||
value.HasValue ? Math.Clamp(value.Value, min, max) : null;
|
||||
|
||||
private static BatchLiveRoomsResultDto BuildBatchResult(
|
||||
int requestedCount,
|
||||
IReadOnlyList<BatchLiveRoomItemResultDto> results)
|
||||
{
|
||||
var successCount = results.Count(static item => item.Success);
|
||||
return new BatchLiveRoomsResultDto
|
||||
{
|
||||
RequestedCount = requestedCount,
|
||||
SuccessCount = successCount,
|
||||
FailedCount = results.Count - successCount,
|
||||
Items = results
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseImportLine(
|
||||
string rawLine,
|
||||
out string url,
|
||||
out string? anchorName,
|
||||
out string? errorMessage)
|
||||
{
|
||||
url = string.Empty;
|
||||
anchorName = null;
|
||||
errorMessage = null;
|
||||
|
||||
var line = rawLine.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
errorMessage = "Import line is empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var markerIndex = IndexOfAnchorMarker(line);
|
||||
if (markerIndex >= 0)
|
||||
{
|
||||
var markerLength = line.AsSpan(markerIndex).StartsWith("主播:", StringComparison.OrdinalIgnoreCase)
|
||||
? "主播:".Length
|
||||
: "主播:".Length;
|
||||
url = line[..markerIndex].Trim().TrimEnd(',', ',', ';', ';', '\t', ' ');
|
||||
anchorName = line[(markerIndex + markerLength)..].Trim().Trim(',', ',', ';', ';', '\t', ' ');
|
||||
}
|
||||
else
|
||||
{
|
||||
url = line.Trim().TrimEnd(',', ',', ';', ';');
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
errorMessage = "Import line does not contain a live room URL.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(anchorName))
|
||||
{
|
||||
anchorName = null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int IndexOfAnchorMarker(string line)
|
||||
{
|
||||
var halfWidthIndex = line.IndexOf("主播:", StringComparison.OrdinalIgnoreCase);
|
||||
var fullWidthIndex = line.IndexOf("主播:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (halfWidthIndex < 0)
|
||||
{
|
||||
return fullWidthIndex;
|
||||
}
|
||||
|
||||
if (fullWidthIndex < 0)
|
||||
{
|
||||
return halfWidthIndex;
|
||||
}
|
||||
|
||||
return Math.Min(halfWidthIndex, fullWidthIndex);
|
||||
}
|
||||
|
||||
private static string FormatExportLine(LiveRoom room)
|
||||
{
|
||||
var url = string.IsNullOrWhiteSpace(room.NormalizedUrl) ? room.SourceUrl : room.NormalizedUrl;
|
||||
var anchorName = NormalizeSingleLine(room.AnchorName);
|
||||
return string.IsNullOrWhiteSpace(anchorName)
|
||||
? NormalizeSingleLine(url)
|
||||
: $"{NormalizeSingleLine(url)},主播: {anchorName}";
|
||||
}
|
||||
|
||||
private static string NormalizeSingleLine(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: value.Trim().Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Application.Abstractions.Scripting;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
|
||||
@@ -8,10 +9,14 @@ namespace LiveRecorder.Application.Services;
|
||||
public sealed class LiveRoomStatusService
|
||||
{
|
||||
private readonly IEmailNotificationService _emailNotificationService;
|
||||
private readonly IEventScriptService _eventScriptService;
|
||||
|
||||
public LiveRoomStatusService(IEmailNotificationService emailNotificationService)
|
||||
public LiveRoomStatusService(
|
||||
IEmailNotificationService emailNotificationService,
|
||||
IEventScriptService eventScriptService)
|
||||
{
|
||||
_emailNotificationService = emailNotificationService;
|
||||
_eventScriptService = eventScriptService;
|
||||
}
|
||||
|
||||
public async Task ApplySnapshotAsync(
|
||||
@@ -23,17 +28,32 @@ public sealed class LiveRoomStatusService
|
||||
ArgumentNullException.ThrowIfNull(liveRoom);
|
||||
ArgumentNullException.ThrowIfNull(liveStatus);
|
||||
|
||||
liveRoom.UpdateMetadata(liveStatus.Title, liveStatus.AnchorName, liveStatus.CoverUrl, observedAt);
|
||||
var wasLive = liveRoom.AvailabilityStatus == LiveRoomAvailabilityStatus.Live;
|
||||
|
||||
liveRoom.UpdateMetadata(
|
||||
liveStatus.Title,
|
||||
liveStatus.AnchorName,
|
||||
liveStatus.AnchorId,
|
||||
liveStatus.AvatarUrl,
|
||||
liveStatus.CoverUrl,
|
||||
observedAt);
|
||||
liveRoom.UpdateAvailability(
|
||||
liveStatus.IsLive ? LiveRoomAvailabilityStatus.Live : LiveRoomAvailabilityStatus.Offline,
|
||||
observedAt);
|
||||
|
||||
if (wasLive && !liveStatus.IsLive)
|
||||
{
|
||||
await _eventScriptService.RunLiveEndedAsync(liveRoom, observedAt, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!liveStatus.IsLive || liveRoom.HasSentLiveNotificationForCurrentSession)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _emailNotificationService.SendLiveStartedAsync(liveRoom, cancellationToken);
|
||||
await _eventScriptService.RunLiveStartedAsync(liveRoom, observedAt, cancellationToken);
|
||||
liveRoom.MarkLiveNotificationSent(observedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
@@ -6,7 +7,7 @@ namespace LiveRecorder.Application.Services;
|
||||
|
||||
internal static class RecordModelMapper
|
||||
{
|
||||
public static RecordTaskDto MapTask(RecordTask recordTask) => new()
|
||||
public static RecordTaskDto MapTask(RecordTask recordTask, RecordTaskRuntimeState? runtimeState = null) => new()
|
||||
{
|
||||
Id = recordTask.Id,
|
||||
LiveRoomId = recordTask.LiveRoomId,
|
||||
@@ -15,7 +16,7 @@ internal static class RecordModelMapper
|
||||
LiveRoomTitle = recordTask.LiveRoom?.Title ?? recordTask.LiveRoom?.AnchorName ?? recordTask.LiveRoom?.RoomId ?? "Unknown Room",
|
||||
Platform = recordTask.LiveRoom?.Platform ?? LivePlatformType.Unknown,
|
||||
RoomId = recordTask.LiveRoom?.RoomId ?? string.Empty,
|
||||
Status = recordTask.Status,
|
||||
Status = runtimeState?.Status ?? recordTask.Status,
|
||||
PreferredQuality = recordTask.PreferredQuality,
|
||||
OutputFormat = recordTask.OutputFormat,
|
||||
StreamUrl = recordTask.StreamUrl,
|
||||
@@ -25,7 +26,10 @@ internal static class RecordModelMapper
|
||||
CreatedAt = recordTask.CreatedAt,
|
||||
StartedAt = recordTask.StartedAt,
|
||||
EndedAt = recordTask.EndedAt,
|
||||
DurationSeconds = recordTask.DurationSeconds
|
||||
DurationSeconds = recordTask.DurationSeconds,
|
||||
PostProcessStage = runtimeState?.Stage,
|
||||
PostProcessProgressPercent = runtimeState?.ProgressPercent,
|
||||
PostProcessDetail = runtimeState?.Detail
|
||||
};
|
||||
|
||||
public static RecordResultDto MapResult(RecordResult recordResult) => new()
|
||||
@@ -41,7 +45,9 @@ internal static class RecordModelMapper
|
||||
CreatedAt = recordResult.CreatedAt
|
||||
};
|
||||
|
||||
public static RecordSessionDto MapSession(RecordSession recordSession)
|
||||
public static RecordSessionDto MapSession(
|
||||
RecordSession recordSession,
|
||||
IReadOnlyDictionary<Guid, RecordTaskRuntimeState>? runtimeStates = null)
|
||||
{
|
||||
var orderedTasks = recordSession.RecordTasks
|
||||
.OrderBy(static item => item.SegmentIndex)
|
||||
@@ -74,11 +80,19 @@ internal static class RecordModelMapper
|
||||
EndedAt = recordSession.EndedAt,
|
||||
TotalFileSizeBytes = totalFileSizeBytes,
|
||||
TotalDanmakuMessageCount = totalDanmakuMessageCount,
|
||||
Tasks = orderedTasks.Select(item => MapTaskWithFallback(item, recordSession)).ToList()
|
||||
Tasks = orderedTasks.Select(item => MapTaskWithFallback(
|
||||
item,
|
||||
recordSession,
|
||||
runtimeStates is not null && runtimeStates.TryGetValue(item.Id, out var runtimeState)
|
||||
? runtimeState
|
||||
: null)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static RecordTaskDto MapTaskWithFallback(RecordTask recordTask, RecordSession recordSession)
|
||||
private static RecordTaskDto MapTaskWithFallback(
|
||||
RecordTask recordTask,
|
||||
RecordSession recordSession,
|
||||
RecordTaskRuntimeState? runtimeState)
|
||||
{
|
||||
var liveRoom = recordTask.LiveRoom ?? recordSession.LiveRoom;
|
||||
return new RecordTaskDto
|
||||
@@ -90,7 +104,7 @@ internal static class RecordModelMapper
|
||||
LiveRoomTitle = liveRoom?.Title ?? liveRoom?.AnchorName ?? liveRoom?.RoomId ?? "Unknown Room",
|
||||
Platform = liveRoom?.Platform ?? LivePlatformType.Unknown,
|
||||
RoomId = liveRoom?.RoomId ?? string.Empty,
|
||||
Status = recordTask.Status,
|
||||
Status = runtimeState?.Status ?? recordTask.Status,
|
||||
PreferredQuality = recordTask.PreferredQuality,
|
||||
OutputFormat = recordTask.OutputFormat,
|
||||
StreamUrl = recordTask.StreamUrl,
|
||||
@@ -100,7 +114,10 @@ internal static class RecordModelMapper
|
||||
CreatedAt = recordTask.CreatedAt,
|
||||
StartedAt = recordTask.StartedAt,
|
||||
EndedAt = recordTask.EndedAt,
|
||||
DurationSeconds = recordTask.DurationSeconds
|
||||
DurationSeconds = recordTask.DurationSeconds,
|
||||
PostProcessStage = runtimeState?.Stage,
|
||||
PostProcessProgressPercent = runtimeState?.ProgressPercent,
|
||||
PostProcessDetail = runtimeState?.Detail
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Abstractions.Storage;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
@@ -25,6 +26,8 @@ public sealed class RecordService
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
private readonly IEmailNotificationService _emailNotificationService;
|
||||
private readonly LiveRoomStatusService _liveRoomStatusService;
|
||||
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
|
||||
private readonly IStorageGuardService _storageGuardService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public RecordService(
|
||||
@@ -40,6 +43,8 @@ public sealed class RecordService
|
||||
ISystemLogService systemLogService,
|
||||
IEmailNotificationService emailNotificationService,
|
||||
LiveRoomStatusService liveRoomStatusService,
|
||||
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
|
||||
IStorageGuardService storageGuardService,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
_liveRoomRepository = liveRoomRepository;
|
||||
@@ -54,6 +59,8 @@ public sealed class RecordService
|
||||
_systemLogService = systemLogService;
|
||||
_emailNotificationService = emailNotificationService;
|
||||
_liveRoomStatusService = liveRoomStatusService;
|
||||
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
|
||||
_storageGuardService = storageGuardService;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
@@ -62,9 +69,12 @@ public sealed class RecordService
|
||||
await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken);
|
||||
|
||||
var tasks = await _recordTaskRepository.ListAsync(liveRoomId, cancellationToken);
|
||||
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(tasks.Select(static item => item.Id).ToArray());
|
||||
return tasks
|
||||
.OrderByDescending(static item => item.CreatedAt)
|
||||
.Select(RecordModelMapper.MapTask)
|
||||
.Select(item => RecordModelMapper.MapTask(
|
||||
item,
|
||||
runtimeStates.TryGetValue(item.Id, out var runtimeState) ? runtimeState : null))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -92,10 +102,13 @@ public sealed class RecordService
|
||||
recordTaskId: id,
|
||||
take: 300,
|
||||
cancellationToken: cancellationToken);
|
||||
var runtimeStates = _ffmpegService.GetTaskRuntimeStates([id]);
|
||||
|
||||
return new RecordTaskDetailDto
|
||||
{
|
||||
Task = RecordModelMapper.MapTask(recordTask),
|
||||
Task = RecordModelMapper.MapTask(
|
||||
recordTask,
|
||||
runtimeStates.TryGetValue(id, out var runtimeState) ? runtimeState : null),
|
||||
Result = recordResult is null ? null : RecordModelMapper.MapResult(recordResult),
|
||||
Logs = logs
|
||||
};
|
||||
@@ -120,10 +133,26 @@ public sealed class RecordService
|
||||
}
|
||||
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
var storageCheck = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!storageCheck.HasEnoughSpace)
|
||||
{
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Storage",
|
||||
"Recording start paused because storage is below threshold.",
|
||||
storageCheck.Message,
|
||||
liveRoomId: liveRoom.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
throw new InvalidOperationException(storageCheck.Message);
|
||||
}
|
||||
|
||||
var effectiveSettings = _liveRoomRecordingSettingsResolver.Resolve(liveRoom, settings);
|
||||
var adapter = _livePlatformAdapterFactory.GetByPlatform(liveRoom.Platform);
|
||||
var preferredQuality = string.IsNullOrWhiteSpace(request.PreferredQuality) ? settings.DefaultQuality : request.PreferredQuality.Trim();
|
||||
var outputFormat = request.OutputFormat ?? settings.DefaultOutputFormat;
|
||||
var saveMode = settings.SaveMode;
|
||||
var preferredQuality = string.IsNullOrWhiteSpace(request.PreferredQuality)
|
||||
? effectiveSettings.PreferredQuality
|
||||
: request.PreferredQuality.Trim();
|
||||
var outputFormat = request.OutputFormat ?? effectiveSettings.OutputFormat;
|
||||
var saveMode = effectiveSettings.SaveMode;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
|
||||
@@ -175,7 +204,7 @@ public sealed class RecordService
|
||||
initialTask.MarkStarting(streamResult.SelectedUrl, initialOutputPath, now);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
await _ffmpegService.StartAsync(recordSession, initialTask, streamResult, cancellationToken);
|
||||
await _ffmpegService.StartAsync(recordSession, initialTask, streamResult, effectiveSettings, cancellationToken);
|
||||
|
||||
recordSession.MarkRunning(DateTimeOffset.UtcNow);
|
||||
initialTask.MarkRunning(DateTimeOffset.UtcNow);
|
||||
@@ -349,6 +378,40 @@ public sealed class RecordService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RecordTaskDetailDto> StartManualTranscodeAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken)
|
||||
?? throw new KeyNotFoundException("Recording task was not found.");
|
||||
|
||||
if (recordTask.OutputFormat != RecordOutputFormat.Mp4)
|
||||
{
|
||||
throw new InvalidOperationException("Only MP4 tasks support manual transcoding.");
|
||||
}
|
||||
|
||||
if (IsActiveTaskStatus(recordTask.Status))
|
||||
{
|
||||
throw new InvalidOperationException("The task is still active. Stop the recording before starting manual transcoding.");
|
||||
}
|
||||
|
||||
var started = await _ffmpegService.StartManualFinalizeTaskAsync(id, cancellationToken);
|
||||
if (!started)
|
||||
{
|
||||
throw new InvalidOperationException("No intermediate recording file is available for manual transcoding.");
|
||||
}
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"FFmpeg",
|
||||
"Manual MP4 finalization was requested.",
|
||||
liveRoomId: recordTask.LiveRoomId,
|
||||
recordSessionId: recordTask.RecordSessionId,
|
||||
recordTaskId: recordTask.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return (await GetDetailAsync(id, cancellationToken))
|
||||
?? throw new KeyNotFoundException("Recording task was not found after starting manual transcoding.");
|
||||
}
|
||||
|
||||
public async Task<RecordTaskDto> StopAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var recordTask = await _recordTaskRepository.GetByIdAsync(id, cancellationToken)
|
||||
@@ -404,7 +467,10 @@ public sealed class RecordService
|
||||
};
|
||||
|
||||
private static bool IsActiveTaskStatus(RecordTaskStatus status) =>
|
||||
status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping;
|
||||
status is RecordTaskStatus.Starting
|
||||
or RecordTaskStatus.Running
|
||||
or RecordTaskStatus.Stopping
|
||||
or RecordTaskStatus.Processing;
|
||||
|
||||
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
|
||||
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||
@@ -551,7 +617,8 @@ public sealed class RecordService
|
||||
? (forPathSegment ? "{platform}/{yyyy}/{MM}/{dd}/{anchor}" : "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}")
|
||||
: template;
|
||||
|
||||
var tokens = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
// Path tokens must stay case-sensitive so {MM} (month) and {mm} (minute) don't collide.
|
||||
var tokens = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["platform"] = platform.ToString(),
|
||||
["roomId"] = roomId,
|
||||
@@ -610,6 +677,7 @@ public sealed class RecordService
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
TryDeletePath(outputPath, warnings, deletedFilePaths, $"output for task {recordTask.Id}");
|
||||
TryDeleteIntermediateRecordingArtifacts(outputPath, recordTask.OutputFormat, warnings, deletedFilePaths, recordTask.Id);
|
||||
}
|
||||
|
||||
var danmakuPath = recordTask.Result?.DanmakuFilePath;
|
||||
@@ -619,6 +687,43 @@ public sealed class RecordService
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteIntermediateRecordingArtifacts(
|
||||
string finalOutputPath,
|
||||
RecordOutputFormat outputFormat,
|
||||
List<string> warnings,
|
||||
List<string> deletedFilePaths,
|
||||
Guid recordTaskId)
|
||||
{
|
||||
if (outputFormat != RecordOutputFormat.Mp4)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var absoluteFinalPath = Path.IsPathRooted(finalOutputPath)
|
||||
? finalOutputPath
|
||||
: Path.GetFullPath(finalOutputPath, AppContext.BaseDirectory);
|
||||
|
||||
var intermediateCandidates = new[]
|
||||
{
|
||||
Path.ChangeExtension(absoluteFinalPath, ".ts"),
|
||||
Path.Combine(
|
||||
Path.GetDirectoryName(absoluteFinalPath) ?? string.Empty,
|
||||
$"{Path.GetFileNameWithoutExtension(absoluteFinalPath)}.recording.ts")
|
||||
};
|
||||
|
||||
foreach (var candidate in intermediateCandidates
|
||||
.Where(static path => !string.IsNullOrWhiteSpace(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(candidate, absoluteFinalPath, StringComparison.OrdinalIgnoreCase) || !File.Exists(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryDeletePath(candidate, warnings, deletedFilePaths, $"intermediate output for task {recordTaskId}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeletePath(
|
||||
string path,
|
||||
List<string> warnings,
|
||||
|
||||
@@ -51,9 +51,11 @@ public sealed class RecordSessionService
|
||||
await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken);
|
||||
|
||||
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
|
||||
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
|
||||
sessions.SelectMany(static item => item.RecordTasks).Select(static item => item.Id).ToArray());
|
||||
return sessions
|
||||
.OrderByDescending(static item => item.CreatedAt)
|
||||
.Select(RecordModelMapper.MapSession)
|
||||
.Select(item => RecordModelMapper.MapSession(item, runtimeStates))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -76,9 +78,11 @@ public sealed class RecordSessionService
|
||||
}
|
||||
|
||||
var logs = await _systemLogService.ListAsync(recordSessionId: id, take: 500, cancellationToken: cancellationToken);
|
||||
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
|
||||
session.RecordTasks.Select(static item => item.Id).ToArray());
|
||||
return new RecordSessionDetailDto
|
||||
{
|
||||
Session = RecordModelMapper.MapSession(session),
|
||||
Session = RecordModelMapper.MapSession(session, runtimeStates),
|
||||
Logs = logs
|
||||
};
|
||||
}
|
||||
@@ -105,7 +109,9 @@ public sealed class RecordSessionService
|
||||
recordSessionId: session.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return RecordModelMapper.MapSession(session);
|
||||
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
|
||||
session.RecordTasks.Select(static item => item.Id).ToArray());
|
||||
return RecordModelMapper.MapSession(session, runtimeStates);
|
||||
}
|
||||
|
||||
public async Task<DeleteCompletedRecordTasksResultDto> DeleteAsync(
|
||||
|
||||
@@ -4,6 +4,7 @@ using LiveRecorder.Application.Models.Logs;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiveRecorder.Application.Services;
|
||||
|
||||
@@ -11,13 +12,16 @@ public sealed class SystemLogService : ISystemLogService
|
||||
{
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ISystemLogRepository _systemLogRepository;
|
||||
private readonly ILogger<SystemLogService> _logger;
|
||||
|
||||
public SystemLogService(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ISystemLogRepository systemLogRepository)
|
||||
ISystemLogRepository systemLogRepository,
|
||||
ILogger<SystemLogService> logger)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_systemLogRepository = systemLogRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(
|
||||
@@ -30,23 +34,46 @@ public sealed class SystemLogService : ISystemLogService
|
||||
Guid? recordTaskId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = new SystemLogEntry(level, category, message, detail, liveRoomId, recordSessionId, recordTaskId, DateTimeOffset.UtcNow);
|
||||
try
|
||||
{
|
||||
var entry = new SystemLogEntry(level, category, message, detail, liveRoomId, recordSessionId, recordTaskId, DateTimeOffset.UtcNow);
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<ISystemLogRepository>();
|
||||
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||
await repository.AddAsync(entry, cancellationToken);
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<ISystemLogRepository>();
|
||||
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
|
||||
await repository.AddAsync(entry, cancellationToken);
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Logging must be best-effort. If SQLite is locked/full, throwing from here
|
||||
// causes the scheduler to turn a log persistence failure into an email storm.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"System log write failed. Category={Category}; Message={Message}; LiveRoomId={LiveRoomId}; RecordSessionId={RecordSessionId}; RecordTaskId={RecordTaskId}",
|
||||
category,
|
||||
message,
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SystemLogDto>> ListAsync(
|
||||
Guid? liveRoomId = null,
|
||||
Guid? recordSessionId = null,
|
||||
Guid? recordTaskId = null,
|
||||
SystemLogLevel? level = null,
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = await _systemLogRepository.ListAsync(liveRoomId, recordSessionId, recordTaskId, take, cancellationToken);
|
||||
var entries = await _systemLogRepository.ListAsync(
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
level,
|
||||
take,
|
||||
cancellationToken);
|
||||
return entries
|
||||
.Select(static item => new SystemLogDto
|
||||
{
|
||||
|
||||
@@ -17,6 +17,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
private const string SaveModeKey = "recording.save_mode";
|
||||
private const string RecordingTemplateKey = "recording.template";
|
||||
private const string SegmentDurationMinutesKey = "recording.segment_duration_minutes";
|
||||
private const string MaxConcurrentFfmpegTranscodeTasksKey = "recording.max_concurrent_ffmpeg_transcode_tasks";
|
||||
private const string Mp4FinalizeTimeoutMinutesKey = "recording.mp4_finalize_timeout_minutes";
|
||||
private const string EnableStorageGuardKey = "storage.guard.enabled";
|
||||
private const string PauseRecordingWhenFreeSpaceBelowMegabytesKey = "storage.guard.pause_recording_below_mb";
|
||||
private const string ResumeRecordingWhenFreeSpaceAboveMegabytesKey = "storage.guard.resume_recording_above_mb";
|
||||
private const string EnableReconnectKey = "recording.enable_auto_reconnect";
|
||||
private const string ReconnectDelayMaxSecondsKey = "recording.reconnect_delay_max_seconds";
|
||||
private const string ReadWriteTimeoutMillisecondsKey = "recording.read_write_timeout_milliseconds";
|
||||
@@ -27,6 +32,11 @@ 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 EnableEventScriptsKey = "event_scripts.enabled";
|
||||
private const string LiveStartedScriptPathKey = "event_scripts.live_started.path";
|
||||
private const string LiveEndedScriptPathKey = "event_scripts.live_ended.path";
|
||||
private const string SegmentCompletedScriptPathKey = "event_scripts.segment_completed.path";
|
||||
private const string EventScriptTimeoutSecondsKey = "event_scripts.timeout_seconds";
|
||||
private const string EnableEmailNotificationKey = "notification.email.enabled";
|
||||
private const string EmailSmtpHostKey = "notification.email.smtp_host";
|
||||
private const string EmailSmtpPortKey = "notification.email.smtp_port";
|
||||
@@ -77,6 +87,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
? recordingTemplate
|
||||
: RecordingTemplateType.StreamCopy,
|
||||
SegmentDurationMinutes = GetIntValue(lookup, SegmentDurationMinutesKey, 30, 1, 720),
|
||||
MaxConcurrentFfmpegTranscodeTasks = GetIntValue(lookup, MaxConcurrentFfmpegTranscodeTasksKey, 1, 1, 16),
|
||||
Mp4FinalizeTimeoutMinutes = GetIntValue(lookup, Mp4FinalizeTimeoutMinutesKey, 60, 1, 1440),
|
||||
EnableStorageGuard = bool.TryParse(GetValue(lookup, EnableStorageGuardKey, "true"), out var enableStorageGuard) && enableStorageGuard,
|
||||
PauseRecordingWhenFreeSpaceBelowMegabytes = GetIntValue(lookup, PauseRecordingWhenFreeSpaceBelowMegabytesKey, 1024, 0, 1048576),
|
||||
ResumeRecordingWhenFreeSpaceAboveMegabytes = GetIntValue(lookup, ResumeRecordingWhenFreeSpaceAboveMegabytesKey, 4096, 0, 1048576),
|
||||
EnableAutoReconnect = bool.TryParse(GetValue(lookup, EnableReconnectKey, "true"), out var enableReconnect) && enableReconnect,
|
||||
ReconnectDelayMaxSeconds = GetIntValue(lookup, ReconnectDelayMaxSecondsKey, 5, 1, 300),
|
||||
ReadWriteTimeoutMilliseconds = GetIntValue(lookup, ReadWriteTimeoutMillisecondsKey, 15000000, 1000, 60000000),
|
||||
@@ -87,6 +102,11 @@ 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),
|
||||
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
|
||||
LiveStartedScriptPath = GetValue(lookup, LiveStartedScriptPathKey, string.Empty),
|
||||
LiveEndedScriptPath = GetValue(lookup, LiveEndedScriptPathKey, string.Empty),
|
||||
SegmentCompletedScriptPath = GetValue(lookup, SegmentCompletedScriptPathKey, string.Empty),
|
||||
EventScriptTimeoutSeconds = GetIntValue(lookup, EventScriptTimeoutSecondsKey, 60, 1, 3600),
|
||||
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
|
||||
EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty),
|
||||
EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535),
|
||||
@@ -159,6 +179,27 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(SaveModeKey, request.SaveMode.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RecordingTemplateKey, request.RecordingTemplate.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(SegmentDurationMinutesKey, request.SegmentDurationMinutes.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(
|
||||
MaxConcurrentFfmpegTranscodeTasksKey,
|
||||
Math.Clamp(request.MaxConcurrentFfmpegTranscodeTasks, 1, 16).ToString(),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(
|
||||
Mp4FinalizeTimeoutMinutesKey,
|
||||
Math.Clamp(request.Mp4FinalizeTimeoutMinutes, 1, 1440).ToString(),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(EnableStorageGuardKey, request.EnableStorageGuard.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(
|
||||
PauseRecordingWhenFreeSpaceBelowMegabytesKey,
|
||||
Math.Clamp(request.PauseRecordingWhenFreeSpaceBelowMegabytes, 0, 1048576).ToString(),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(
|
||||
ResumeRecordingWhenFreeSpaceAboveMegabytesKey,
|
||||
Math.Clamp(request.ResumeRecordingWhenFreeSpaceAboveMegabytes, 0, 1048576).ToString(),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(EnableReconnectKey, request.EnableAutoReconnect.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(ReconnectDelayMaxSecondsKey, request.ReconnectDelayMaxSeconds.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(ReadWriteTimeoutMillisecondsKey, request.ReadWriteTimeoutMilliseconds.ToString(), now, cancellationToken);
|
||||
@@ -169,6 +210,11 @@ 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(EnableEventScriptsKey, request.EnableEventScripts.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(LiveStartedScriptPathKey, request.LiveStartedScriptPath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(LiveEndedScriptPathKey, request.LiveEndedScriptPath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(SegmentCompletedScriptPathKey, request.SegmentCompletedScriptPath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(EventScriptTimeoutSecondsKey, Math.Clamp(request.EventScriptTimeoutSeconds, 1, 3600).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user