From 9701c88c8b9e07f620c531ee524710054187da7a Mon Sep 17 00:00:00 2001 From: nanxun Date: Sat, 25 Apr 2026 00:06:25 +0800 Subject: [PATCH] fix: improve recovery and auto-start flows --- frontend/src/views/SettingsView.vue | 100 +++++++++++++++++- .../Services/LiveRoomService.cs | 56 ++++++++++ .../Services/FfmpegService.Utils.cs | 29 +++-- .../Services/FfmpegService.cs | 29 ++++- .../LiveRoomPollingBackgroundService.cs | 7 ++ 5 files changed, 207 insertions(+), 14 deletions(-) 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(