diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 61a3b4e..74d8f25 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -216,9 +216,15 @@ export interface SystemSettings { autoStartRecordingOnLive: boolean; pollingIntervalSeconds: number; enableEventScripts: boolean; + liveStartedScriptMode: string; liveStartedScriptPath: string; + liveStartedScriptContent: string; + liveEndedScriptMode: string; liveEndedScriptPath: string; + liveEndedScriptContent: string; + segmentCompletedScriptMode: string; segmentCompletedScriptPath: string; + segmentCompletedScriptContent: string; eventScriptTimeoutSeconds: number; enableEmailNotification: boolean; emailSmtpHost: string; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index e9e9aca..a61149d 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -36,9 +36,15 @@ const form = reactive({ autoStartRecordingOnLive: true, pollingIntervalSeconds: 60, enableEventScripts: false, + liveStartedScriptMode: "path", liveStartedScriptPath: "", + liveStartedScriptContent: "", + liveEndedScriptMode: "path", liveEndedScriptPath: "", + liveEndedScriptContent: "", + segmentCompletedScriptMode: "path", segmentCompletedScriptPath: "", + segmentCompletedScriptContent: "", eventScriptTimeoutSeconds: 60, enableEmailNotification: false, emailSmtpHost: "", @@ -135,6 +141,11 @@ const eventScriptEnvironmentExamples = [ { name: "LIVE_RECORDER_SESSION_STATUS", example: "Running", scope: "仅分片完成" } ]; +const eventScriptModeOptions = [ + { label: "路径", value: "path" }, + { label: "脚本文本", value: "inline" } +]; + const canSendTestEmail = computed(() => Boolean(form.emailSmtpHost.trim() && form.emailFromAddress.trim() && form.emailToAddresses.trim()) ); @@ -482,19 +493,91 @@ onMounted(loadSettings); - - - +
+
+

开播

+ + + {{ option.label }} + + +
+ + + + + + +
- - - +
+
+

下播

+ + + {{ option.label }} + + +
+ + + + + + +
- - - +
+
+

分片完成

+ + + {{ option.label }} + + +
+ + + + + + +
@@ -504,6 +587,10 @@ onMounted(loadSettings); 开播/下播脚本会带“全部事件”里的变量;分片完成脚本会额外带分片路径、弹幕路径、时长、文件大小、任务状态这些值。取不到时会传空字符串。 +
+ 脚本文本模式在 Linux 或 Docker 中通过 /bin/sh -c 执行,在 Windows 中通过 PowerShell -Command 执行。 +
+
环境变量示例值
@@ -734,6 +821,28 @@ onMounted(loadSettings); margin-bottom: 12px; } +.event-script-section { + padding: 16px; + border-radius: 10px; + border: 1px solid rgba(91, 110, 129, 0.12); + background: rgba(255, 255, 255, 0.72); +} + +.event-script-section__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.event-script-section__title { + margin: 0; + font-size: 14px; + font-weight: 700; + color: #1e2937; +} + .event-script-example { display: grid; gap: 10px; @@ -860,6 +969,11 @@ onMounted(loadSettings); align-items: flex-start; } + .event-script-section__header { + flex-direction: column; + align-items: flex-start; + } + .event-script-example__row { grid-template-columns: 1fr; gap: 4px; diff --git a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs index 158f18f..9604396 100644 --- a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs +++ b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs @@ -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; diff --git a/src/LiveRecorder.Application/Services/SystemSettingsService.cs b/src/LiveRecorder.Application/Services/SystemSettingsService.cs index e96d039..35cbd15 100644 --- a/src/LiveRecorder.Application/Services/SystemSettingsService.cs +++ b/src/LiveRecorder.Application/Services/SystemSettingsService.cs @@ -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 lookup, string key, string fallback) => lookup.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback; + private static string GetEventScriptMode( + IReadOnlyDictionary 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 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); diff --git a/src/LiveRecorder.Infrastructure/Services/EventScriptService.cs b/src/LiveRecorder.Infrastructure/Services/EventScriptService.cs index acee9af..8edcd01 100644 --- a/src/LiveRecorder.Infrastructure/Services/EventScriptService.cs +++ b/src/LiveRecorder.Infrastructure/Services/EventScriptService.cs @@ -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 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 BuildLiveRoomEnvironment(LiveRoom? liveRoom, DateTimeOffset occurredAt) { return new Dictionary(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); }