This commit is contained in:
2026-04-16 15:19:48 +08:00
commit 1c892259a9
127 changed files with 17390 additions and 0 deletions
@@ -0,0 +1,265 @@
using System.Net;
using System.Net.Mail;
using System.Text;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class EmailNotificationService : IEmailNotificationService
{
private const string AppName = "LiveRecorder";
private static readonly Regex TemplateTokenRegex = new("""\{\{\s*(?<name>[a-zA-Z0-9_]+)\s*\}\}""", RegexOptions.Compiled);
private readonly ISystemSettingsService _systemSettingsService;
private readonly ILogger<EmailNotificationService> _logger;
public EmailNotificationService(
ISystemSettingsService systemSettingsService,
ILogger<EmailNotificationService> logger)
{
_systemSettingsService = systemSettingsService;
_logger = logger;
}
public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableEmailNotification || !settings.NotifyOnLiveStarted)
{
return;
}
var tokens = CreateTokenMap(new Dictionary<string, string?>
{
["platform"] = liveRoom.Platform.ToString(),
["roomId"] = liveRoom.RoomId,
["title"] = liveRoom.Title,
["anchor"] = liveRoom.AnchorName,
["sourceUrl"] = liveRoom.SourceUrl,
["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
});
var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens);
var body = RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, tokens);
await SendAsync(settings, subject, body, cancellationToken);
}
public async Task SendExceptionAsync(
string source,
string summary,
string? detail = null,
LiveRoom? liveRoom = null,
RecordTask? recordTask = null,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableEmailNotification || !settings.NotifyOnException)
{
return;
}
var tokens = CreateTokenMap(new Dictionary<string, string?>
{
["source"] = source,
["summary"] = summary,
["detail"] = detail,
["liveRoomId"] = liveRoom?.Id.ToString(),
["roomId"] = liveRoom?.RoomId,
["recordTaskId"] = recordTask?.Id.ToString(),
["taskStatus"] = recordTask?.Status.ToString(),
["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
});
var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens);
var body = RenderHtml(settings.EmailExceptionBodyTemplateHtml, tokens);
await SendAsync(settings, subject, body, cancellationToken);
}
public async Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var settings = new SystemSettingsDto
{
EnableEmailNotification = true,
NotifyOnLiveStarted = true,
NotifyOnException = true,
EmailSmtpHost = request.EmailSmtpHost.Trim(),
EmailSmtpPort = request.EmailSmtpPort,
EmailUseSsl = request.EmailUseSsl,
EmailUsername = request.EmailUsername.Trim(),
EmailPassword = request.EmailPassword,
EmailFromAddress = request.EmailFromAddress.Trim(),
EmailFromDisplayName = request.EmailFromDisplayName.Trim(),
EmailToAddresses = request.EmailToAddresses.Trim(),
EmailLiveStartedSubjectTemplate = request.EmailLiveStartedSubjectTemplate,
EmailLiveStartedBodyTemplateHtml = request.EmailLiveStartedBodyTemplateHtml,
EmailExceptionSubjectTemplate = request.EmailExceptionSubjectTemplate,
EmailExceptionBodyTemplateHtml = request.EmailExceptionBodyTemplateHtml
};
var sampleLiveTokens = CreateTokenMap(new Dictionary<string, string?>
{
["platform"] = "Douyin",
["roomId"] = "123456789",
["title"] = "Sample Live Title",
["anchor"] = "Sample Anchor",
["sourceUrl"] = "https://live.douyin.com/123456789",
["detectedAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
});
var sampleExceptionTokens = CreateTokenMap(new Dictionary<string, string?>
{
["source"] = "Scheduler",
["summary"] = "Background polling failed for a live room.",
["detail"] = "Sample stack trace or diagnostic detail goes here.",
["liveRoomId"] = Guid.NewGuid().ToString(),
["roomId"] = "123456789",
["recordTaskId"] = Guid.NewGuid().ToString(),
["taskStatus"] = "Running",
["occurredAtUtc"] = DateTimeOffset.UtcNow.ToString("O")
});
var body = $$"""
<!DOCTYPE html>
<html lang="en">
<body style="margin: 0; padding: 24px; background: #f5f7fb; font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937;">
<div style="max-width: 880px; margin: 0 auto;">
<h1 style="margin: 0 0 20px; color: #2f4c66;">LiveRecorder SMTP template test</h1>
<div style="margin-bottom: 18px; padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;">
<h2 style="margin: 0 0 14px; color: #3e5f7c;"></h2>
<div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailLiveStartedSubjectTemplate, sampleLiveTokens))}}</div>
{{RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, sampleLiveTokens)}}
</div>
<div style="padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;">
<h2 style="margin: 0 0 14px; color: #8b5e3c;"></h2>
<div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailExceptionSubjectTemplate, sampleExceptionTokens))}}</div>
{{RenderHtml(settings.EmailExceptionBodyTemplateHtml, sampleExceptionTokens)}}
</div>
</div>
</body>
</html>
""";
await SendAsync(settings, "[LiveRecorder] SMTP template test", body, cancellationToken, swallowErrors: false);
}
private async Task SendAsync(
SystemSettingsDto settings,
string subject,
string body,
CancellationToken cancellationToken,
bool swallowErrors = true)
{
var recipients = ParseRecipients(settings.EmailToAddresses);
if (string.IsNullOrWhiteSpace(settings.EmailSmtpHost) ||
string.IsNullOrWhiteSpace(settings.EmailFromAddress) ||
recipients.Count == 0)
{
if (swallowErrors)
{
return;
}
throw new InvalidOperationException("SMTP host, sender address or recipient list is missing.");
}
try
{
using var message = new MailMessage
{
From = new MailAddress(settings.EmailFromAddress, settings.EmailFromDisplayName),
Subject = subject,
Body = body,
BodyEncoding = Encoding.UTF8,
SubjectEncoding = Encoding.UTF8,
IsBodyHtml = true
};
foreach (var recipient in recipients)
{
message.To.Add(recipient);
}
using var client = new SmtpClient(settings.EmailSmtpHost, settings.EmailSmtpPort)
{
EnableSsl = settings.EmailUseSsl,
DeliveryMethod = SmtpDeliveryMethod.Network
};
if (!string.IsNullOrWhiteSpace(settings.EmailUsername))
{
client.Credentials = new NetworkCredential(settings.EmailUsername, settings.EmailPassword);
}
using var registration = cancellationToken.Register(client.SendAsyncCancel);
await client.SendMailAsync(message);
}
catch (Exception ex)
{
if (!swallowErrors)
{
throw new InvalidOperationException($"SMTP test email send failed: {ex.Message}", ex);
}
_logger.LogWarning(ex, "Email notification send failed");
}
}
private static List<string> ParseRecipients(string rawAddresses) =>
rawAddresses
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static Dictionary<string, string> CreateTokenMap(IReadOnlyDictionary<string, string?> rawTokens)
{
var tokens = rawTokens.ToDictionary(
static pair => pair.Key,
static pair => pair.Value?.Trim() ?? string.Empty,
StringComparer.OrdinalIgnoreCase);
tokens["appName"] = AppName;
return tokens;
}
private static string RenderSubject(string template, IReadOnlyDictionary<string, string> tokens)
{
var rendered = RenderTemplate(template, tokens, htmlEncodeValues: false);
rendered = rendered.Replace("\r", " ").Replace("\n", " ").Trim();
return string.IsNullOrWhiteSpace(rendered) ? $"[{AppName}] Notification" : rendered;
}
private static string RenderHtml(string template, IReadOnlyDictionary<string, string> tokens)
{
var rendered = RenderTemplate(template, tokens, htmlEncodeValues: true).Trim();
return string.IsNullOrWhiteSpace(rendered) ? "<div></div>" : rendered;
}
private static string RenderTemplate(string template, IReadOnlyDictionary<string, string> tokens, bool htmlEncodeValues)
{
if (string.IsNullOrWhiteSpace(template))
{
return string.Empty;
}
return TemplateTokenRegex.Replace(
template,
match =>
{
var tokenName = match.Groups["name"].Value;
if (!tokens.TryGetValue(tokenName, out var value))
{
return string.Empty;
}
return htmlEncodeValues ? WebUtility.HtmlEncode(value) : value;
});
}
}
@@ -0,0 +1,767 @@
using System.Diagnostics;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService
{
private async Task HandleProcessOutputAsync(SessionProcessRuntime runtime, string? line, bool isError)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
_logger.LogDebug("ffmpeg[{SessionId}] {Line}", runtime.RecordSessionId, line);
if (TryParseSegmentOpenPath(line, out var openedPath))
{
await HandleSegmentOpenedAsync(runtime, openedPath);
return;
}
if (!runtime.HasOpenedFirstSegment && IsOptionCompatibilityFailureLine(line))
{
runtime.MarkStartupFailure(StartupFailureKind.InputOptionCompatibility, line);
}
else if (!runtime.HasOpenedFirstSegment && IsRetryableStartupFailureLine(line))
{
runtime.MarkStartupFailure(StartupFailureKind.StreamHandshake, line);
}
if (line.Contains("error", StringComparison.OrdinalIgnoreCase) ||
line.Contains("fail", StringComparison.OrdinalIgnoreCase) ||
line.Contains("timed out", StringComparison.OrdinalIgnoreCase))
{
await PersistFfmpegLineAsync(runtime, line, isError ? SystemLogLevel.Error : SystemLogLevel.Warning);
}
}
private async Task PersistFfmpegLineAsync(SessionProcessRuntime runtime, string line, SystemLogLevel level)
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
level,
"FFmpeg",
"ffmpeg reported a warning or error line.",
line,
runtime.LiveRoomId,
runtime.RecordSessionId,
runtime.CurrentTaskId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Persist ffmpeg output failed for session {RecordSessionId}", runtime.RecordSessionId);
}
}
private async Task HandleSegmentOpenedAsync(SessionProcessRuntime runtime, string openedPath)
{
await runtime.Gate.WaitAsync();
try
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var session = await dbContext.RecordSessions
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId);
if (session is null)
{
return;
}
var now = DateTimeOffset.UtcNow;
var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId);
if (currentTask is null)
{
return;
}
if (runtime.SaveMode == RecordSaveMode.SingleFile)
{
runtime.HasOpenedFirstSegment = true;
currentTask.AttachProcess(runtime.ProcessId, now);
currentTask.MarkRunning(now);
session.AttachProcess(runtime.ProcessId, now);
session.MarkRunning(now);
session.ActivateSegment(1, now);
if (!runtime.HasInitializedDanmaku)
{
await EnsureDanmakuSegmentAsync(runtime, currentTask, now);
runtime.HasInitializedDanmaku = true;
}
await dbContext.SaveChangesAsync();
return;
}
var segmentIndex = string.Equals(openedPath, runtime.CurrentOutputFilePath, StringComparison.OrdinalIgnoreCase)
? Math.Max(1, runtime.CurrentSegmentIndex)
: Math.Max(1, runtime.CurrentSegmentIndex + 1);
if (!runtime.HasInitializedDanmaku)
{
await EnsureDanmakuSegmentAsync(runtime, currentTask, now);
runtime.HasInitializedDanmaku = true;
}
if (segmentIndex == runtime.CurrentSegmentIndex)
{
runtime.HasOpenedFirstSegment = true;
currentTask.MarkStarting(runtime.StreamUrl, openedPath, currentTask.StartedAt ?? now);
currentTask.AttachProcess(runtime.ProcessId, now);
currentTask.MarkRunning(now);
session.AttachProcess(runtime.ProcessId, now);
session.MarkRunning(now);
session.ActivateSegment(segmentIndex, now);
runtime.CurrentOutputFilePath = openedPath;
await dbContext.SaveChangesAsync();
return;
}
var previousTask = currentTask;
var previousTaskId = previousTask.Id;
var previousOutputPath = runtime.CurrentOutputFilePath;
var newTask = new RecordTask(
session.LiveRoomId,
session.Id,
segmentIndex,
session.PreferredQuality,
session.OutputFormat,
now);
newTask.MarkStarting(runtime.StreamUrl, openedPath, now);
newTask.AttachProcess(runtime.ProcessId, now);
newTask.MarkRunning(now);
await dbContext.RecordTasks.AddAsync(newTask);
previousTask.MarkCompleted(
now,
previousTask.StartedAt.HasValue ? Math.Max(0, (now - previousTask.StartedAt.Value).TotalSeconds) : null);
previousTask.DetachProcess(now);
session.AttachProcess(runtime.ProcessId, now);
session.MarkRunning(now);
session.ActivateSegment(segmentIndex, now);
await dbContext.SaveChangesAsync();
if (runtime.DanmakuRecorder is not null)
{
await runtime.DanmakuRecorder.StartSegmentAsync(newTask.Id, segmentIndex, openedPath, now);
}
var previousDanmakuSummary = runtime.DanmakuRecorder?.TakeSummary(previousTaskId);
UpsertRecordResult(
previousTask,
dbContext,
previousOutputPath,
CalculateFileSize(previousOutputPath),
previousTask.DurationSeconds,
previousDanmakuSummary?.FilePath,
previousDanmakuSummary?.MessageCount ?? 0,
now);
await dbContext.SaveChangesAsync();
runtime.CurrentTaskId = newTask.Id;
runtime.CurrentSegmentIndex = segmentIndex;
runtime.CurrentOutputFilePath = openedPath;
runtime.HasOpenedFirstSegment = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Handle segment open failed for session {RecordSessionId}", runtime.RecordSessionId);
}
finally
{
runtime.Gate.Release();
}
}
private async Task HandleProcessExitedAsync(SessionProcessRuntime runtime, Process process)
{
_processes.TryRemove(runtime.RecordSessionId, out _);
try
{
runtime.DanmakuCancellation.Cancel();
if (runtime.DanmakuPumpTask is not null)
{
try
{
await runtime.DanmakuPumpTask;
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Danmaku pump ended with error for session {RecordSessionId}", runtime.RecordSessionId);
}
}
SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary = null;
if (runtime.DanmakuRecorder is not null)
{
activeDanmakuSummary = await runtime.DanmakuRecorder.CompleteActiveSegmentAsync();
await runtime.DanmakuRecorder.DisposeAsync();
}
if (runtime.DanmakuConnection is not null)
{
await runtime.DanmakuConnection.DisposeAsync();
}
if (await TryRecoverStartupFailureAsync(runtime))
{
return;
}
await FinalizeExitedSessionAsync(runtime, process, activeDanmakuSummary);
}
catch (Exception ex)
{
_logger.LogError(ex, "Handle ffmpeg exit failed for session {RecordSessionId}", runtime.RecordSessionId);
}
finally
{
runtime.ExitCompletion.TrySetResult(true);
runtime.Dispose();
process.Dispose();
}
}
private async Task<bool> TryRecoverStartupFailureAsync(SessionProcessRuntime runtime)
{
if (runtime.HasOpenedFirstSegment ||
runtime.StartupFailureKind == StartupFailureKind.None ||
string.IsNullOrWhiteSpace(runtime.LastStartupFailureLine))
{
return false;
}
try
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var session = await dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId);
if (session?.LiveRoom is null || !IsActiveSessionStatus(session.Status))
{
return false;
}
var currentTask = session.RecordTasks
.OrderBy(item => item.SegmentIndex)
.ThenBy(item => item.CreatedAt)
.FirstOrDefault(item => item.Id == runtime.CurrentTaskId);
if (currentTask is null || !IsActiveTaskStatus(currentTask.Status))
{
return false;
}
var observedAt = DateTimeOffset.UtcNow;
if (runtime.StartupFailureKind == StartupFailureKind.InputOptionCompatibility)
{
if (runtime.HasRetriedWithCompatibilityProfile)
{
return false;
}
await logService.WriteAsync(
SystemLogLevel.Warning,
"FFmpeg",
"Initial ffmpeg input profile was rejected. Retrying once with minimal compatibility options.",
runtime.LastStartupFailureLine,
session.LiveRoomId,
session.Id,
currentTask.Id);
var retryStream = new StreamUrlResult(
runtime.SelectedQuality,
runtime.SelectedProtocol,
runtime.StreamUrl,
runtime.InputHeaders,
Array.Empty<StreamQualityOption>());
session.MarkStarting(retryStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt);
session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt);
currentTask.MarkStarting(retryStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt);
await dbContext.SaveChangesAsync();
await StartInternalAsync(
session,
currentTask,
retryStream,
FfmpegInputOptionProfile.Minimal,
hasRetriedWithCompatibilityProfile: true,
hasRetriedWithRefreshedStream: runtime.HasRetriedWithRefreshedStream,
runtime.RetryAttemptCount + 1);
var restartedAt = DateTimeOffset.UtcNow;
session.MarkRunning(restartedAt);
currentTask.MarkRunning(restartedAt);
await dbContext.SaveChangesAsync();
await logService.WriteAsync(
SystemLogLevel.Info,
"FFmpeg",
"ffmpeg startup retry succeeded with minimal compatibility options.",
liveRoomId: session.LiveRoomId,
recordSessionId: session.Id,
recordTaskId: currentTask.Id);
return true;
}
if (runtime.StartupFailureKind != StartupFailureKind.StreamHandshake || runtime.HasRetriedWithRefreshedStream)
{
return false;
}
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform);
var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId);
await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt);
await dbContext.SaveChangesAsync();
if (!liveStatus.IsLive)
{
return false;
}
await logService.WriteAsync(
SystemLogLevel.Warning,
"FFmpeg",
"Initial ffmpeg input handshake failed. Refreshing stream URL and retrying once.",
runtime.LastStartupFailureLine,
session.LiveRoomId,
session.Id,
currentTask.Id);
var refreshedStream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality);
session.MarkStarting(refreshedStream.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, observedAt);
session.ActivateSegment(Math.Max(1, runtime.CurrentSegmentIndex), observedAt);
currentTask.MarkStarting(refreshedStream.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, observedAt);
await dbContext.SaveChangesAsync();
await StartInternalAsync(
session,
currentTask,
refreshedStream,
runtime.InputOptionProfile,
hasRetriedWithCompatibilityProfile: runtime.HasRetriedWithCompatibilityProfile,
hasRetriedWithRefreshedStream: true,
runtime.RetryAttemptCount + 1);
var refreshedRetryStartedAt = DateTimeOffset.UtcNow;
session.MarkRunning(refreshedRetryStartedAt);
currentTask.MarkRunning(refreshedRetryStartedAt);
await dbContext.SaveChangesAsync();
await logService.WriteAsync(
SystemLogLevel.Info,
"FFmpeg",
"ffmpeg startup retry succeeded with a refreshed stream URL.",
liveRoomId: session.LiveRoomId,
recordSessionId: session.Id,
recordTaskId: currentTask.Id);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Recover ffmpeg startup failure failed for session {RecordSessionId}", runtime.RecordSessionId);
return false;
}
}
private async Task FinalizeExitedSessionAsync(
SessionProcessRuntime runtime,
Process process,
SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
var settings = await settingsService.GetAsync();
var session = await dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId);
if (session is null)
{
return;
}
var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId)
?? session.RecordTasks.OrderByDescending(static item => item.SegmentIndex).FirstOrDefault();
if (currentTask is null)
{
return;
}
var endedAt = DateTimeOffset.UtcNow;
string? finalizationError = null;
var effectiveOutputPath = runtime.CurrentOutputFilePath;
if (session.SaveMode == RecordSaveMode.SingleFile &&
session.OutputFormat == RecordOutputFormat.Mp4 &&
!string.IsNullOrWhiteSpace(session.OutputPathPattern))
{
var finalOutputPath = Path.IsPathRooted(session.OutputPathPattern)
? session.OutputPathPattern
: Path.GetFullPath(session.OutputPathPattern, AppContext.BaseDirectory);
var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, runtime.RecorderOutputPath, finalOutputPath);
effectiveOutputPath = finalizationResult.OutputPath;
finalizationError = finalizationResult.ErrorMessage;
}
var fileSize = CalculateFileSize(effectiveOutputPath);
var durationSeconds = currentTask.StartedAt.HasValue
? (double?)Math.Max(0, (endedAt - currentTask.StartedAt.Value).TotalSeconds)
: null;
var danmakuPath = activeDanmakuSummary?.FilePath ?? currentTask.Result?.DanmakuFilePath ?? GuessDanmakuPath(currentTask.OutputFilePath);
var danmakuMessageCount = activeDanmakuSummary?.MessageCount ?? CountDanmakuMessages(danmakuPath);
if (!string.IsNullOrWhiteSpace(finalizationError))
{
currentTask.MarkFailed(finalizationError, endedAt);
session.MarkFailed(finalizationError, endedAt);
}
else if (runtime.StopRequested)
{
currentTask.MarkStopped(endedAt, durationSeconds);
session.MarkStopped(endedAt);
}
else if (process.ExitCode == 0 && (runtime.CompletionRequested || !runtime.StopRequested))
{
currentTask.MarkCompleted(endedAt, durationSeconds);
session.MarkCompleted(endedAt);
}
else
{
var errorMessage = $"ffmpeg exit code: {process.ExitCode}";
currentTask.MarkFailed(errorMessage, endedAt);
session.MarkFailed(errorMessage, endedAt);
}
currentTask.DetachProcess(endedAt);
session.SyncSegmentCount(session.RecordTasks.Count, endedAt);
UpsertRecordResult(currentTask, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuMessageCount, endedAt);
await dbContext.SaveChangesAsync();
await logService.WriteAsync(
session.Status == RecordSessionStatus.Completed ? SystemLogLevel.Info :
session.Status == RecordSessionStatus.Stopped ? SystemLogLevel.Warning :
SystemLogLevel.Error,
"FFmpeg",
$"Recording session exited with status={session.Status}.",
$"exitCode={process.ExitCode}; output={effectiveOutputPath}; recorderOutput={runtime.RecorderOutputPath}",
session.LiveRoomId,
session.Id,
currentTask.Id);
if (session.Status == RecordSessionStatus.Failed && session.LiveRoom is not null)
{
await emailNotificationService.SendExceptionAsync(
"FFmpeg",
"Recording session exited abnormally.",
$"exitCode={process.ExitCode}; output={effectiveOutputPath}",
session.LiveRoom,
currentTask);
}
}
private async Task PersistProcessBindingAsync(Guid recordSessionId, Guid recordTaskId, int processId, CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var session = await dbContext.RecordSessions.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
var task = await dbContext.RecordTasks.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
var now = DateTimeOffset.UtcNow;
session?.AttachProcess(processId, now);
task?.AttachProcess(processId, now);
if (session is not null || task is not null)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
}
private async Task StartDanmakuAsync(SessionProcessRuntime runtime, RecordTask initialTask, CancellationToken cancellationToken)
{
try
{
LiveRoom? liveRoom = null;
var includeNonChatEvents = true;
using (var readScope = _serviceScopeFactory.CreateScope())
{
var dbContext = readScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var adapterFactory = readScope.ServiceProvider.GetRequiredService<ILiveDanmakuAdapterFactory>();
var settingsService = readScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(cancellationToken);
if (!settings.EnableDanmakuRecording)
{
return;
}
liveRoom = await dbContext.LiveRooms
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == runtime.LiveRoomId, cancellationToken);
if (liveRoom is null)
{
return;
}
includeNonChatEvents = settings.DanmakuIncludeNonChatEvents;
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
runtime.DanmakuConnection = await adapter.ConnectAsync(
new DanmakuConnectionContext(
liveRoom.Id,
runtime.RecordSessionId,
liveRoom.Platform,
liveRoom.RoomId,
liveRoom.AnchorName,
liveRoom.Title,
liveRoom.SourceUrl,
settings.DanmakuMinPollIntervalMilliseconds,
settings.DanmakuRetryDelayMaxSeconds),
cancellationToken);
}
runtime.DanmakuRecorder = new SessionDanmakuXmlRecorder(
liveRoom.Platform,
liveRoom.Id,
runtime.RecordSessionId,
liveRoom.RoomId);
await runtime.DanmakuRecorder.StartSegmentAsync(
initialTask.Id,
initialTask.SegmentIndex,
initialTask.OutputFilePath ?? runtime.OutputPathPattern,
initialTask.StartedAt ?? DateTimeOffset.UtcNow);
runtime.HasInitializedDanmaku = true;
runtime.DanmakuPumpTask = Task.Run(
() => runtime.DanmakuConnection.StartAsync(
danmakuEvent =>
{
if (runtime.DanmakuRecorder is null)
{
return Task.CompletedTask;
}
if (!includeNonChatEvents &&
!string.Equals(danmakuEvent.Type, "chat", StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}
return runtime.DanmakuRecorder.AppendAsync(danmakuEvent);
},
runtime.DanmakuCancellation.Token),
runtime.DanmakuCancellation.Token);
using var logScope = _serviceScopeFactory.CreateScope();
var logService = logScope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
SystemLogLevel.Info,
"Danmaku",
"Danmaku capture started for the recording session.",
liveRoomId: liveRoom.Id,
recordSessionId: runtime.RecordSessionId,
recordTaskId: initialTask.Id,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Start danmaku capture failed for session {RecordSessionId}", runtime.RecordSessionId);
try
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
SystemLogLevel.Warning,
"Danmaku",
"Danmaku capture startup failed. Recording will continue without live comments until retry succeeds.",
ex.ToString(),
runtime.LiveRoomId,
runtime.RecordSessionId,
runtime.CurrentTaskId,
cancellationToken);
}
catch (Exception logEx)
{
_logger.LogWarning(logEx, "Persist danmaku startup failure log failed for session {RecordSessionId}", runtime.RecordSessionId);
}
}
}
private static bool IsRetryableStartupFailureLine(string line) =>
line.Contains("Error reading HTTP response", StringComparison.OrdinalIgnoreCase) ||
line.Contains("unexpected EOF", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Connection reset", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) ||
line.Contains("I/O error", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Invalid data found when processing input", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Server returned 4", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Server returned 5", StringComparison.OrdinalIgnoreCase) ||
line.Contains("HTTP error 4", StringComparison.OrdinalIgnoreCase) ||
line.Contains("HTTP error 5", StringComparison.OrdinalIgnoreCase);
private static bool IsOptionCompatibilityFailureLine(string line) =>
line.Contains("Option not found", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Unrecognized option", StringComparison.OrdinalIgnoreCase);
private async Task EnsureDanmakuSegmentAsync(SessionProcessRuntime runtime, RecordTask recordTask, DateTimeOffset startedAt)
{
if (runtime.DanmakuRecorder is null)
{
return;
}
await runtime.DanmakuRecorder.StartSegmentAsync(
recordTask.Id,
recordTask.SegmentIndex,
recordTask.OutputFilePath ?? runtime.CurrentOutputFilePath,
startedAt);
}
private sealed class SessionProcessRuntime : IDisposable
{
public SessionProcessRuntime(
Guid recordSessionId,
Guid liveRoomId,
string streamUrl,
string outputPathPattern,
string recorderOutputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
Guid currentTaskId,
int currentSegmentIndex,
string currentOutputFilePath,
string selectedQuality,
string selectedProtocol,
StreamInputHeaders? inputHeaders,
FfmpegInputOptionProfile inputOptionProfile,
bool hasRetriedWithCompatibilityProfile,
bool hasRetriedWithRefreshedStream,
int retryAttemptCount)
{
RecordSessionId = recordSessionId;
LiveRoomId = liveRoomId;
StreamUrl = streamUrl;
OutputPathPattern = outputPathPattern;
RecorderOutputPath = recorderOutputPath;
OutputFormat = outputFormat;
SaveMode = saveMode;
CurrentTaskId = currentTaskId;
CurrentSegmentIndex = currentSegmentIndex;
CurrentOutputFilePath = currentOutputFilePath;
SelectedQuality = selectedQuality;
SelectedProtocol = selectedProtocol;
InputHeaders = inputHeaders;
InputOptionProfile = inputOptionProfile;
HasRetriedWithCompatibilityProfile = hasRetriedWithCompatibilityProfile;
HasRetriedWithRefreshedStream = hasRetriedWithRefreshedStream;
RetryAttemptCount = Math.Max(0, retryAttemptCount);
}
public Guid RecordSessionId { get; }
public Guid LiveRoomId { get; }
public string StreamUrl { get; }
public string OutputPathPattern { get; }
public string RecorderOutputPath { get; }
public RecordOutputFormat OutputFormat { get; }
public RecordSaveMode SaveMode { get; }
public Guid CurrentTaskId { get; set; }
public int CurrentSegmentIndex { get; set; }
public string CurrentOutputFilePath { get; set; }
public string SelectedQuality { get; }
public string SelectedProtocol { get; }
public StreamInputHeaders? InputHeaders { get; }
public FfmpegInputOptionProfile InputOptionProfile { get; }
public bool HasRetriedWithCompatibilityProfile { get; }
public bool HasRetriedWithRefreshedStream { get; }
public Process? Process { get; private set; }
public int ProcessId => Process?.Id ?? 0;
public bool CompletionRequested { get; private set; }
public bool StopRequested { get; private set; }
public bool HasInitializedDanmaku { get; set; }
public bool HasOpenedFirstSegment { get; set; }
public int RetryAttemptCount { get; }
public StartupFailureKind StartupFailureKind { get; private set; }
public string? LastStartupFailureLine { get; private set; }
public SemaphoreSlim Gate { get; } = new(1, 1);
public CancellationTokenSource DanmakuCancellation { get; } = new();
public TaskCompletionSource<bool> ExitCompletion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public SessionDanmakuXmlRecorder? DanmakuRecorder { get; set; }
public ILiveDanmakuConnection? DanmakuConnection { get; set; }
public Task? DanmakuPumpTask { get; set; }
public void AttachProcess(Process process) => Process = process;
public void MarkStopRequested(bool markAsCompletedOnExit)
{
if (markAsCompletedOnExit)
{
CompletionRequested = true;
StopRequested = false;
}
else
{
CompletionRequested = false;
StopRequested = true;
}
}
public void MarkStartupFailure(StartupFailureKind kind, string line)
{
if (StartupFailureKind == StartupFailureKind.InputOptionCompatibility &&
kind != StartupFailureKind.InputOptionCompatibility)
{
return;
}
StartupFailureKind = kind;
LastStartupFailureLine = line;
}
public void Dispose()
{
DanmakuCancellation.Dispose();
Gate.Dispose();
}
}
}
@@ -0,0 +1,399 @@
using System.Diagnostics;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService
{
private static async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async(
string ffmpegPath,
string sourcePath,
string targetPath)
{
if (!File.Exists(sourcePath))
{
return (File.Exists(targetPath) ? targetPath : sourcePath, "The intermediate recording file was not found for MP4 finalization.");
}
var tempPath = Path.Combine(
Path.GetDirectoryName(targetPath)!,
$"{Path.GetFileNameWithoutExtension(targetPath)}.remux{Path.GetExtension(targetPath)}");
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
var remuxProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = $"-hide_banner -y -i {Quote(sourcePath)} -c copy -movflags +faststart {Quote(tempPath)}",
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true
}
};
remuxProcess.Start();
await remuxProcess.WaitForExitAsync();
if (remuxProcess.ExitCode == 0 && File.Exists(tempPath))
{
if (File.Exists(targetPath))
{
File.Replace(tempPath, targetPath, null, ignoreMetadataErrors: true);
}
else
{
File.Move(tempPath, targetPath);
}
if (!string.Equals(sourcePath, targetPath, StringComparison.OrdinalIgnoreCase) && File.Exists(sourcePath))
{
File.Delete(sourcePath);
}
return (targetPath, null);
}
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath;
return (fallbackPath, "The MP4 file could not be finalized into a seekable output.");
}
private static IReadOnlyList<string> BuildArgumentList(
string streamUrl,
string outputFilePath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode,
RecordingTemplateType recordingTemplate,
bool enableReconnect,
int reconnectDelayMaxSeconds,
int readWriteTimeoutMilliseconds,
int segmentDurationMinutes,
StreamInputHeaders? inputHeaders,
FfmpegInputOptionProfile inputOptionProfile)
{
var arguments = new List<string> { "-hide_banner", "-y" };
var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode);
if (IsHttpInput(streamUrl))
{
if (!string.IsNullOrWhiteSpace(inputHeaders?.UserAgent))
{
arguments.AddRange(["-user_agent", inputHeaders.UserAgent]);
}
if (!string.IsNullOrWhiteSpace(inputHeaders?.Referer))
{
arguments.AddRange(["-referer", inputHeaders.Referer]);
}
var customHeaders = BuildCustomHeaderArgument(inputHeaders);
if (!string.IsNullOrWhiteSpace(customHeaders))
{
arguments.AddRange(["-headers", customHeaders]);
}
}
if (enableReconnect && inputOptionProfile == FfmpegInputOptionProfile.Baseline)
{
arguments.AddRange(
[
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_at_eof", "1",
"-reconnect_delay_max", reconnectDelayMaxSeconds.ToString()
]);
}
arguments.AddRange(["-rw_timeout", readWriteTimeoutMilliseconds.ToString(), "-fflags", "+discardcorrupt+genpts", "-i", streamUrl]);
arguments.AddRange(BuildCodecArguments(recordingTemplate));
if (saveMode == RecordSaveMode.Segmented)
{
arguments.AddRange(
[
"-f", "segment",
"-segment_start_number", "1",
"-segment_time", Math.Max(60, segmentDurationMinutes * 60).ToString(),
"-reset_timestamps", "1",
"-strftime", "0",
"-segment_format", outputFormat == RecordOutputFormat.Ts ? "mpegts" : "mp4"
]);
if (outputFormat == RecordOutputFormat.Mp4)
{
arguments.AddRange(["-segment_format_options", $"movflags={BuildSegmentedMp4MovFlags()}"]);
}
}
else if (useIntermediateTransportStream)
{
arguments.AddRange(["-f", "mpegts"]);
}
else if (outputFormat == RecordOutputFormat.Mp4)
{
arguments.AddRange(["-movflags", BuildSingleFileMp4MovFlags()]);
}
arguments.Add(outputFilePath);
return arguments;
}
private static IReadOnlyList<string> BuildCodecArguments(RecordingTemplateType recordingTemplate) =>
recordingTemplate switch
{
RecordingTemplateType.BalancedMp4 =>
[
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-c:a", "aac",
"-b:a", "128k"
],
RecordingTemplateType.ArchiveTs =>
[
"-map", "0",
"-c", "copy"
],
_ =>
[
"-c", "copy"
]
};
private static long? CalculateFileSize(string? outputPath)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
return null;
}
if (File.Exists(outputPath))
{
return new FileInfo(outputPath).Length;
}
if (Directory.Exists(outputPath))
{
return new DirectoryInfo(outputPath)
.EnumerateFiles("*", SearchOption.TopDirectoryOnly)
.Sum(static file => file.Length);
}
return null;
}
private static bool HasUsableOutput(string? outputPath, long? fileSize)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
return false;
}
if (File.Exists(outputPath))
{
return fileSize.GetValueOrDefault() > 0;
}
if (Directory.Exists(outputPath))
{
return Directory.EnumerateFiles(outputPath, "*", SearchOption.TopDirectoryOnly).Any();
}
return false;
}
private static void UpsertRecordResult(
RecordTask recordTask,
LiveRecorderDbContext dbContext,
string? effectiveOutputPath,
long? fileSize,
double? durationSeconds,
string? danmakuFilePath,
int danmakuMessageCount,
DateTimeOffset endedAt)
{
if (string.IsNullOrWhiteSpace(effectiveOutputPath))
{
effectiveOutputPath = recordTask.OutputFilePath ?? string.Empty;
}
if (recordTask.Result is null)
{
dbContext.RecordResults.Add(new RecordResult(
recordTask.Id,
effectiveOutputPath,
fileSize,
durationSeconds,
danmakuFilePath,
danmakuMessageCount,
recordTask.Status,
recordTask.ErrorMessage,
endedAt));
return;
}
recordTask.Result.Update(
effectiveOutputPath,
fileSize,
durationSeconds,
danmakuFilePath,
danmakuMessageCount,
recordTask.Status,
recordTask.ErrorMessage);
}
private static string BuildSingleFileMp4MovFlags() =>
"+faststart+frag_keyframe+empty_moov+default_base_moof";
private static string BuildSegmentedMp4MovFlags() =>
"+faststart+frag_keyframe+empty_moov+default_base_moof";
private static string GetRecorderOutputPath(
string finalOutputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode)
{
if (saveMode == RecordSaveMode.SingleFile && outputFormat == RecordOutputFormat.Mp4)
{
return Path.Combine(
Path.GetDirectoryName(finalOutputPath)!,
$"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts");
}
return finalOutputPath;
}
private static bool ShouldUseIntermediateTransportStream(
string outputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode) =>
saveMode == RecordSaveMode.SingleFile &&
outputFormat == RecordOutputFormat.Mp4 &&
outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase);
private static bool IsHttpInput(string streamUrl) =>
streamUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
streamUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
private static string? BuildCustomHeaderArgument(StreamInputHeaders? inputHeaders)
{
if (inputHeaders is null)
{
return null;
}
var builder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(inputHeaders.Cookie))
{
builder.Append("Cookie: ");
builder.Append(inputHeaders.Cookie.Trim());
builder.Append("\r\n");
}
if (inputHeaders.AdditionalHeaders is not null)
{
foreach (var pair in inputHeaders.AdditionalHeaders.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key)))
{
builder.Append(pair.Key.Trim());
builder.Append(": ");
builder.Append(pair.Value?.Trim() ?? string.Empty);
builder.Append("\r\n");
}
}
return builder.Length == 0 ? null : builder.ToString();
}
private static bool TryParseSegmentOpenPath(string line, out string openedPath)
{
var match = SegmentOpeningRegex.Match(line);
if (match.Success)
{
openedPath = match.Groups[1].Value;
return true;
}
openedPath = string.Empty;
return false;
}
private static int? ExtractSegmentIndex(string openedPath)
{
var fileName = Path.GetFileNameWithoutExtension(openedPath);
var lastUnderscore = fileName.LastIndexOf('_');
if (lastUnderscore < 0 || lastUnderscore == fileName.Length - 1)
{
return null;
}
var suffix = fileName[(lastUnderscore + 1)..];
return int.TryParse(suffix, out var value) ? value : null;
}
private static string? GuessDanmakuPath(string? outputFilePath)
{
if (string.IsNullOrWhiteSpace(outputFilePath))
{
return null;
}
var fullPath = Path.IsPathRooted(outputFilePath)
? outputFilePath
: Path.GetFullPath(outputFilePath, AppContext.BaseDirectory);
return SessionDanmakuXmlRecorder.GetDanmakuFilePath(fullPath);
}
private static int CountDanmakuMessages(string? danmakuPath)
{
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
{
return 0;
}
var count = 0;
foreach (var line in File.ReadLines(danmakuPath))
{
if (line.Contains("<d ", StringComparison.OrdinalIgnoreCase) ||
line.Contains("<event ", StringComparison.OrdinalIgnoreCase))
{
count++;
}
}
return count;
}
private static bool IsActiveTaskStatus(RecordTaskStatus status) =>
status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping;
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
private enum FfmpegInputOptionProfile
{
Baseline = 0,
Minimal = 1
}
private enum StartupFailureKind
{
None = 0,
InputOptionCompatibility = 1,
StreamHandshake = 2
}
}
@@ -0,0 +1,380 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService : IFfmpegService
{
private static readonly Regex SegmentOpeningRegex = new(
"""Opening '([^']+)' for writing""",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new();
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<FfmpegService> _logger;
public FfmpegService(
IServiceScopeFactory serviceScopeFactory,
ILogger<FfmpegService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
public bool IsRunning(Guid recordSessionId) => _processes.ContainsKey(recordSessionId);
public Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
RequestStopAsync(recordSessionId, markAsCompletedOnExit: true, cancellationToken);
public Task StartAsync(
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
CancellationToken cancellationToken = default) =>
StartInternalAsync(
recordSession,
initialTask,
streamUrlResult,
inputOptionProfile: FfmpegInputOptionProfile.Baseline,
hasRetriedWithCompatibilityProfile: false,
hasRetriedWithRefreshedStream: false,
retryAttemptCount: 0,
cancellationToken);
private async Task StartInternalAsync(
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
FfmpegInputOptionProfile inputOptionProfile,
bool hasRetriedWithCompatibilityProfile,
bool hasRetriedWithRefreshedStream,
int retryAttemptCount,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(recordSession);
ArgumentNullException.ThrowIfNull(initialTask);
ArgumentNullException.ThrowIfNull(streamUrlResult);
if (string.IsNullOrWhiteSpace(streamUrlResult.SelectedUrl) || string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
{
throw new InvalidOperationException("Recording session is missing stream URL or output path.");
}
using var settingsScope = _serviceScopeFactory.CreateScope();
var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(cancellationToken);
var outputPathPattern = Path.IsPathRooted(recordSession.OutputPathPattern)
? recordSession.OutputPathPattern
: Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory);
var recorderOutputPath = GetRecorderOutputPath(outputPathPattern, recordSession.OutputFormat, recordSession.SaveMode);
Directory.CreateDirectory(Path.GetDirectoryName(recorderOutputPath)!);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = settings.FfmpegPath,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
var runtime = new SessionProcessRuntime(
recordSession.Id,
recordSession.LiveRoomId,
streamUrlResult.SelectedUrl,
outputPathPattern,
recorderOutputPath,
recordSession.OutputFormat,
recordSession.SaveMode,
initialTask.Id,
Math.Max(1, initialTask.SegmentIndex),
initialTask.OutputFilePath ?? outputPathPattern,
streamUrlResult.SelectedQuality,
streamUrlResult.SelectedProtocol,
streamUrlResult.InputHeaders,
inputOptionProfile,
hasRetriedWithCompatibilityProfile,
hasRetriedWithRefreshedStream,
retryAttemptCount);
foreach (var argument in BuildArgumentList(
streamUrlResult.SelectedUrl,
recorderOutputPath,
recordSession.OutputFormat,
recordSession.SaveMode,
settings.RecordingTemplate,
settings.EnableAutoReconnect,
settings.ReconnectDelayMaxSeconds,
settings.ReadWriteTimeoutMilliseconds,
settings.SegmentDurationMinutes,
streamUrlResult.InputHeaders,
inputOptionProfile))
{
process.StartInfo.ArgumentList.Add(argument);
}
process.OutputDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: false);
process.ErrorDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: true);
process.Exited += (_, _) => _ = HandleProcessExitedAsync(runtime, process);
if (!process.Start())
{
throw new InvalidOperationException("ffmpeg failed to start.");
}
runtime.AttachProcess(process);
if (!_processes.TryAdd(recordSession.Id, runtime))
{
process.Kill(true);
process.Dispose();
throw new InvalidOperationException("A running ffmpeg process already exists for the recording session.");
}
await PersistProcessBindingAsync(recordSession.Id, initialTask.Id, process.Id, cancellationToken);
await PersistStartupProfileAsync(runtime, cancellationToken);
await StartDanmakuAsync(runtime, initialTask, cancellationToken);
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
public Task StopAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
RequestStopAsync(recordSessionId, markAsCompletedOnExit: false, cancellationToken);
public async Task<bool> StopAndWaitAsync(
Guid recordSessionId,
bool markAsCompletedOnExit,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return true;
}
await RequestStopAsync(recordSessionId, markAsCompletedOnExit, cancellationToken);
return await WaitForExitAsync(runtime, timeout, cancellationToken);
}
public async Task<bool> KillAndWaitAsync(
Guid recordSessionId,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return true;
}
var process = runtime.Process;
if (process is not null)
{
try
{
if (!process.HasExited)
{
process.Kill(true);
}
}
catch (InvalidOperationException)
{
}
}
return await WaitForExitAsync(runtime, timeout, cancellationToken);
}
public async Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
{
if (IsRunning(recordSessionId))
{
return false;
}
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var recordSession = await dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
if (recordSession is null || !IsActiveSessionStatus(recordSession.Status))
{
return false;
}
var settings = await settingsService.GetAsync(cancellationToken);
var endedAt = DateTimeOffset.UtcNow;
var tasks = recordSession.RecordTasks
.OrderBy(static item => item.SegmentIndex)
.ThenBy(static item => item.CreatedAt)
.ToList();
var anyUsableOutput = false;
string? finalizationError = null;
foreach (var task in tasks.Where(item => IsActiveTaskStatus(item.Status)))
{
var effectiveOutputPath = task.OutputFilePath ?? string.Empty;
if (recordSession.SaveMode == RecordSaveMode.SingleFile &&
recordSession.OutputFormat == RecordOutputFormat.Mp4 &&
!string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
{
var finalOutputPath = Path.IsPathRooted(recordSession.OutputPathPattern)
? recordSession.OutputPathPattern
: Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory);
var recorderOutputPath = GetRecorderOutputPath(finalOutputPath, recordSession.OutputFormat, recordSession.SaveMode);
if (File.Exists(recorderOutputPath))
{
var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, recorderOutputPath, finalOutputPath);
effectiveOutputPath = finalizationResult.OutputPath;
finalizationError ??= finalizationResult.ErrorMessage;
}
}
var fileSize = CalculateFileSize(effectiveOutputPath);
var danmakuPath = task.Result?.DanmakuFilePath ?? GuessDanmakuPath(task.OutputFilePath);
var danmakuCount = CountDanmakuMessages(danmakuPath);
var durationSeconds = task.StartedAt.HasValue
? (double?)Math.Max(0, (endedAt - task.StartedAt.Value).TotalSeconds)
: null;
if (!string.IsNullOrWhiteSpace(finalizationError))
{
task.MarkFailed(finalizationError, endedAt);
}
else if (HasUsableOutput(effectiveOutputPath, fileSize))
{
task.MarkCompleted(endedAt, durationSeconds);
anyUsableOutput = true;
}
else
{
task.MarkStopped(endedAt, durationSeconds, "Recording process was no longer running when the session was reconciled.");
}
UpsertRecordResult(task, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuCount, endedAt);
}
recordSession.SyncSegmentCount(tasks.Count, endedAt);
if (!string.IsNullOrWhiteSpace(finalizationError))
{
recordSession.MarkFailed(finalizationError, endedAt);
}
else if (anyUsableOutput)
{
recordSession.MarkCompleted(endedAt);
}
else
{
recordSession.MarkStopped(endedAt, "Recording process was no longer running when the session was reconciled.");
}
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
private async Task PersistStartupProfileAsync(SessionProcessRuntime runtime, CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
SystemLogLevel.Info,
"FFmpeg",
$"ffmpeg input profile={runtime.InputOptionProfile}.",
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}",
liveRoomId: runtime.LiveRoomId,
recordSessionId: runtime.RecordSessionId,
recordTaskId: runtime.CurrentTaskId,
cancellationToken: cancellationToken);
}
private static async Task<bool> WaitForExitAsync(
SessionProcessRuntime runtime,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var delayTask = Task.Delay(timeout, timeoutCts.Token);
var completed = await Task.WhenAny(runtime.ExitCompletion.Task, delayTask);
if (completed == runtime.ExitCompletion.Task)
{
timeoutCts.Cancel();
await runtime.ExitCompletion.Task;
return true;
}
return false;
}
private async Task RequestStopAsync(Guid recordSessionId, bool markAsCompletedOnExit, CancellationToken cancellationToken)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return;
}
runtime.MarkStopRequested(markAsCompletedOnExit);
var process = runtime.Process;
if (process is null)
{
return;
}
try
{
if (process.HasExited)
{
return;
}
await process.StandardInput.WriteLineAsync("q");
await process.StandardInput.FlushAsync();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(12));
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(true);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Stop ffmpeg process failed for session {RecordSessionId}", recordSessionId);
if (!process.HasExited)
{
process.Kill(true);
}
}
}
}
@@ -0,0 +1,243 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class LiveRoomPollingBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<LiveRoomPollingBackgroundService> _logger;
public LiveRoomPollingBackgroundService(
IServiceScopeFactory serviceScopeFactory,
ILogger<LiveRoomPollingBackgroundService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var delaySeconds = 60;
try
{
using var scope = _serviceScopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(stoppingToken);
var emailNotificationService = scope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
delaySeconds = settings.PollingIntervalSeconds;
if (!settings.EnableBackgroundPolling)
{
await DelayAsync(delaySeconds, stoppingToken);
continue;
}
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var recordService = scope.ServiceProvider.GetRequiredService<RecordService>();
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
var liveRooms = (await dbContext.LiveRooms.ToListAsync(stoppingToken))
.Where(static item => item.IsEnabled)
.OrderBy(static item => item.UpdatedAt)
.ToList();
foreach (var liveRoom in liveRooms)
{
if (stoppingToken.IsCancellationRequested)
{
break;
}
try
{
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, stoppingToken);
var now = DateTimeOffset.UtcNow;
await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, stoppingToken);
await dbContext.SaveChangesAsync(stoppingToken);
if (!liveStatus.IsLive)
{
await CompleteActiveSessionsForOfflineRoomAsync(
dbContext,
ffmpegService,
logService,
liveRoom.Id,
stoppingToken);
continue;
}
if (!settings.AutoStartRecordingOnLive)
{
continue;
}
var hasRunningSession = await dbContext.RecordSessions.AnyAsync(
item => item.LiveRoomId == liveRoom.Id &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping),
stoppingToken);
if (hasRunningSession)
{
continue;
}
_logger.LogInformation("Auto-start recording for live room {RoomId}", liveRoom.RoomId);
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
"Live detected by background poller. Auto-starting recording task.",
liveRoomId: liveRoom.Id,
cancellationToken: stoppingToken);
await recordService.StartAsync(
new StartRecordTaskRequest
{
LiveRoomId = liveRoom.Id,
PreferredQuality = settings.DefaultQuality,
OutputFormat = settings.DefaultOutputFormat
},
stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Polling room {RoomId} failed", liveRoom.RoomId);
var isTransient = IsTransientPollingException(ex, stoppingToken);
await logService.WriteAsync(
isTransient ? SystemLogLevel.Warning : SystemLogLevel.Error,
"Scheduler",
isTransient
? "Transient background polling failure. The room will be retried on the next cycle."
: "Background polling failed for a live room.",
ex.ToString(),
liveRoomId: liveRoom.Id,
cancellationToken: stoppingToken);
if (!isTransient)
{
await emailNotificationService.SendExceptionAsync(
"Scheduler",
"Background polling failed for a live room.",
ex.ToString(),
liveRoom,
cancellationToken: stoppingToken);
}
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Background live room polling failed");
try
{
using var notificationScope = _serviceScopeFactory.CreateScope();
var emailNotificationService = notificationScope.ServiceProvider.GetRequiredService<IEmailNotificationService>();
await emailNotificationService.SendExceptionAsync(
"Scheduler",
"Background live room polling failed.",
ex.ToString(),
cancellationToken: stoppingToken);
}
catch (Exception notificationEx)
{
_logger.LogWarning(notificationEx, "Scheduler failure notification send failed");
}
}
await DelayAsync(delaySeconds, stoppingToken);
}
}
private static Task DelayAsync(int delaySeconds, CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delaySeconds, 10, 3600)), cancellationToken);
private static async Task CompleteActiveSessionsForOfflineRoomAsync(
LiveRecorderDbContext dbContext,
IFfmpegService ffmpegService,
ISystemLogService logService,
Guid liveRoomId,
CancellationToken cancellationToken)
{
var activeSessions = await dbContext.RecordSessions
.Where(item => item.LiveRoomId == liveRoomId &&
(item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping))
.ToListAsync(cancellationToken);
activeSessions = activeSessions
.OrderBy(item => item.CreatedAt)
.ToList();
foreach (var activeSession in activeSessions)
{
if (ffmpegService.IsRunning(activeSession.Id))
{
await ffmpegService.CompleteAsync(activeSession.Id, cancellationToken);
await logService.WriteAsync(
SystemLogLevel.Info,
"Scheduler",
"Live room is offline. Completing the active recording session.",
liveRoomId: liveRoomId,
recordSessionId: activeSession.Id,
cancellationToken: cancellationToken);
continue;
}
if (await ffmpegService.TryReconcileInactiveSessionAsync(activeSession.Id, cancellationToken))
{
await logService.WriteAsync(
SystemLogLevel.Warning,
"Scheduler",
"Recovered a stale active recording session after the room was detected offline.",
liveRoomId: liveRoomId,
recordSessionId: activeSession.Id,
cancellationToken: cancellationToken);
}
}
}
private static bool IsTransientPollingException(Exception exception, CancellationToken cancellationToken)
{
if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested)
{
return false;
}
if (exception is HttpRequestException or IOException or TimeoutException)
{
return true;
}
return exception.InnerException is not null &&
IsTransientPollingException(exception.InnerException, cancellationToken);
}
}
@@ -0,0 +1,102 @@
using System.Security.Cryptography;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Caching.Memory;
namespace LiveRecorder.Infrastructure.Services;
public sealed class RecordMediaService : IRecordMediaService
{
private static readonly TimeSpan PreviewTicketLifetime = TimeSpan.FromMinutes(5);
private readonly IMemoryCache _memoryCache;
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IRecordResultRepository _recordResultRepository;
public RecordMediaService(
IMemoryCache memoryCache,
IRecordTaskRepository recordTaskRepository,
IRecordResultRepository recordResultRepository)
{
_memoryCache = memoryCache;
_recordTaskRepository = recordTaskRepository;
_recordResultRepository = recordResultRepository;
}
public async Task<RecordPreviewTicketGrant> CreatePreviewTicketAsync(Guid recordTaskId, CancellationToken cancellationToken = default)
{
var recordTask = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken)
?? throw new KeyNotFoundException("Recording task was not found.");
if (recordTask.Status != RecordTaskStatus.Completed)
{
throw new InvalidOperationException("Preview is only available for completed tasks.");
}
if (recordTask.OutputFormat != RecordOutputFormat.Mp4)
{
throw new NotSupportedException("Only MP4 recordings are supported for preview.");
}
var recordResult = await _recordResultRepository.GetByTaskIdAsync(recordTaskId, cancellationToken);
var filePath = recordResult?.FilePath ?? recordTask.OutputFilePath;
if (string.IsNullOrWhiteSpace(filePath))
{
throw new InvalidOperationException("Preview file path is missing.");
}
var absoluteFilePath = Path.IsPathRooted(filePath)
? filePath
: Path.GetFullPath(filePath, AppContext.BaseDirectory);
if (Directory.Exists(absoluteFilePath))
{
throw new NotSupportedException("Segmented recordings are not supported for preview.");
}
if (!absoluteFilePath.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException("Only MP4 recordings are supported for preview.");
}
if (!File.Exists(absoluteFilePath))
{
throw new InvalidOperationException("Preview file does not exist on disk.");
}
var ticket = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
var expiresAt = DateTimeOffset.UtcNow.Add(PreviewTicketLifetime);
_memoryCache.Set(
GetCacheKey(ticket),
new PreviewTicketPayload(recordTaskId, absoluteFilePath, "video/mp4"),
expiresAt);
return new RecordPreviewTicketGrant(ticket, expiresAt);
}
public Task<RecordMediaFile?> ResolvePreviewAsync(string ticket, CancellationToken cancellationToken = default)
{
if (!_memoryCache.TryGetValue(GetCacheKey(ticket), out PreviewTicketPayload? payload) || payload is null)
{
return Task.FromResult<RecordMediaFile?>(null);
}
if (!File.Exists(payload.FilePath))
{
_memoryCache.Remove(GetCacheKey(ticket));
return Task.FromResult<RecordMediaFile?>(null);
}
return Task.FromResult<RecordMediaFile?>(
new RecordMediaFile(payload.RecordTaskId, payload.FilePath, payload.ContentType));
}
private static string GetCacheKey(string ticket) => $"record-preview:{ticket}";
private sealed record PreviewTicketPayload(
Guid RecordTaskId,
string FilePath,
string ContentType);
}
@@ -0,0 +1,236 @@
using System.Collections.Concurrent;
using System.Security;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Infrastructure.Services;
internal sealed class SessionDanmakuXmlRecorder : IAsyncDisposable
{
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly LivePlatformType _platformType;
private readonly Guid _liveRoomId;
private readonly Guid _recordSessionId;
private readonly string _roomId;
private DanmakuSegmentWriter? _currentWriter;
private readonly ConcurrentDictionary<Guid, DanmakuSegmentSummary> _completedSummaries = new();
public SessionDanmakuXmlRecorder(
LivePlatformType platformType,
Guid liveRoomId,
Guid recordSessionId,
string roomId)
{
_platformType = platformType;
_liveRoomId = liveRoomId;
_recordSessionId = recordSessionId;
_roomId = roomId;
}
public async Task StartSegmentAsync(Guid recordTaskId, int segmentIndex, string videoFilePath, DateTimeOffset startedAt)
{
await _gate.WaitAsync();
try
{
if (_currentWriter is not null)
{
var summary = await _currentWriter.CloseAsync();
_completedSummaries[_currentWriter.RecordTaskId] = summary;
}
_currentWriter = await DanmakuSegmentWriter.CreateAsync(
_platformType,
_liveRoomId,
_recordSessionId,
recordTaskId,
_roomId,
Math.Max(1, segmentIndex),
GetDanmakuFilePath(videoFilePath),
startedAt);
}
finally
{
_gate.Release();
}
}
public async Task AppendAsync(DanmakuEvent danmakuEvent)
{
await _gate.WaitAsync();
try
{
if (_currentWriter is null)
{
return;
}
await _currentWriter.AppendAsync(danmakuEvent);
}
finally
{
_gate.Release();
}
}
public async Task<DanmakuSegmentSummary?> CompleteActiveSegmentAsync()
{
await _gate.WaitAsync();
try
{
if (_currentWriter is null)
{
return null;
}
var summary = await _currentWriter.CloseAsync();
_completedSummaries[_currentWriter.RecordTaskId] = summary;
_currentWriter = null;
return summary;
}
finally
{
_gate.Release();
}
}
public DanmakuSegmentSummary? TakeSummary(Guid recordTaskId)
{
if (_completedSummaries.TryRemove(recordTaskId, out var summary))
{
return summary;
}
if (_currentWriter is not null && _currentWriter.RecordTaskId == recordTaskId)
{
return _currentWriter.ToSummary();
}
return null;
}
public async ValueTask DisposeAsync()
{
await CompleteActiveSegmentAsync();
_gate.Dispose();
}
public static string GetDanmakuFilePath(string videoFilePath) =>
Path.ChangeExtension(videoFilePath, ".xml");
internal sealed record DanmakuSegmentSummary(Guid RecordTaskId, string FilePath, int MessageCount);
private sealed class DanmakuSegmentWriter
{
private readonly StreamWriter _writer;
private readonly DateTimeOffset _startedAt;
private bool _closed;
private DanmakuSegmentWriter(
Guid recordTaskId,
int segmentIndex,
string filePath,
DateTimeOffset startedAt,
StreamWriter writer)
{
RecordTaskId = recordTaskId;
SegmentIndex = segmentIndex;
FilePath = filePath;
_startedAt = startedAt;
_writer = writer;
}
public Guid RecordTaskId { get; }
public int SegmentIndex { get; }
public string FilePath { get; }
public int MessageCount { get; private set; }
public static async Task<DanmakuSegmentWriter> CreateAsync(
LivePlatformType platformType,
Guid liveRoomId,
Guid recordSessionId,
Guid recordTaskId,
string roomId,
int segmentIndex,
string filePath,
DateTimeOffset startedAt)
{
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
var writer = new StreamWriter(filePath, false, new UTF8Encoding(false));
await writer.WriteLineAsync("""<?xml version="1.0" encoding="UTF-8"?>""");
await writer.WriteLineAsync(
$"""<i platform="{Escape(platformType.ToString())}" roomId="{Escape(roomId)}" liveRoomId="{liveRoomId}" recordSessionId="{recordSessionId}" recordTaskId="{recordTaskId}" segmentIndex="{segmentIndex}" startedAt="{startedAt:O}">""");
await writer.FlushAsync();
return new DanmakuSegmentWriter(recordTaskId, segmentIndex, filePath, startedAt, writer);
}
public async Task AppendAsync(DanmakuEvent danmakuEvent)
{
if (_closed)
{
return;
}
var offsetSeconds = Math.Max(0, (danmakuEvent.OccurredAt - _startedAt).TotalSeconds);
var timestamp = danmakuEvent.OccurredAt.ToUnixTimeMilliseconds();
if (string.Equals(danmakuEvent.Type, "chat", StringComparison.OrdinalIgnoreCase))
{
var content = Escape(danmakuEvent.Content ?? string.Empty);
var user = Escape(danmakuEvent.User ?? string.Empty);
var userId = Escape(danmakuEvent.UserId ?? "0");
var raw = Escape(CompactRawPayload(danmakuEvent.RawPayload));
await _writer.WriteLineAsync(
$""" <d p="{offsetSeconds:F1},1,25,16777215,{timestamp},0,{userId},0" user="{user}" type="chat" raw="{raw}">{content}</d>""");
}
else
{
var extraAttributes = string.Empty;
if (danmakuEvent.Extra is not null)
{
extraAttributes = string.Join(
string.Empty,
danmakuEvent.Extra.Select(pair => $" {Escape(pair.Key)}=\"{Escape(pair.Value)}\""));
}
await _writer.WriteLineAsync(
$""" <event type="{Escape(danmakuEvent.Type)}" ts="{timestamp}" offset="{offsetSeconds:F1}" user="{Escape(danmakuEvent.User ?? string.Empty)}" userId="{Escape(danmakuEvent.UserId ?? string.Empty)}" content="{Escape(danmakuEvent.Content ?? string.Empty)}" raw="{Escape(CompactRawPayload(danmakuEvent.RawPayload))}"{extraAttributes} />""");
}
MessageCount++;
await _writer.FlushAsync();
}
public async Task<DanmakuSegmentSummary> CloseAsync()
{
if (!_closed)
{
await _writer.WriteLineAsync("</i>");
await _writer.FlushAsync();
await _writer.DisposeAsync();
_closed = true;
}
return ToSummary();
}
public DanmakuSegmentSummary ToSummary() => new(RecordTaskId, FilePath, MessageCount);
private static string Escape(string input) => SecurityElement.Escape(input) ?? string.Empty;
private static string CompactRawPayload(string rawPayload)
{
if (string.IsNullOrWhiteSpace(rawPayload))
{
return string.Empty;
}
var compact = rawPayload.Trim();
return compact.Length <= 512 ? compact : compact[..512];
}
}
}