feat: add recovery center and daily reviews
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user