feat: add recovery center and daily reviews

This commit is contained in:
2026-04-26 11:03:08 +08:00
parent 94f6e08dea
commit 79047b5488
33 changed files with 3127 additions and 40 deletions
@@ -11,6 +11,10 @@ namespace LiveRecorder.Infrastructure.Services;
public sealed class EventScriptService : IEventScriptService
{
private const string ScriptLogPathEnvironmentVariable = "LIVE_RECORDER_SCRIPT_LOG_PATH";
private const int SystemLogDetailMaxLength = 4000;
private const string TruncatedDetailSuffix = "... [truncated to fit system log detail limit]";
private readonly ISystemSettingsService _settingsService;
private readonly ISystemLogService _systemLogService;
private readonly ILogger<EventScriptService> _logger;
@@ -38,6 +42,7 @@ public sealed class EventScriptService : IEventScriptService
settings.EventScriptTimeoutSeconds,
"live_started",
environment,
"Script",
liveRoom.Id,
recordSessionId: null,
recordTaskId: null,
@@ -57,6 +62,7 @@ public sealed class EventScriptService : IEventScriptService
settings.EventScriptTimeoutSeconds,
"live_ended",
environment,
"Script",
liveRoom.Id,
recordSessionId: null,
recordTaskId: null,
@@ -93,12 +99,61 @@ public sealed class EventScriptService : IEventScriptService
settings.EventScriptTimeoutSeconds,
"segment_completed",
environment,
"Script",
liveRoom?.Id ?? recordSession.LiveRoomId,
recordSession.Id,
recordTask.Id,
cancellationToken);
}
public async Task<EventScriptTestResultDto> TestAsync(
TestEventScriptRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var eventName = NormalizeEventType(request.EventType);
if (eventName is null)
{
var result = new EventScriptTestResultDto
{
Success = false,
Message = "Unsupported event script test type.",
Detail = request.EventType
};
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"ScriptTest",
result.Message,
result.Detail,
cancellationToken: cancellationToken);
return result;
}
var outcome = await ExecuteAsync(
request.ScriptMode,
request.ScriptPath,
request.ScriptContent,
request.TimeoutSeconds,
eventName,
BuildTestEnvironment(eventName, DateTimeOffset.UtcNow),
"ScriptTest",
liveRoomId: null,
recordSessionId: null,
recordTaskId: null,
cancellationToken);
return new EventScriptTestResultDto
{
Success = outcome.Success,
Message = outcome.Message,
Detail = outcome.Detail,
CustomLogOutput = outcome.CustomLogOutput
};
}
private async Task RunAsync(
bool enabled,
string scriptMode,
@@ -107,6 +162,7 @@ public sealed class EventScriptService : IEventScriptService
int timeoutSeconds,
string eventName,
IReadOnlyDictionary<string, string> environment,
string logCategory,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
@@ -117,24 +173,70 @@ public sealed class EventScriptService : IEventScriptService
return;
}
await ExecuteAsync(
scriptMode,
scriptPath,
scriptContent,
timeoutSeconds,
eventName,
environment,
logCategory,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
}
private async Task<ScriptExecutionOutcome> ExecuteAsync(
string scriptMode,
string scriptPath,
string scriptContent,
int timeoutSeconds,
string eventName,
IReadOnlyDictionary<string, string> environment,
string logCategory,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
CancellationToken cancellationToken)
{
var execution = CreateExecution(scriptMode, scriptPath, scriptContent);
if (execution is null)
{
return;
}
var missingConfiguration = new ScriptExecutionOutcome(
false,
$"Event script was not configured for {eventName}.",
null,
null);
if (execution.IsMissing)
{
await _systemLogService.WriteAsync(
await WriteOutcomeLogAsync(
missingConfiguration,
logCategory,
SystemLogLevel.Warning,
"Script",
$"Event script was not found for {eventName}.",
execution.Detail,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
return;
return missingConfiguration;
}
if (execution.IsMissing)
{
var missingScript = new ScriptExecutionOutcome(
false,
$"Event script was not found for {eventName}.",
execution.Detail,
null);
await WriteOutcomeLogAsync(
missingScript,
logCategory,
SystemLogLevel.Warning,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
return missingScript;
}
var startInfo = execution.StartInfo!;
@@ -143,12 +245,25 @@ public sealed class EventScriptService : IEventScriptService
startInfo.Environment[pair.Key] = pair.Value;
}
var scriptLogPath = CreateScriptLogPath();
try
{
await File.WriteAllTextAsync(scriptLogPath, string.Empty, CancellationToken.None);
startInfo.Environment[ScriptLogPathEnvironmentVariable] = scriptLogPath;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to prepare event script log file for {EventName}", eventName);
}
using var process = new Process
{
StartInfo = startInfo,
EnableRaisingEvents = true
};
ScriptExecutionOutcome outcome;
SystemLogLevel outcomeLevel;
try
{
process.Start();
@@ -156,44 +271,59 @@ public sealed class EventScriptService : IEventScriptService
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 3600)));
await process.WaitForExitAsync(timeoutCts.Token);
await _systemLogService.WriteAsync(
process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning,
"Script",
outcome = new ScriptExecutionOutcome(
process.ExitCode == 0,
process.ExitCode == 0
? $"Event script completed for {eventName}."
: $"Event script exited with code {process.ExitCode} for {eventName}.",
execution.Detail,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
null);
outcomeLevel = process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
TryKill(process);
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Script",
outcome = new ScriptExecutionOutcome(
false,
$"Event script timed out for {eventName}.",
execution.Detail,
liveRoomId,
recordSessionId,
recordTaskId,
CancellationToken.None);
null);
outcomeLevel = SystemLogLevel.Warning;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Event script failed for {EventName}", eventName);
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Script",
outcome = new ScriptExecutionOutcome(
false,
$"Event script failed for {eventName}.",
ex.ToString(),
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
null);
outcomeLevel = SystemLogLevel.Warning;
}
finally
{
// Any script-provided text is persisted and surfaced separately from the execution outcome.
}
var customLogOutput = await TryWriteCustomLogOutputAsync(
eventName,
logCategory,
scriptLogPath,
liveRoomId,
recordSessionId,
recordTaskId);
TryDeleteFile(scriptLogPath);
outcome = outcome with { CustomLogOutput = customLogOutput };
await WriteOutcomeLogAsync(
outcome,
logCategory,
outcomeLevel,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
return outcome;
}
private static EventScriptExecution? CreateExecution(string scriptMode, string scriptPath, string scriptContent)
@@ -237,6 +367,47 @@ public sealed class EventScriptService : IEventScriptService
};
}
private static IReadOnlyDictionary<string, string> BuildTestEnvironment(string eventName, DateTimeOffset occurredAt)
{
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["LIVE_RECORDER_EVENT"] = eventName,
["LIVE_RECORDER_PLATFORM"] = "Douyin",
["LIVE_RECORDER_LIVE_ROOM_ID"] = "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901",
["LIVE_RECORDER_ROOM_ID"] = "676493068539",
["LIVE_RECORDER_TITLE"] = "Sample live title",
["LIVE_RECORDER_ANCHOR"] = "Sample anchor",
["LIVE_RECORDER_SOURCE_URL"] = "https://live.douyin.com/676493068539",
["LIVE_RECORDER_OCCURRED_AT_UTC"] = occurredAt.ToString("O")
};
if (string.Equals(eventName, "segment_completed", StringComparison.OrdinalIgnoreCase))
{
environment["LIVE_RECORDER_RECORD_SESSION_ID"] = "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77";
environment["LIVE_RECORDER_RECORD_TASK_ID"] = "2a4810a2-7ef4-4a22-90d4-0211b90cc54c";
environment["LIVE_RECORDER_SEGMENT_INDEX"] = "1";
environment["LIVE_RECORDER_SEGMENT_FILE_PATH"] = "/app/records/Douyin/Sample Anchor/2026-04-25/203000_Sample live title__00001.mp4";
environment["LIVE_RECORDER_DANMAKU_FILE_PATH"] = "/app/records/Douyin/Sample Anchor/2026-04-25/203000_Sample live title__00001.xml";
environment["LIVE_RECORDER_DURATION_SECONDS"] = "2185.1";
environment["LIVE_RECORDER_FILE_SIZE_BYTES"] = "734003200";
environment["LIVE_RECORDER_TASK_STATUS"] = "Completed";
environment["LIVE_RECORDER_SESSION_STATUS"] = "Completed";
}
return environment;
}
private static string? NormalizeEventType(string? eventType)
{
return eventType?.Trim().ToLowerInvariant() switch
{
"live_started" => "live_started",
"live_ended" => "live_ended",
"segment_completed" => "segment_completed",
_ => null
};
}
private static ProcessStartInfo CreateStartInfo(string scriptPath)
{
var extension = Path.GetExtension(scriptPath);
@@ -330,6 +501,85 @@ public sealed class EventScriptService : IEventScriptService
return new FileInfo(normalizedPath).Length.ToString();
}
private async Task<string?> TryWriteCustomLogOutputAsync(
string eventName,
string logCategory,
string scriptLogPath,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId)
{
if (string.IsNullOrWhiteSpace(scriptLogPath) || !File.Exists(scriptLogPath))
{
return null;
}
try
{
var output = await File.ReadAllTextAsync(scriptLogPath, CancellationToken.None);
var trimmedOutput = output.Trim();
if (string.IsNullOrWhiteSpace(trimmedOutput))
{
return null;
}
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
logCategory,
$"Event script emitted custom log output for {eventName}.",
TruncateSystemLogDetail(trimmedOutput),
liveRoomId,
recordSessionId,
recordTaskId,
CancellationToken.None);
return trimmedOutput;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to collect event script custom log output for {EventName}", eventName);
return null;
}
}
private async Task WriteOutcomeLogAsync(
ScriptExecutionOutcome outcome,
string logCategory,
SystemLogLevel level,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
CancellationToken cancellationToken)
{
await _systemLogService.WriteAsync(
level,
logCategory,
outcome.Message,
outcome.Detail,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
}
private static string CreateScriptLogPath()
{
return Path.Combine(
Path.GetTempPath(),
$"live-recorder-script-log-{Guid.NewGuid():N}.txt");
}
private static string TruncateSystemLogDetail(string detail)
{
if (detail.Length <= SystemLogDetailMaxLength)
{
return detail;
}
var prefixLength = Math.Max(0, SystemLogDetailMaxLength - TruncatedDetailSuffix.Length);
return string.Concat(detail[..prefixLength], TruncatedDetailSuffix);
}
private static void TryKill(Process process)
{
try
@@ -344,5 +594,25 @@ public sealed class EventScriptService : IEventScriptService
}
}
private static void TryDeleteFile(string path)
{
try
{
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
private sealed record EventScriptExecution(ProcessStartInfo? StartInfo, string Detail, bool IsMissing = false);
private sealed record ScriptExecutionOutcome(
bool Success,
string Message,
string? Detail,
string? CustomLogOutput);
}