1111
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user