feat: improve recording automation and task workflows

This commit is contained in:
2026-04-23 23:18:11 +08:00
parent 1c892259a9
commit 23ead56781
88 changed files with 9579 additions and 1581 deletions
@@ -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);