diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index acdbb31..e9e9aca 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -115,6 +115,26 @@ const emailTemplateTokens = [
"{{occurredAtUtc}}"
];
+const eventScriptEnvironmentExamples = [
+ { name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "全部事件" },
+ { name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "全部事件" },
+ { name: "LIVE_RECORDER_LIVE_ROOM_ID", example: "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901", scope: "全部事件" },
+ { name: "LIVE_RECORDER_ROOM_ID", example: "676493068539", scope: "全部事件" },
+ { name: "LIVE_RECORDER_TITLE", example: "今晚划水聊天", scope: "全部事件" },
+ { name: "LIVE_RECORDER_ANCHOR", example: "布莱克", scope: "全部事件" },
+ { name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "全部事件" },
+ { name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T12:34:56.7890000+00:00", scope: "全部事件" },
+ { name: "LIVE_RECORDER_RECORD_SESSION_ID", example: "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77", scope: "仅分片完成" },
+ { name: "LIVE_RECORDER_RECORD_TASK_ID", example: "2a4810a2-7ef4-4a22-90d4-0211b90cc54c", scope: "仅分片完成" },
+ { name: "LIVE_RECORDER_SEGMENT_INDEX", example: "1", scope: "仅分片完成" },
+ { name: "LIVE_RECORDER_SEGMENT_FILE_PATH", example: "/app/records/Douyin/布莱克/2026-04-25/203000_今晚划水聊天__00001.mp4", scope: "仅分片完成" },
+ { name: "LIVE_RECORDER_DANMAKU_FILE_PATH", example: "/app/records/Douyin/布莱克/2026-04-25/203000_今晚划水聊天__00001.xml", scope: "仅分片完成" },
+ { name: "LIVE_RECORDER_DURATION_SECONDS", example: "2185.1", scope: "仅分片完成" },
+ { name: "LIVE_RECORDER_FILE_SIZE_BYTES", example: "734003200", scope: "仅分片完成" },
+ { name: "LIVE_RECORDER_TASK_STATUS", example: "Completed", scope: "仅分片完成" },
+ { name: "LIVE_RECORDER_SESSION_STATUS", example: "Running", scope: "仅分片完成" }
+];
+
const canSendTestEmail = computed(() =>
Boolean(form.emailSmtpHost.trim() && form.emailFromAddress.trim() && form.emailToAddresses.trim())
);
@@ -480,7 +500,25 @@ onMounted(loadSettings);
- 常用环境变量:LIVE_RECORDER_EVENT、LIVE_RECORDER_PLATFORM、LIVE_RECORDER_ROOM_ID、LIVE_RECORDER_ANCHOR、LIVE_RECORDER_RECORD_SESSION_ID、LIVE_RECORDER_RECORD_TASK_ID、LIVE_RECORDER_SEGMENT_INDEX、LIVE_RECORDER_SEGMENT_FILE_PATH、LIVE_RECORDER_DANMAKU_FILE_PATH。
+
+ 开播/下播脚本会带“全部事件”里的变量;分片完成脚本会额外带分片路径、弹幕路径、时长、文件大小、任务状态这些值。取不到时会传空字符串。
+
+
+
+
环境变量示例值
+
+
+ {{ item.name }}
+ {{ item.example }}
+ {{ item.scope }}
+
+
+
+
+
+
Bash: echo "$LIVE_RECORDER_EVENT $LIVE_RECORDER_SEGMENT_FILE_PATH"
+
PowerShell: Write-Host "$env:LIVE_RECORDER_EVENT $env:LIVE_RECORDER_SEGMENT_FILE_PATH"
+
@@ -692,6 +730,57 @@ onMounted(loadSettings);
line-height: 1.7;
}
+.event-script-help__intro {
+ margin-bottom: 12px;
+}
+
+.event-script-example {
+ display: grid;
+ gap: 10px;
+ margin-top: 12px;
+ padding: 14px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.72);
+ border: 1px solid rgba(91, 110, 129, 0.12);
+}
+
+.event-script-example__label {
+ font-size: 12px;
+ font-weight: 700;
+ color: #687381;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.event-script-example__grid {
+ display: grid;
+ gap: 8px;
+}
+
+.event-script-example__row {
+ display: grid;
+ grid-template-columns: minmax(220px, 1.2fr) minmax(0, 1.8fr) auto;
+ gap: 12px;
+ align-items: start;
+ font-size: 12px;
+}
+
+.event-script-example__value {
+ overflow-wrap: anywhere;
+ color: #44505c;
+}
+
+.event-script-example__scope {
+ color: #687381;
+ white-space: nowrap;
+}
+
+.event-script-help__usage {
+ display: grid;
+ gap: 6px;
+ margin-top: 12px;
+}
+
.template-section {
padding: 18px 18px 4px;
border-radius: 12px;
@@ -770,6 +859,15 @@ onMounted(loadSettings);
flex-direction: column;
align-items: flex-start;
}
+
+ .event-script-example__row {
+ grid-template-columns: 1fr;
+ gap: 4px;
+ }
+
+ .event-script-example__scope {
+ white-space: normal;
+ }
}
@media (max-width: 768px) {
diff --git a/src/LiveRecorder.Application/Services/LiveRoomService.cs b/src/LiveRecorder.Application/Services/LiveRoomService.cs
index 84b6d68..817289e 100644
--- a/src/LiveRecorder.Application/Services/LiveRoomService.cs
+++ b/src/LiveRecorder.Application/Services/LiveRoomService.cs
@@ -4,6 +4,7 @@ using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.LiveRooms;
+using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
@@ -17,6 +18,7 @@ public sealed class LiveRoomService
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
private readonly ISystemSettingsService _systemSettingsService;
private readonly StoppedOrphanRecordSessionCleanupService _stoppedOrphanRecordSessionCleanupService;
+ private readonly RecordService _recordService;
private readonly IUnitOfWork _unitOfWork;
private readonly ISystemLogService _systemLogService;
@@ -27,6 +29,7 @@ public sealed class LiveRoomService
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
ISystemSettingsService systemSettingsService,
StoppedOrphanRecordSessionCleanupService stoppedOrphanRecordSessionCleanupService,
+ RecordService recordService,
IUnitOfWork unitOfWork,
ISystemLogService systemLogService)
{
@@ -36,6 +39,7 @@ public sealed class LiveRoomService
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
_systemSettingsService = systemSettingsService;
_stoppedOrphanRecordSessionCleanupService = stoppedOrphanRecordSessionCleanupService;
+ _recordService = recordService;
_unitOfWork = unitOfWork;
_systemLogService = systemLogService;
}
@@ -214,6 +218,7 @@ public sealed class LiveRoomService
$"Live room resolved: {liveRoom.RoomId} ({liveRoom.Platform}).",
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
+ await TryAutoStartRecordingAsync(liveRoom, liveStatus, cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(liveRoom, cancellationToken);
return (liveRoom, effectiveSettings, created);
@@ -238,6 +243,7 @@ public sealed class LiveRoomService
detail: $"status={liveStatus.StatusCode}, rawStatus={liveStatus.RawStatus}",
liveRoomId: room.Id,
cancellationToken: cancellationToken);
+ await TryAutoStartRecordingAsync(room, liveStatus, cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
@@ -405,6 +411,56 @@ public sealed class LiveRoomService
return rooms.ToDictionary(item => item.Id, item => _liveRoomRecordingSettingsResolver.Resolve(item, systemSettings));
}
+ private async Task TryAutoStartRecordingAsync(
+ LiveRoom liveRoom,
+ LiveStatusSnapshot liveStatus,
+ CancellationToken cancellationToken)
+ {
+ if (!liveStatus.IsLive || !liveRoom.IsEnabled)
+ {
+ return;
+ }
+
+ var settings = await _systemSettingsService.GetAsync(cancellationToken);
+ if (!settings.AutoStartRecordingOnLive)
+ {
+ await _systemLogService.WriteAsync(
+ SystemLogLevel.Info,
+ "LiveRoom",
+ "Live room is already online, but immediate auto-start is disabled by settings.",
+ liveRoomId: liveRoom.Id,
+ cancellationToken: cancellationToken);
+ return;
+ }
+
+ await _systemLogService.WriteAsync(
+ SystemLogLevel.Info,
+ "LiveRoom",
+ "Live room is already online. Attempting immediate auto-start after resolve/refresh.",
+ liveRoomId: liveRoom.Id,
+ cancellationToken: cancellationToken);
+
+ try
+ {
+ await _recordService.StartAsync(
+ new StartRecordTaskRequest
+ {
+ LiveRoomId = liveRoom.Id
+ },
+ cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ await _systemLogService.WriteAsync(
+ ex is InvalidOperationException ? SystemLogLevel.Warning : SystemLogLevel.Error,
+ "LiveRoom",
+ "Immediate auto-start after resolve/refresh did not start a recording session.",
+ ex.Message,
+ liveRoomId: liveRoom.Id,
+ cancellationToken: cancellationToken);
+ }
+ }
+
private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings) => new()
{
Id = room.Id,
diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs
index 316d738..cfeff4b 100644
--- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs
+++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs
@@ -80,7 +80,9 @@ public sealed partial class FfmpegService
var stderrLines = new Queue();
var progressSync = new object();
double? processedSeconds = null;
- var lastReportedWholePercent = -1;
+ double? lastReportedProgressPercent = null;
+ string? lastReportedStage = null;
+ string? lastReportedDetail = null;
var attemptStartedAt = DateTimeOffset.UtcNow;
var activeStage = "Finalizing MP4";
var maxDuration = TimeSpan.FromMinutes(Math.Clamp(mp4FinalizeTimeoutMinutes, 1, 1440));
@@ -114,19 +116,28 @@ public sealed partial class FfmpegService
detail = $"Processed {processedSeconds.Value:F1}s";
}
- var wholePercent = progressPercent.HasValue ? (int)Math.Floor(progressPercent.Value) : -1;
- if (stageOverride is null && wholePercent == lastReportedWholePercent)
+ var effectiveStage = stageOverride ?? activeStage;
+ var normalizedProgressPercent = progressPercent.HasValue
+ ? Math.Round(progressPercent.Value, 1, MidpointRounding.AwayFromZero)
+ : (double?)null;
+ var effectiveDetail = detail ?? $"Optimizing MP4 index for {Path.GetFileName(targetPath)}";
+
+ if (string.Equals(effectiveStage, lastReportedStage, StringComparison.Ordinal) &&
+ string.Equals(effectiveDetail, lastReportedDetail, StringComparison.Ordinal) &&
+ Nullable.Equals(normalizedProgressPercent, lastReportedProgressPercent))
{
return;
}
- lastReportedWholePercent = wholePercent;
+ lastReportedStage = effectiveStage;
+ lastReportedDetail = effectiveDetail;
+ lastReportedProgressPercent = normalizedProgressPercent;
SetPostProcessState(
recordSessionId,
recordTaskId,
- stageOverride ?? activeStage,
- progressPercent,
- detail ?? $"Optimizing MP4 index for {Path.GetFileName(targetPath)}");
+ effectiveStage,
+ normalizedProgressPercent,
+ effectiveDetail);
}
}
@@ -159,7 +170,9 @@ public sealed partial class FfmpegService
ClearErrorDetail();
activeStage = stage;
processedSeconds = null;
- lastReportedWholePercent = -1;
+ lastReportedProgressPercent = null;
+ lastReportedStage = null;
+ lastReportedDetail = null;
attemptStartedAt = DateTimeOffset.UtcNow;
System.Threading.Interlocked.Exchange(ref lastActivityTicks, attemptStartedAt.UtcTicks);
diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs
index 8ed2c2c..7bfd67c 100644
--- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs
+++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs
@@ -242,7 +242,7 @@ public sealed partial class FfmpegService : IFfmpegService
public async Task TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
{
- if (IsRunning(recordSessionId) || IsSessionUnderPostProcessing(recordSessionId))
+ if (IsRunning(recordSessionId))
{
return false;
}
@@ -269,11 +269,23 @@ public sealed partial class FfmpegService : IFfmpegService
.ThenBy(static item => item.CreatedAt)
.ToList();
- var anyUsableOutput = false;
+ var anyUsableOutput = tasks.Any(static item => item.Status == RecordTaskStatus.Completed);
+ var hasBackgroundPostProcessing = false;
string? sessionFinalizationError = null;
- foreach (var task in tasks.Where(item => IsActiveTaskStatus(item.Status)))
+ foreach (var task in tasks)
{
+ if (IsTaskUnderPostProcessing(task.Id))
+ {
+ hasBackgroundPostProcessing = true;
+ continue;
+ }
+
+ if (!IsActiveTaskStatus(task.Status))
+ {
+ continue;
+ }
+
var durationSeconds = task.StartedAt.HasValue
? (double?)Math.Max(0, (endedAt - task.StartedAt.Value).TotalSeconds)
: null;
@@ -299,6 +311,7 @@ public sealed partial class FfmpegService : IFfmpegService
if (IsLowStoragePauseError(taskFinalizationError))
{
task.MarkProcessing(taskFinalizationError, endedAt);
+ hasBackgroundPostProcessing = true;
}
else if (!string.IsNullOrWhiteSpace(taskFinalizationError))
{
@@ -323,6 +336,12 @@ public sealed partial class FfmpegService : IFfmpegService
{
recordSession.MarkFailed(sessionFinalizationError, endedAt);
}
+ else if (hasBackgroundPostProcessing)
+ {
+ recordSession.MarkStopped(
+ endedAt,
+ "Recording process was no longer running when the session was reconciled. Post-processing will continue in background.");
+ }
else if (anyUsableOutput)
{
recordSession.MarkCompleted(endedAt);
@@ -594,8 +613,8 @@ public sealed partial class FfmpegService : IFfmpegService
private void ClearPostProcessState(Guid recordTaskId) =>
_postProcessStates.TryRemove(recordTaskId, out _);
- private bool IsSessionUnderPostProcessing(Guid recordSessionId) =>
- _postProcessStates.Values.Any(entry => entry.RecordSessionId == recordSessionId);
+ private bool IsTaskUnderPostProcessing(Guid recordTaskId) =>
+ _postProcessStates.ContainsKey(recordTaskId);
private async Task AcquireTranscodeSlotAsync(int maxConcurrentTasks, CancellationToken cancellationToken)
{
diff --git a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs
index f6e3f1d..c97ef47 100644
--- a/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs
+++ b/src/LiveRecorder.Infrastructure/Services/LiveRoomPollingBackgroundService.cs
@@ -93,7 +93,14 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
try
{
using var notificationScope = _serviceScopeFactory.CreateScope();
+ var logService = notificationScope.ServiceProvider.GetRequiredService();
var emailNotificationService = notificationScope.ServiceProvider.GetRequiredService();
+ await logService.WriteAsync(
+ SystemLogLevel.Error,
+ "Scheduler",
+ "Background live room polling failed.",
+ ex.ToString(),
+ cancellationToken: CancellationToken.None);
if (ShouldSendExceptionEmail(BuildExceptionEmailKey("background-loop", ex)))
{
await emailNotificationService.SendExceptionAsync(