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 _logger; public EventScriptService( ISystemSettingsService settingsService, ISystemLogService systemLogService, IEmailNotificationService emailNotificationService, IWebhookNotificationService webhookNotificationService, ILogger logger) { _settingsService = settingsService; _systemLogService = systemLogService; _emailNotificationService = emailNotificationService; _webhookNotificationService = webhookNotificationService; _logger = logger; } public async Task 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 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 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 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 RunAsync( bool enabled, string scriptMode, string scriptPath, string scriptContent, int timeoutSeconds, int retryAttempts, int retryDelaySeconds, string eventName, IReadOnlyDictionary 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 ExecuteWithRetryAsync( string scriptMode, string scriptPath, string scriptContent, int timeoutSeconds, int retryAttempts, int retryDelaySeconds, string eventName, IReadOnlyDictionary 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 ExecuteAsync( string scriptMode, string scriptPath, string scriptContent, int timeoutSeconds, string eventName, IReadOnlyDictionary 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 BuildLiveRoomEnvironment(LiveRoom? liveRoom, DateTimeOffset occurredAt) { var localOccurredAt = ChinaTime.ToBeijingTime(occurredAt); return new Dictionary(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 BuildTestEnvironment(string eventName, DateTimeOffset occurredAt) { var localOccurredAt = ChinaTime.ToBeijingTime(occurredAt); var environment = new Dictionary(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 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); }