feat: support inline event scripts

This commit is contained in:
2026-04-25 14:32:30 +08:00
parent 05961eb700
commit aa548db980
5 changed files with 278 additions and 16 deletions
@@ -2,6 +2,13 @@ using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Models.Settings;
public static class EventScriptSourceModes
{
public const string Path = "path";
public const string Inline = "inline";
}
public sealed class SystemSettingsDto
{
public string FfmpegPath { get; set; } = "ffmpeg";
@@ -54,12 +61,24 @@ public sealed class SystemSettingsDto
public bool EnableEventScripts { get; set; } = false;
public string LiveStartedScriptMode { get; set; } = EventScriptSourceModes.Path;
public string LiveStartedScriptPath { get; set; } = string.Empty;
public string LiveStartedScriptContent { get; set; } = string.Empty;
public string LiveEndedScriptMode { get; set; } = EventScriptSourceModes.Path;
public string LiveEndedScriptPath { get; set; } = string.Empty;
public string LiveEndedScriptContent { get; set; } = string.Empty;
public string SegmentCompletedScriptMode { get; set; } = EventScriptSourceModes.Path;
public string SegmentCompletedScriptPath { get; set; } = string.Empty;
public string SegmentCompletedScriptContent { get; set; } = string.Empty;
public int EventScriptTimeoutSeconds { get; set; } = 60;
public bool EnableEmailNotification { get; set; } = false;
@@ -179,12 +198,24 @@ public sealed class UpdateSystemSettingsRequest
public bool EnableEventScripts { get; set; } = false;
public string LiveStartedScriptMode { get; set; } = EventScriptSourceModes.Path;
public string LiveStartedScriptPath { get; set; } = string.Empty;
public string LiveStartedScriptContent { get; set; } = string.Empty;
public string LiveEndedScriptMode { get; set; } = EventScriptSourceModes.Path;
public string LiveEndedScriptPath { get; set; } = string.Empty;
public string LiveEndedScriptContent { get; set; } = string.Empty;
public string SegmentCompletedScriptMode { get; set; } = EventScriptSourceModes.Path;
public string SegmentCompletedScriptPath { get; set; } = string.Empty;
public string SegmentCompletedScriptContent { get; set; } = string.Empty;
public int EventScriptTimeoutSeconds { get; set; } = 60;
public bool EnableEmailNotification { get; set; } = false;
@@ -33,9 +33,15 @@ public sealed class SystemSettingsService : ISystemSettingsService
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 LiveStartedScriptModeKey = "event_scripts.live_started.mode";
private const string LiveStartedScriptPathKey = "event_scripts.live_started.path";
private const string LiveStartedScriptContentKey = "event_scripts.live_started.content";
private const string LiveEndedScriptModeKey = "event_scripts.live_ended.mode";
private const string LiveEndedScriptPathKey = "event_scripts.live_ended.path";
private const string LiveEndedScriptContentKey = "event_scripts.live_ended.content";
private const string SegmentCompletedScriptModeKey = "event_scripts.segment_completed.mode";
private const string SegmentCompletedScriptPathKey = "event_scripts.segment_completed.path";
private const string SegmentCompletedScriptContentKey = "event_scripts.segment_completed.content";
private const string EventScriptTimeoutSecondsKey = "event_scripts.timeout_seconds";
private const string EnableEmailNotificationKey = "notification.email.enabled";
private const string EmailSmtpHostKey = "notification.email.smtp_host";
@@ -103,9 +109,15 @@ public sealed class SystemSettingsService : ISystemSettingsService
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,
LiveStartedScriptMode = GetEventScriptMode(lookup, LiveStartedScriptModeKey, LiveStartedScriptPathKey, LiveStartedScriptContentKey),
LiveStartedScriptPath = GetValue(lookup, LiveStartedScriptPathKey, string.Empty),
LiveStartedScriptContent = GetValue(lookup, LiveStartedScriptContentKey, string.Empty),
LiveEndedScriptMode = GetEventScriptMode(lookup, LiveEndedScriptModeKey, LiveEndedScriptPathKey, LiveEndedScriptContentKey),
LiveEndedScriptPath = GetValue(lookup, LiveEndedScriptPathKey, string.Empty),
LiveEndedScriptContent = GetValue(lookup, LiveEndedScriptContentKey, string.Empty),
SegmentCompletedScriptMode = GetEventScriptMode(lookup, SegmentCompletedScriptModeKey, SegmentCompletedScriptPathKey, SegmentCompletedScriptContentKey),
SegmentCompletedScriptPath = GetValue(lookup, SegmentCompletedScriptPathKey, string.Empty),
SegmentCompletedScriptContent = GetValue(lookup, SegmentCompletedScriptContentKey, 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),
@@ -211,9 +223,15 @@ public sealed class SystemSettingsService : ISystemSettingsService
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(LiveStartedScriptModeKey, NormalizeEventScriptMode(request.LiveStartedScriptMode), now, cancellationToken);
await UpsertAsync(LiveStartedScriptPathKey, request.LiveStartedScriptPath.Trim(), now, cancellationToken);
await UpsertAsync(LiveStartedScriptContentKey, request.LiveStartedScriptContent, now, cancellationToken);
await UpsertAsync(LiveEndedScriptModeKey, NormalizeEventScriptMode(request.LiveEndedScriptMode), now, cancellationToken);
await UpsertAsync(LiveEndedScriptPathKey, request.LiveEndedScriptPath.Trim(), now, cancellationToken);
await UpsertAsync(LiveEndedScriptContentKey, request.LiveEndedScriptContent, now, cancellationToken);
await UpsertAsync(SegmentCompletedScriptModeKey, NormalizeEventScriptMode(request.SegmentCompletedScriptMode), now, cancellationToken);
await UpsertAsync(SegmentCompletedScriptPathKey, request.SegmentCompletedScriptPath.Trim(), now, cancellationToken);
await UpsertAsync(SegmentCompletedScriptContentKey, request.SegmentCompletedScriptContent, 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);
@@ -241,6 +259,24 @@ public sealed class SystemSettingsService : ISystemSettingsService
private static string GetValue(IReadOnlyDictionary<string, string> lookup, string key, string fallback) =>
lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback;
private static string GetEventScriptMode(
IReadOnlyDictionary<string, string> lookup,
string modeKey,
string pathKey,
string contentKey)
{
if (lookup.TryGetValue(modeKey, out var configuredMode) && !string.IsNullOrWhiteSpace(configuredMode))
{
return NormalizeEventScriptMode(configuredMode);
}
var configuredPath = GetValue(lookup, pathKey, string.Empty);
var configuredContent = GetValue(lookup, contentKey, string.Empty);
return string.IsNullOrWhiteSpace(configuredContent) || !string.IsNullOrWhiteSpace(configuredPath)
? EventScriptSourceModes.Path
: EventScriptSourceModes.Inline;
}
private static int GetIntValue(
IReadOnlyDictionary<string, string> lookup,
string key,
@@ -257,6 +293,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
return Math.Clamp(parsedValue, minimum, maximum);
}
private static string NormalizeEventScriptMode(string? value) =>
string.Equals(value?.Trim(), EventScriptSourceModes.Inline, StringComparison.OrdinalIgnoreCase)
? EventScriptSourceModes.Inline
: EventScriptSourceModes.Path;
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
{
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);
@@ -2,6 +2,7 @@ using System.Diagnostics;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Logging;
@@ -31,7 +32,9 @@ public sealed class EventScriptService : IEventScriptService
environment["LIVE_RECORDER_EVENT"] = "live_started";
await RunAsync(
settings.EnableEventScripts,
settings.LiveStartedScriptMode,
settings.LiveStartedScriptPath,
settings.LiveStartedScriptContent,
settings.EventScriptTimeoutSeconds,
"live_started",
environment,
@@ -48,7 +51,9 @@ public sealed class EventScriptService : IEventScriptService
environment["LIVE_RECORDER_EVENT"] = "live_ended";
await RunAsync(
settings.EnableEventScripts,
settings.LiveEndedScriptMode,
settings.LiveEndedScriptPath,
settings.LiveEndedScriptContent,
settings.EventScriptTimeoutSeconds,
"live_ended",
environment,
@@ -82,7 +87,9 @@ public sealed class EventScriptService : IEventScriptService
await RunAsync(
settings.EnableEventScripts,
settings.SegmentCompletedScriptMode,
settings.SegmentCompletedScriptPath,
settings.SegmentCompletedScriptContent,
settings.EventScriptTimeoutSeconds,
"segment_completed",
environment,
@@ -94,7 +101,9 @@ public sealed class EventScriptService : IEventScriptService
private async Task RunAsync(
bool enabled,
string scriptMode,
string scriptPath,
string scriptContent,
int timeoutSeconds,
string eventName,
IReadOnlyDictionary<string, string> environment,
@@ -103,19 +112,24 @@ public sealed class EventScriptService : IEventScriptService
Guid? recordTaskId,
CancellationToken cancellationToken)
{
if (!enabled || string.IsNullOrWhiteSpace(scriptPath))
if (!enabled)
{
return;
}
var normalizedScriptPath = NormalizePath(scriptPath);
if (!File.Exists(normalizedScriptPath))
var execution = CreateExecution(scriptMode, scriptPath, scriptContent);
if (execution is null)
{
return;
}
if (execution.IsMissing)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"Script",
$"Event script was not found for {eventName}.",
normalizedScriptPath,
execution.Detail,
liveRoomId,
recordSessionId,
recordTaskId,
@@ -123,7 +137,7 @@ public sealed class EventScriptService : IEventScriptService
return;
}
var startInfo = CreateStartInfo(normalizedScriptPath);
var startInfo = execution.StartInfo!;
foreach (var pair in environment)
{
startInfo.Environment[pair.Key] = pair.Value;
@@ -148,7 +162,7 @@ public sealed class EventScriptService : IEventScriptService
process.ExitCode == 0
? $"Event script completed for {eventName}."
: $"Event script exited with code {process.ExitCode} for {eventName}.",
normalizedScriptPath,
execution.Detail,
liveRoomId,
recordSessionId,
recordTaskId,
@@ -161,7 +175,7 @@ public sealed class EventScriptService : IEventScriptService
SystemLogLevel.Warning,
"Script",
$"Event script timed out for {eventName}.",
normalizedScriptPath,
execution.Detail,
liveRoomId,
recordSessionId,
recordTaskId,
@@ -182,6 +196,32 @@ public sealed class EventScriptService : IEventScriptService
}
}
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)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -239,6 +279,34 @@ public sealed class EventScriptService : IEventScriptService
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))
@@ -275,4 +343,6 @@ public sealed class EventScriptService : IEventScriptService
{
}
}
private sealed record EventScriptExecution(ProcessStartInfo? StartInfo, string Detail, bool IsMissing = false);
}