858 lines
30 KiB
C#
858 lines
30 KiB
C#
using System.Diagnostics;
|
|
using System.Text;
|
|
using LiveRecorder.Application.Abstractions.Logging;
|
|
using LiveRecorder.Application.Abstractions.Notifications;
|
|
using LiveRecorder.Application.Abstractions.Scripting;
|
|
using LiveRecorder.Application.Abstractions.Settings;
|
|
using LiveRecorder.Application.Common;
|
|
using LiveRecorder.Application.Models.Settings;
|
|
using LiveRecorder.Domain.Entities;
|
|
using LiveRecorder.Domain.Enums;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
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 IEmailNotificationService _emailNotificationService;
|
|
private readonly IWebhookNotificationService _webhookNotificationService;
|
|
private readonly ILogger<EventScriptService> _logger;
|
|
|
|
public EventScriptService(
|
|
ISystemSettingsService settingsService,
|
|
ISystemLogService systemLogService,
|
|
IEmailNotificationService emailNotificationService,
|
|
IWebhookNotificationService webhookNotificationService,
|
|
ILogger<EventScriptService> logger)
|
|
{
|
|
_settingsService = settingsService;
|
|
_systemLogService = systemLogService;
|
|
_emailNotificationService = emailNotificationService;
|
|
_webhookNotificationService = webhookNotificationService;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<EventScriptExecutionResultDto?> RunLiveStartedAsync(
|
|
LiveRoom liveRoom,
|
|
DateTimeOffset occurredAt,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var settings = await _settingsService.GetAsync(cancellationToken);
|
|
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
|
|
environment["LIVE_RECORDER_EVENT"] = "live_started";
|
|
return await RunAsync(
|
|
settings.EnableEventScripts && settings.EnableLiveStartedScript,
|
|
settings.LiveStartedScriptMode,
|
|
settings.LiveStartedScriptPath,
|
|
settings.LiveStartedScriptContent,
|
|
settings.EventScriptTimeoutSeconds,
|
|
settings.EventScriptRetryAttempts,
|
|
settings.EventScriptRetryDelaySeconds,
|
|
"live_started",
|
|
environment,
|
|
liveRoom,
|
|
recordTask: null,
|
|
"Script",
|
|
liveRoom.Id,
|
|
recordSessionId: null,
|
|
recordTaskId: null,
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<EventScriptExecutionResultDto?> RunLiveEndedAsync(
|
|
LiveRoom liveRoom,
|
|
DateTimeOffset occurredAt,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var settings = await _settingsService.GetAsync(cancellationToken);
|
|
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
|
|
environment["LIVE_RECORDER_EVENT"] = "live_ended";
|
|
return await RunAsync(
|
|
settings.EnableEventScripts && settings.EnableLiveEndedScript,
|
|
settings.LiveEndedScriptMode,
|
|
settings.LiveEndedScriptPath,
|
|
settings.LiveEndedScriptContent,
|
|
settings.EventScriptTimeoutSeconds,
|
|
settings.EventScriptRetryAttempts,
|
|
settings.EventScriptRetryDelaySeconds,
|
|
"live_ended",
|
|
environment,
|
|
liveRoom,
|
|
recordTask: null,
|
|
"Script",
|
|
liveRoom.Id,
|
|
recordSessionId: null,
|
|
recordTaskId: null,
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<EventScriptExecutionResultDto?> RunSegmentCompletedAsync(
|
|
LiveRoom? liveRoom,
|
|
RecordSession recordSession,
|
|
RecordTask recordTask,
|
|
RecordResult? recordResult,
|
|
string segmentFilePath,
|
|
DateTimeOffset occurredAt,
|
|
bool forceRun = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var settings = await _settingsService.GetAsync(cancellationToken);
|
|
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
|
|
environment["LIVE_RECORDER_EVENT"] = "segment_completed";
|
|
environment["LIVE_RECORDER_RECORD_SESSION_ID"] = recordSession.Id.ToString();
|
|
environment["LIVE_RECORDER_RECORD_TASK_ID"] = recordTask.Id.ToString();
|
|
environment["LIVE_RECORDER_SEGMENT_INDEX"] = recordTask.SegmentIndex.ToString();
|
|
environment["LIVE_RECORDER_SEGMENT_FILE_PATH"] = NormalizePath(segmentFilePath);
|
|
environment["LIVE_RECORDER_DANMAKU_FILE_PATH"] = NormalizePath(recordResult?.DanmakuFilePath);
|
|
environment["LIVE_RECORDER_DURATION_SECONDS"] = recordResult?.DurationSeconds?.ToString("0.###") ?? recordTask.DurationSeconds?.ToString("0.###") ?? string.Empty;
|
|
environment["LIVE_RECORDER_FILE_SIZE_BYTES"] = recordResult?.FileSizeBytes?.ToString() ?? TryGetFileSize(segmentFilePath);
|
|
environment["LIVE_RECORDER_TASK_STATUS"] = recordTask.Status.ToString();
|
|
environment["LIVE_RECORDER_SESSION_STATUS"] = recordSession.Status.ToString();
|
|
|
|
return await RunAsync(
|
|
forceRun || (settings.EnableEventScripts && settings.EnableSegmentCompletedScript),
|
|
settings.SegmentCompletedScriptMode,
|
|
settings.SegmentCompletedScriptPath,
|
|
settings.SegmentCompletedScriptContent,
|
|
settings.EventScriptTimeoutSeconds,
|
|
settings.EventScriptRetryAttempts,
|
|
settings.EventScriptRetryDelaySeconds,
|
|
"segment_completed",
|
|
environment,
|
|
liveRoom,
|
|
recordTask,
|
|
"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 static EventScriptExecutionResultDto MapOutcome(ScriptExecutionOutcome outcome) => new()
|
|
{
|
|
Success = outcome.Success,
|
|
Message = outcome.Message,
|
|
Detail = outcome.Detail,
|
|
CustomLogOutput = outcome.CustomLogOutput
|
|
};
|
|
|
|
private async Task<EventScriptExecutionResultDto?> RunAsync(
|
|
bool enabled,
|
|
string scriptMode,
|
|
string scriptPath,
|
|
string scriptContent,
|
|
int timeoutSeconds,
|
|
int retryAttempts,
|
|
int retryDelaySeconds,
|
|
string eventName,
|
|
IReadOnlyDictionary<string, string> environment,
|
|
LiveRoom? liveRoom,
|
|
RecordTask? recordTask,
|
|
string logCategory,
|
|
Guid? liveRoomId,
|
|
Guid? recordSessionId,
|
|
Guid? recordTaskId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!enabled)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var outcome = await ExecuteWithRetryAsync(
|
|
scriptMode,
|
|
scriptPath,
|
|
scriptContent,
|
|
timeoutSeconds,
|
|
retryAttempts,
|
|
retryDelaySeconds,
|
|
eventName,
|
|
environment,
|
|
liveRoom,
|
|
recordTask,
|
|
logCategory,
|
|
liveRoomId,
|
|
recordSessionId,
|
|
recordTaskId,
|
|
cancellationToken);
|
|
|
|
return MapOutcome(outcome);
|
|
}
|
|
|
|
private async Task<ScriptExecutionOutcome> ExecuteWithRetryAsync(
|
|
string scriptMode,
|
|
string scriptPath,
|
|
string scriptContent,
|
|
int timeoutSeconds,
|
|
int retryAttempts,
|
|
int retryDelaySeconds,
|
|
string eventName,
|
|
IReadOnlyDictionary<string, string> environment,
|
|
LiveRoom? liveRoom,
|
|
RecordTask? recordTask,
|
|
string logCategory,
|
|
Guid? liveRoomId,
|
|
Guid? recordSessionId,
|
|
Guid? recordTaskId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var outcome = await ExecuteAsync(
|
|
scriptMode,
|
|
scriptPath,
|
|
scriptContent,
|
|
timeoutSeconds,
|
|
eventName,
|
|
environment,
|
|
logCategory,
|
|
liveRoomId,
|
|
recordSessionId,
|
|
recordTaskId,
|
|
cancellationToken);
|
|
|
|
if (outcome.Success || !IsRetryableFailure(outcome))
|
|
{
|
|
return outcome;
|
|
}
|
|
|
|
var maxRetryAttempts = Math.Clamp(retryAttempts, 0, 20);
|
|
var totalAttempts = 1;
|
|
|
|
for (var retryIndex = 1; retryIndex <= maxRetryAttempts; retryIndex++)
|
|
{
|
|
await _systemLogService.WriteAsync(
|
|
SystemLogLevel.Warning,
|
|
logCategory,
|
|
$"Event script retry {retryIndex} of {maxRetryAttempts} scheduled for {eventName}.",
|
|
BuildRetryAttemptDetail(eventName, retryIndex + 1, maxRetryAttempts + 1, retryDelaySeconds, outcome),
|
|
liveRoomId,
|
|
recordSessionId,
|
|
recordTaskId,
|
|
cancellationToken);
|
|
|
|
if (retryDelaySeconds > 0)
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(retryDelaySeconds, 0, 3600)), cancellationToken);
|
|
}
|
|
|
|
outcome = await ExecuteAsync(
|
|
scriptMode,
|
|
scriptPath,
|
|
scriptContent,
|
|
timeoutSeconds,
|
|
eventName,
|
|
environment,
|
|
logCategory,
|
|
liveRoomId,
|
|
recordSessionId,
|
|
recordTaskId,
|
|
cancellationToken);
|
|
totalAttempts++;
|
|
|
|
if (outcome.Success || !IsRetryableFailure(outcome))
|
|
{
|
|
return outcome;
|
|
}
|
|
}
|
|
|
|
await NotifyRetryExhaustedAsync(eventName, totalAttempts, outcome, liveRoom, recordTask, cancellationToken);
|
|
return outcome;
|
|
}
|
|
|
|
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)
|
|
{
|
|
var missingConfiguration = new ScriptExecutionOutcome(
|
|
false,
|
|
$"Event script was not configured for {eventName}.",
|
|
null,
|
|
null,
|
|
null,
|
|
ScriptFailureKind.MissingConfiguration);
|
|
|
|
await WriteOutcomeLogAsync(
|
|
missingConfiguration,
|
|
logCategory,
|
|
SystemLogLevel.Warning,
|
|
liveRoomId,
|
|
recordSessionId,
|
|
recordTaskId,
|
|
cancellationToken);
|
|
return missingConfiguration;
|
|
}
|
|
|
|
if (execution.IsMissing)
|
|
{
|
|
var missingScript = new ScriptExecutionOutcome(
|
|
false,
|
|
$"Event script was not found for {eventName}.",
|
|
execution.Detail,
|
|
null,
|
|
execution.Detail,
|
|
ScriptFailureKind.MissingScript);
|
|
|
|
await WriteOutcomeLogAsync(
|
|
missingScript,
|
|
logCategory,
|
|
SystemLogLevel.Warning,
|
|
liveRoomId,
|
|
recordSessionId,
|
|
recordTaskId,
|
|
cancellationToken);
|
|
return missingScript;
|
|
}
|
|
|
|
var startInfo = execution.StartInfo!;
|
|
foreach (var pair in environment)
|
|
{
|
|
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();
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 3600)));
|
|
await process.WaitForExitAsync(timeoutCts.Token);
|
|
|
|
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,
|
|
null,
|
|
execution.Detail,
|
|
process.ExitCode == 0 ? ScriptFailureKind.None : ScriptFailureKind.ExitCode);
|
|
outcomeLevel = process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning;
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
TryKill(process);
|
|
outcome = new ScriptExecutionOutcome(
|
|
false,
|
|
$"Event script timed out for {eventName}.",
|
|
execution.Detail,
|
|
null,
|
|
execution.Detail,
|
|
ScriptFailureKind.Timeout);
|
|
outcomeLevel = SystemLogLevel.Warning;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Event script failed for {EventName}", eventName);
|
|
outcome = new ScriptExecutionOutcome(
|
|
false,
|
|
$"Event script failed for {eventName}.",
|
|
ex.ToString(),
|
|
null,
|
|
execution.Detail,
|
|
ScriptFailureKind.Exception);
|
|
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 async Task NotifyRetryExhaustedAsync(
|
|
string eventName,
|
|
int totalAttempts,
|
|
ScriptExecutionOutcome outcome,
|
|
LiveRoom? liveRoom,
|
|
RecordTask? recordTask,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var detail = BuildRetryFailureNotificationDetail(eventName, totalAttempts, outcome);
|
|
await _emailNotificationService.SendExceptionAsync(
|
|
"EventScript",
|
|
"Event script failed after retries.",
|
|
detail,
|
|
liveRoom,
|
|
recordTask,
|
|
cancellationToken);
|
|
await _webhookNotificationService.SendExceptionAsync(
|
|
"EventScript",
|
|
"Event script failed after retries.",
|
|
detail,
|
|
liveRoom,
|
|
recordTask,
|
|
cancellationToken);
|
|
}
|
|
|
|
private static EventScriptExecution? CreateExecution(string scriptMode, string scriptPath, string scriptContent)
|
|
{
|
|
if (string.Equals(scriptMode, EventScriptSourceModes.Inline, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(scriptContent))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new EventScriptExecution(CreateInlineStartInfo(scriptContent), "[inline script]");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(scriptPath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var normalizedScriptPath = NormalizePath(scriptPath);
|
|
if (!File.Exists(normalizedScriptPath))
|
|
{
|
|
return new EventScriptExecution(null, normalizedScriptPath, IsMissing: true);
|
|
}
|
|
|
|
return new EventScriptExecution(CreateStartInfo(normalizedScriptPath), normalizedScriptPath);
|
|
}
|
|
|
|
private static Dictionary<string, string> BuildLiveRoomEnvironment(LiveRoom? liveRoom, DateTimeOffset occurredAt)
|
|
{
|
|
var localOccurredAt = ChinaTime.ToBeijingTime(occurredAt);
|
|
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["LIVE_RECORDER_EVENT"] = string.Empty,
|
|
["LIVE_RECORDER_PLATFORM"] = liveRoom?.Platform.ToString() ?? string.Empty,
|
|
["LIVE_RECORDER_LIVE_ROOM_ID"] = liveRoom?.Id.ToString() ?? string.Empty,
|
|
["LIVE_RECORDER_ROOM_ID"] = liveRoom?.RoomId ?? string.Empty,
|
|
["LIVE_RECORDER_TITLE"] = liveRoom?.Title ?? string.Empty,
|
|
["LIVE_RECORDER_ANCHOR"] = liveRoom?.AnchorName ?? string.Empty,
|
|
["LIVE_RECORDER_SOURCE_URL"] = liveRoom?.SourceUrl ?? liveRoom?.NormalizedUrl ?? string.Empty,
|
|
["LIVE_RECORDER_OCCURRED_AT_UTC"] = localOccurredAt.ToString("O")
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string> BuildTestEnvironment(string eventName, DateTimeOffset occurredAt)
|
|
{
|
|
var localOccurredAt = ChinaTime.ToBeijingTime(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"] = localOccurredAt.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);
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
UseShellExecute = false,
|
|
RedirectStandardError = false,
|
|
RedirectStandardOutput = false,
|
|
CreateNoWindow = true,
|
|
WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? AppContext.BaseDirectory
|
|
};
|
|
|
|
if (OperatingSystem.IsWindows() && extension.Equals(".ps1", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
startInfo.FileName = "powershell";
|
|
startInfo.ArgumentList.Add("-NoProfile");
|
|
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
|
startInfo.ArgumentList.Add("Bypass");
|
|
startInfo.ArgumentList.Add("-File");
|
|
startInfo.ArgumentList.Add(scriptPath);
|
|
return startInfo;
|
|
}
|
|
|
|
if (OperatingSystem.IsWindows() && (extension.Equals(".bat", StringComparison.OrdinalIgnoreCase) || extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
startInfo.FileName = "cmd";
|
|
startInfo.ArgumentList.Add("/c");
|
|
startInfo.ArgumentList.Add(scriptPath);
|
|
return startInfo;
|
|
}
|
|
|
|
if (!OperatingSystem.IsWindows() && extension.Equals(".sh", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
startInfo.FileName = "/bin/sh";
|
|
startInfo.ArgumentList.Add(scriptPath);
|
|
return startInfo;
|
|
}
|
|
|
|
startInfo.FileName = scriptPath;
|
|
return startInfo;
|
|
}
|
|
|
|
private static ProcessStartInfo CreateInlineStartInfo(string scriptContent)
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
UseShellExecute = false,
|
|
RedirectStandardError = false,
|
|
RedirectStandardOutput = false,
|
|
CreateNoWindow = true,
|
|
WorkingDirectory = AppContext.BaseDirectory
|
|
};
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
startInfo.FileName = "powershell";
|
|
startInfo.ArgumentList.Add("-NoProfile");
|
|
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
|
startInfo.ArgumentList.Add("Bypass");
|
|
startInfo.ArgumentList.Add("-Command");
|
|
startInfo.ArgumentList.Add(scriptContent);
|
|
return startInfo;
|
|
}
|
|
|
|
startInfo.FileName = "/bin/sh";
|
|
startInfo.ArgumentList.Add("-c");
|
|
startInfo.ArgumentList.Add(scriptContent);
|
|
return startInfo;
|
|
}
|
|
|
|
private static string NormalizePath(string? path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
return Path.IsPathRooted(path)
|
|
? path
|
|
: Path.GetFullPath(path, AppContext.BaseDirectory);
|
|
}
|
|
|
|
private static string TryGetFileSize(string? path)
|
|
{
|
|
var normalizedPath = NormalizePath(path);
|
|
if (string.IsNullOrWhiteSpace(normalizedPath) || !File.Exists(normalizedPath))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
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 BuildRetryAttemptDetail(
|
|
string eventName,
|
|
int nextAttempt,
|
|
int totalAttempts,
|
|
int retryDelaySeconds,
|
|
ScriptExecutionOutcome outcome)
|
|
{
|
|
var builder = new StringBuilder();
|
|
builder.AppendLine($"Event: {eventName}");
|
|
builder.AppendLine($"Next attempt: {nextAttempt}/{totalAttempts}");
|
|
builder.AppendLine($"Retry delay: {Math.Clamp(retryDelaySeconds, 0, 3600)} second(s)");
|
|
builder.AppendLine($"Last result: {outcome.Message}");
|
|
|
|
if (!string.IsNullOrWhiteSpace(outcome.ExecutionTarget))
|
|
{
|
|
builder.AppendLine($"Script: {outcome.ExecutionTarget}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(outcome.Detail) &&
|
|
!string.Equals(outcome.Detail, outcome.ExecutionTarget, StringComparison.Ordinal))
|
|
{
|
|
builder.AppendLine();
|
|
builder.AppendLine("Detail:");
|
|
builder.AppendLine(outcome.Detail);
|
|
}
|
|
|
|
return TruncateSystemLogDetail(builder.ToString().Trim());
|
|
}
|
|
|
|
private static string BuildRetryFailureNotificationDetail(
|
|
string eventName,
|
|
int totalAttempts,
|
|
ScriptExecutionOutcome outcome)
|
|
{
|
|
var builder = new StringBuilder();
|
|
builder.AppendLine($"Event: {eventName}");
|
|
builder.AppendLine($"Attempts: {totalAttempts}");
|
|
builder.AppendLine($"Last result: {outcome.Message}");
|
|
|
|
if (!string.IsNullOrWhiteSpace(outcome.ExecutionTarget))
|
|
{
|
|
builder.AppendLine($"Script: {outcome.ExecutionTarget}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(outcome.Detail) &&
|
|
!string.Equals(outcome.Detail, outcome.ExecutionTarget, StringComparison.Ordinal))
|
|
{
|
|
builder.AppendLine();
|
|
builder.AppendLine("Detail:");
|
|
builder.AppendLine(outcome.Detail);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(outcome.CustomLogOutput))
|
|
{
|
|
builder.AppendLine();
|
|
builder.AppendLine("Custom log output:");
|
|
builder.AppendLine(outcome.CustomLogOutput);
|
|
}
|
|
|
|
return TruncateSystemLogDetail(builder.ToString().Trim());
|
|
}
|
|
|
|
private static bool IsRetryableFailure(ScriptExecutionOutcome outcome) =>
|
|
outcome.FailureKind is ScriptFailureKind.ExitCode or ScriptFailureKind.Timeout or ScriptFailureKind.Exception;
|
|
|
|
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
|
|
{
|
|
if (!process.HasExited)
|
|
{
|
|
process.Kill(true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
private static void TryDeleteFile(string path)
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
private enum ScriptFailureKind
|
|
{
|
|
None,
|
|
MissingConfiguration,
|
|
MissingScript,
|
|
ExitCode,
|
|
Timeout,
|
|
Exception
|
|
}
|
|
|
|
private sealed record EventScriptExecution(ProcessStartInfo? StartInfo, string Detail, bool IsMissing = false);
|
|
|
|
private sealed record ScriptExecutionOutcome(
|
|
bool Success,
|
|
string Message,
|
|
string? Detail,
|
|
string? CustomLogOutput,
|
|
string? ExecutionTarget,
|
|
ScriptFailureKind FailureKind);
|
|
}
|