feat: add transcode workspace and media browser
This commit is contained in:
@@ -5,6 +5,7 @@ using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Models.Reports;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -151,6 +152,50 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
await SendAsync(settings, "[LiveRecorder] SMTP template test", body, cancellationToken, swallowErrors: false);
|
||||
}
|
||||
|
||||
public async Task SendDailyReviewAsync(DailyReviewReportDto report, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableEmailNotification)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subject = $"[LiveRecorder] Daily review: {report.Date}";
|
||||
var summary = report.Summary;
|
||||
var roomsMarkup = report.Rooms.Count == 0
|
||||
? "<li>No live rooms recorded for this day.</li>"
|
||||
: string.Join(
|
||||
string.Empty,
|
||||
report.Rooms
|
||||
.OrderByDescending(static item => item.TotalDurationSeconds)
|
||||
.Take(8)
|
||||
.Select(item =>
|
||||
$"<li><strong>{WebUtility.HtmlEncode(item.AnchorName ?? item.LiveRoomTitleFallback())}</strong> ({WebUtility.HtmlEncode(item.PlatformName)} / {WebUtility.HtmlEncode(item.RoomId)}) - sessions {item.SessionCount}, segments {item.SegmentCount}, duration {summaryDuration(item.TotalDurationSeconds)}, danmaku {item.DanmakuCount}</li>"));
|
||||
|
||||
var body = $$"""
|
||||
<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
|
||||
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Daily review</h2>
|
||||
<p>Date: <strong>{{WebUtility.HtmlEncode(report.Date)}}</strong></p>
|
||||
<ul>
|
||||
<li><strong>Active live rooms:</strong> {{summary.ActiveLiveRoomCount}}</li>
|
||||
<li><strong>Sessions:</strong> {{summary.SessionCount}}</li>
|
||||
<li><strong>Segments:</strong> {{summary.SegmentCount}}</li>
|
||||
<li><strong>Total duration:</strong> {{summaryDuration(summary.TotalDurationSeconds)}}</li>
|
||||
<li><strong>Warnings / Errors:</strong> {{summary.WarningCount}} / {{summary.ErrorCount}}</li>
|
||||
<li><strong>Total danmaku:</strong> {{summary.TotalDanmakuCount}}</li>
|
||||
</ul>
|
||||
<h3 style="margin: 20px 0 10px; color: #3e5f7c;">Top live rooms</h3>
|
||||
<ul>
|
||||
{{roomsMarkup}}
|
||||
</ul>
|
||||
</div>
|
||||
""";
|
||||
|
||||
await SendAsync(settings, subject, body, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SendAsync(
|
||||
SystemSettingsDto settings,
|
||||
string subject,
|
||||
@@ -263,4 +308,24 @@ public sealed class EmailNotificationService : IEmailNotificationService
|
||||
return htmlEncodeValues ? WebUtility.HtmlEncode(value) : value;
|
||||
});
|
||||
}
|
||||
|
||||
private static string summaryDuration(double seconds)
|
||||
{
|
||||
var normalized = Math.Max(0, seconds);
|
||||
var timeSpan = TimeSpan.FromSeconds(normalized);
|
||||
return timeSpan.TotalHours >= 1
|
||||
? $"{timeSpan.TotalHours:F1} h"
|
||||
: $"{timeSpan.TotalMinutes:F0} min";
|
||||
}
|
||||
}
|
||||
|
||||
file static class DailyReviewRoomEmailExtensions
|
||||
{
|
||||
public static string LiveRoomTitleFallback(this DailyReviewRoomDto room) =>
|
||||
room.LiveRoomTitleSafe();
|
||||
|
||||
public static string LiveRoomTitleSafe(this DailyReviewRoomDto room) =>
|
||||
string.IsNullOrWhiteSpace(room.Title)
|
||||
? room.RoomId
|
||||
: room.Title!;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Abstractions.Storage;
|
||||
using LiveRecorder.Application.Models.Media;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
@@ -409,6 +410,128 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<TranscodeMediaFileResultDto> StartManualFinalizeFileAsync(
|
||||
string sourceFilePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceFilePath))
|
||||
{
|
||||
return new TranscodeMediaFileResultDto
|
||||
{
|
||||
Success = false,
|
||||
Message = "The selected source file is empty."
|
||||
};
|
||||
}
|
||||
|
||||
var absoluteSourcePath = NormalizeAbsolutePath(sourceFilePath);
|
||||
if (!File.Exists(absoluteSourcePath))
|
||||
{
|
||||
return new TranscodeMediaFileResultDto
|
||||
{
|
||||
Success = false,
|
||||
Message = "The selected .ts file does not exist.",
|
||||
SourcePath = absoluteSourcePath
|
||||
};
|
||||
}
|
||||
|
||||
if (!absoluteSourcePath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TranscodeMediaFileResultDto
|
||||
{
|
||||
Success = false,
|
||||
Message = "Only .ts files can be transcoded to MP4.",
|
||||
SourcePath = absoluteSourcePath
|
||||
};
|
||||
}
|
||||
|
||||
var absoluteTargetPath = Path.ChangeExtension(absoluteSourcePath, ".mp4");
|
||||
if (File.Exists(absoluteTargetPath))
|
||||
{
|
||||
return new TranscodeMediaFileResultDto
|
||||
{
|
||||
Success = false,
|
||||
Message = "A target MP4 file already exists for the selected .ts file.",
|
||||
SourcePath = absoluteSourcePath,
|
||||
OutputPath = absoluteTargetPath
|
||||
};
|
||||
}
|
||||
|
||||
using var settingsScope = _serviceScopeFactory.CreateScope();
|
||||
var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var settings = await settingsService.GetAsync(cancellationToken);
|
||||
|
||||
var syntheticSessionId = Guid.NewGuid();
|
||||
var syntheticTaskId = Guid.NewGuid();
|
||||
SetPostProcessState(
|
||||
syntheticSessionId,
|
||||
syntheticTaskId,
|
||||
"Queued",
|
||||
null,
|
||||
$"Manual file transcode queued for {Path.GetFileName(absoluteSourcePath)}");
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await TryFinalizeMp4Async(
|
||||
settings.FfmpegPath,
|
||||
settings.MaxConcurrentFfmpegTranscodeTasks,
|
||||
settings.Mp4FinalizeTimeoutMinutes,
|
||||
syntheticSessionId,
|
||||
syntheticTaskId,
|
||||
absoluteSourcePath,
|
||||
absoluteTargetPath,
|
||||
expectedDurationSeconds: null,
|
||||
CancellationToken.None);
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(result.ErrorMessage))
|
||||
{
|
||||
await logService.WriteAsync(
|
||||
Domain.Enums.SystemLogLevel.Info,
|
||||
"FFmpeg",
|
||||
"Manual file transcode completed.",
|
||||
$"source={absoluteSourcePath}; output={result.OutputPath}",
|
||||
cancellationToken: CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
await logService.WriteAsync(
|
||||
Domain.Enums.SystemLogLevel.Warning,
|
||||
"FFmpeg",
|
||||
"Manual file transcode failed.",
|
||||
$"source={absoluteSourcePath}; output={result.OutputPath}; error={result.ErrorMessage}",
|
||||
cancellationToken: CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
await logService.WriteAsync(
|
||||
Domain.Enums.SystemLogLevel.Error,
|
||||
"FFmpeg",
|
||||
"Manual file transcode crashed.",
|
||||
$"source={absoluteSourcePath}; output={absoluteTargetPath}; error={ex}",
|
||||
cancellationToken: CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearPostProcessState(syntheticTaskId);
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
return new TranscodeMediaFileResultDto
|
||||
{
|
||||
Success = true,
|
||||
Message = "Manual file transcode started. Refresh later to verify the output file.",
|
||||
SourcePath = absoluteSourcePath,
|
||||
OutputPath = absoluteTargetPath
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiveRecorder.Application.Abstractions.Notifications;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Reports;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -13,21 +16,21 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
{
|
||||
private const string AppName = "LiveRecorder";
|
||||
private static readonly Regex WholeValueTemplateRegex = new(
|
||||
"\"\\{\\{\\s*(?<name>[a-zA-Z0-9_.]+)\\s*\\}\\}\"",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex TokenRegex = new(
|
||||
"\\{\\{\\s*(?<name>[a-zA-Z0-9_.]+)\\s*\\}\\}",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
private readonly ILogger<WebhookNotificationService> _logger;
|
||||
|
||||
public WebhookNotificationService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ISystemSettingsService systemSettingsService,
|
||||
ISystemLogService systemLogService,
|
||||
ILogger<WebhookNotificationService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_systemLogService = systemLogService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -39,22 +42,22 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = BuildPayload(
|
||||
var payload = BuildDefaultPayload(
|
||||
"live_started",
|
||||
$"Live started: {liveRoom.AnchorName ?? liveRoom.RoomId}",
|
||||
liveRoom.SourceUrl,
|
||||
liveRoom,
|
||||
recordTask: null);
|
||||
summary: $"Live started: {liveRoom.AnchorName ?? liveRoom.RoomId}",
|
||||
detail: liveRoom.Title,
|
||||
source: "LiveRoomStatus",
|
||||
liveRoom: liveRoom,
|
||||
recordTask: null,
|
||||
report: null);
|
||||
var variables = BuildTemplateVariables(payload, report: null);
|
||||
|
||||
await SendConfiguredWebhookAsync(
|
||||
await SendInternalAsync(
|
||||
settings,
|
||||
payload,
|
||||
"Webhook notification sent for live_started.",
|
||||
"Webhook notification failed for live_started.",
|
||||
liveRoom.Id,
|
||||
recordSessionId: null,
|
||||
recordTaskId: null,
|
||||
cancellationToken);
|
||||
variables,
|
||||
cancellationToken,
|
||||
swallowErrors: true);
|
||||
}
|
||||
|
||||
public async Task SendExceptionAsync(
|
||||
@@ -71,23 +74,52 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = BuildPayload(
|
||||
var payload = BuildDefaultPayload(
|
||||
"exception",
|
||||
summary,
|
||||
detail,
|
||||
source,
|
||||
liveRoom,
|
||||
recordTask,
|
||||
source);
|
||||
report: null);
|
||||
var variables = BuildTemplateVariables(payload, report: null);
|
||||
|
||||
await SendConfiguredWebhookAsync(
|
||||
await SendInternalAsync(
|
||||
settings,
|
||||
payload,
|
||||
"Webhook notification sent for exception.",
|
||||
"Webhook notification failed for exception.",
|
||||
liveRoom?.Id,
|
||||
recordSessionId: recordTask?.RecordSessionId,
|
||||
recordTaskId: recordTask?.Id,
|
||||
cancellationToken);
|
||||
variables,
|
||||
cancellationToken,
|
||||
swallowErrors: true);
|
||||
}
|
||||
|
||||
public async Task SendDailyReviewAsync(
|
||||
DailyReviewReportDto report,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableWebhookNotification)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = BuildDefaultPayload(
|
||||
"daily_review",
|
||||
$"Daily review {report.Date}",
|
||||
$"rooms={report.Summary.ActiveLiveRoomCount}; sessions={report.Summary.SessionCount}; segments={report.Summary.SegmentCount}; danmaku={report.Summary.TotalDanmakuCount}",
|
||||
"DailyReview",
|
||||
liveRoom: null,
|
||||
recordTask: null,
|
||||
report);
|
||||
var variables = BuildTemplateVariables(payload, report);
|
||||
|
||||
await SendInternalAsync(
|
||||
settings,
|
||||
payload,
|
||||
variables,
|
||||
cancellationToken,
|
||||
swallowErrors: false);
|
||||
}
|
||||
|
||||
public async Task<WebhookTestResultDto> SendTestAsync(
|
||||
@@ -99,239 +131,319 @@ public sealed class WebhookNotificationService : IWebhookNotificationService
|
||||
var settings = new SystemSettingsDto
|
||||
{
|
||||
EnableWebhookNotification = true,
|
||||
NotifyWebhookOnLiveStarted = true,
|
||||
NotifyWebhookOnException = true,
|
||||
WebhookUrl = request.WebhookUrl.Trim(),
|
||||
WebhookHeaders = request.WebhookHeaders,
|
||||
WebhookTimeoutSeconds = request.WebhookTimeoutSeconds,
|
||||
NotifyWebhookOnLiveStarted = true,
|
||||
NotifyWebhookOnException = true
|
||||
WebhookBodyTemplate = request.WebhookBodyTemplate,
|
||||
WebhookTimeoutSeconds = request.WebhookTimeoutSeconds
|
||||
};
|
||||
|
||||
var payload = BuildPayload(
|
||||
"live_started",
|
||||
"Webhook test from LiveRecorder.",
|
||||
"This is a sample webhook payload generated from the settings test action.",
|
||||
new LiveRoom(
|
||||
Domain.Enums.LivePlatformType.Douyin,
|
||||
"https://live.douyin.com/676493068539",
|
||||
"676493068539",
|
||||
"https://live.douyin.com/676493068539",
|
||||
DateTimeOffset.UtcNow),
|
||||
recordTask: null);
|
||||
var sampleLiveRoom = new LiveRoom(
|
||||
Domain.Enums.LivePlatformType.Douyin,
|
||||
"https://live.douyin.com/123456789",
|
||||
"123456789",
|
||||
"https://live.douyin.com/123456789",
|
||||
DateTimeOffset.UtcNow);
|
||||
sampleLiveRoom.UpdateMetadata(
|
||||
title: "Sample Live Title",
|
||||
anchorName: "Sample Anchor",
|
||||
anchorId: "anchor-123",
|
||||
avatarUrl: null,
|
||||
coverUrl: null,
|
||||
updatedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
var payload = BuildDefaultPayload(
|
||||
"test",
|
||||
"Webhook test event",
|
||||
"This is a test payload generated from the current settings form values.",
|
||||
"SettingsTest",
|
||||
sampleLiveRoom,
|
||||
recordTask: null,
|
||||
report: null);
|
||||
var variables = BuildTemplateVariables(payload, report: null);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await SendInternalAsync(settings, payload, cancellationToken);
|
||||
await _systemLogService.WriteAsync(
|
||||
result.Success ? Domain.Enums.SystemLogLevel.Info : Domain.Enums.SystemLogLevel.Warning,
|
||||
"Webhook",
|
||||
result.Success
|
||||
? "Webhook test completed successfully."
|
||||
: "Webhook test failed.",
|
||||
result.Detail,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var detail = await SendInternalAsync(
|
||||
settings,
|
||||
payload,
|
||||
variables,
|
||||
cancellationToken,
|
||||
swallowErrors: false);
|
||||
return new WebhookTestResultDto
|
||||
{
|
||||
Success = result.Success,
|
||||
Message = result.Success
|
||||
? "Webhook test completed successfully."
|
||||
: "Webhook test failed.",
|
||||
Detail = result.Detail
|
||||
Success = true,
|
||||
Message = "Webhook test sent successfully.",
|
||||
Detail = detail
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Webhook test failed");
|
||||
await _systemLogService.WriteAsync(
|
||||
Domain.Enums.SystemLogLevel.Warning,
|
||||
"Webhook",
|
||||
"Webhook test failed.",
|
||||
ex.ToString(),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return new WebhookTestResultDto
|
||||
{
|
||||
Success = false,
|
||||
Message = "Webhook test failed.",
|
||||
Detail = ex.Message
|
||||
Message = $"Webhook test failed: {ex.Message}",
|
||||
Detail = ex.InnerException?.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendConfiguredWebhookAsync(
|
||||
private async Task<string> SendInternalAsync(
|
||||
SystemSettingsDto settings,
|
||||
object payload,
|
||||
string successMessage,
|
||||
string failureMessage,
|
||||
Guid? liveRoomId,
|
||||
Guid? recordSessionId,
|
||||
Guid? recordTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await SendInternalAsync(settings, payload, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
await _systemLogService.WriteAsync(
|
||||
Domain.Enums.SystemLogLevel.Warning,
|
||||
"Webhook",
|
||||
failureMessage,
|
||||
result.Detail,
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
Domain.Enums.SystemLogLevel.Info,
|
||||
"Webhook",
|
||||
successMessage,
|
||||
result.Detail,
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{FailureMessage}", failureMessage);
|
||||
await _systemLogService.WriteAsync(
|
||||
Domain.Enums.SystemLogLevel.Warning,
|
||||
"Webhook",
|
||||
failureMessage,
|
||||
ex.ToString(),
|
||||
liveRoomId,
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<WebhookSendResult> SendInternalAsync(
|
||||
SystemSettingsDto settings,
|
||||
object payload,
|
||||
CancellationToken cancellationToken)
|
||||
Dictionary<string, object?> payload,
|
||||
IReadOnlyDictionary<string, object?> variables,
|
||||
CancellationToken cancellationToken,
|
||||
bool swallowErrors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.WebhookUrl))
|
||||
{
|
||||
throw new InvalidOperationException("Webhook URL is required.");
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(settings.WebhookTimeoutSeconds, 1, 300));
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, settings.WebhookUrl.Trim())
|
||||
{
|
||||
Content = JsonContent.Create(payload)
|
||||
};
|
||||
|
||||
foreach (var header in ParseHeaders(settings.WebhookHeaders))
|
||||
{
|
||||
if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
if (swallowErrors)
|
||||
{
|
||||
request.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
return "Webhook URL is empty.";
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Webhook URL is empty.");
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var detail = BuildResponseDetail(settings.WebhookUrl, response, responseBody);
|
||||
return new WebhookSendResult(response.IsSuccessStatusCode, detail);
|
||||
}
|
||||
|
||||
private static object BuildPayload(
|
||||
string eventType,
|
||||
string summary,
|
||||
string? detail,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
string? source = null)
|
||||
{
|
||||
return new
|
||||
try
|
||||
{
|
||||
appName = AppName,
|
||||
eventType,
|
||||
sentAtUtc = DateTimeOffset.UtcNow,
|
||||
summary,
|
||||
detail,
|
||||
source,
|
||||
liveRoom = liveRoom is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
id = liveRoom.Id,
|
||||
platform = liveRoom.Platform.ToString(),
|
||||
roomId = liveRoom.RoomId,
|
||||
title = liveRoom.Title,
|
||||
anchorName = liveRoom.AnchorName,
|
||||
sourceUrl = liveRoom.SourceUrl
|
||||
},
|
||||
recordTask = recordTask is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
id = recordTask.Id,
|
||||
recordSessionId = recordTask.RecordSessionId,
|
||||
status = recordTask.Status.ToString(),
|
||||
segmentIndex = recordTask.SegmentIndex,
|
||||
outputFilePath = recordTask.OutputFilePath
|
||||
}
|
||||
};
|
||||
var body = BuildRequestBody(settings.WebhookBodyTemplate, payload, variables);
|
||||
using var httpClient = CreateHttpClient(settings.WebhookTimeoutSeconds);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, settings.WebhookUrl.Trim())
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
ApplyHeaders(request.Headers, settings.WebhookHeaders);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Webhook returned {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(responseBody, 600)}");
|
||||
}
|
||||
|
||||
return $"status={(int)response.StatusCode}; body={Truncate(responseBody, 600)}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (swallowErrors)
|
||||
{
|
||||
_logger.LogWarning(ex, "Webhook send failed");
|
||||
return ex.Message;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KeyValuePair<string, string>> ParseHeaders(string rawHeaders)
|
||||
private static HttpClient CreateHttpClient(int timeoutSeconds)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 120));
|
||||
return client;
|
||||
}
|
||||
|
||||
private static void ApplyHeaders(HttpRequestHeaders headers, string rawHeaders)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawHeaders))
|
||||
{
|
||||
return [];
|
||||
return;
|
||||
}
|
||||
|
||||
var results = new List<KeyValuePair<string, string>>();
|
||||
var lines = rawHeaders
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var lines = rawHeaders.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var separatorIndex = line.IndexOf(':');
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid webhook header format: {line}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = line[..separatorIndex].Trim();
|
||||
var value = line[(separatorIndex + 1)..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid webhook header format: {line}");
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(new KeyValuePair<string, string>(name, value));
|
||||
headers.TryAddWithoutValidation(name, value);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static string BuildResponseDetail(string webhookUrl, HttpResponseMessage response, string responseBody)
|
||||
private static string BuildRequestBody(
|
||||
string? template,
|
||||
IReadOnlyDictionary<string, object?> payload,
|
||||
IReadOnlyDictionary<string, object?> variables)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("url=").Append(webhookUrl.Trim());
|
||||
builder.Append("; status=").Append((int)response.StatusCode);
|
||||
builder.Append(' ').Append(response.ReasonPhrase);
|
||||
|
||||
var normalizedBody = responseBody.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedBody))
|
||||
if (string.IsNullOrWhiteSpace(template))
|
||||
{
|
||||
var truncatedBody = normalizedBody.Length <= 1000 ? normalizedBody : normalizedBody[..1000];
|
||||
builder.Append("; body=").Append(truncatedBody);
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
var rendered = WholeValueTemplateRegex.Replace(
|
||||
template,
|
||||
match =>
|
||||
{
|
||||
var name = match.Groups["name"].Value;
|
||||
variables.TryGetValue(name, out var value);
|
||||
return JsonSerializer.Serialize(value);
|
||||
});
|
||||
|
||||
rendered = TokenRegex.Replace(
|
||||
rendered,
|
||||
match =>
|
||||
{
|
||||
var name = match.Groups["name"].Value;
|
||||
variables.TryGetValue(name, out var value);
|
||||
return EscapeTemplateStringValue(value);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var jsonDocument = JsonDocument.Parse(rendered);
|
||||
return jsonDocument.RootElement.GetRawText();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Webhook body template must produce valid JSON. {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record WebhookSendResult(bool Success, string Detail);
|
||||
private static string EscapeTemplateStringValue(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (value is string text)
|
||||
{
|
||||
return JsonEncodedText.Encode(text).ToString();
|
||||
}
|
||||
|
||||
if (value is DateTimeOffset dateTimeOffset)
|
||||
{
|
||||
return JsonEncodedText.Encode(dateTimeOffset.ToString("O")).ToString();
|
||||
}
|
||||
|
||||
return JsonEncodedText.Encode(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty).ToString();
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildDefaultPayload(
|
||||
string eventType,
|
||||
string summary,
|
||||
string? detail,
|
||||
string source,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
DailyReviewReportDto? report)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["appName"] = AppName,
|
||||
["eventType"] = eventType,
|
||||
["sentAtUtc"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["summary"] = summary,
|
||||
["detail"] = detail,
|
||||
["source"] = source,
|
||||
["liveRoom"] = liveRoom is null ? null : new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = liveRoom.Id,
|
||||
["platform"] = liveRoom.Platform.ToString(),
|
||||
["roomId"] = liveRoom.RoomId,
|
||||
["title"] = liveRoom.Title,
|
||||
["anchorName"] = liveRoom.AnchorName,
|
||||
["sourceUrl"] = liveRoom.SourceUrl
|
||||
},
|
||||
["recordTask"] = recordTask is null ? null : new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = recordTask.Id,
|
||||
["recordSessionId"] = recordTask.RecordSessionId,
|
||||
["status"] = recordTask.Status.ToString(),
|
||||
["segmentIndex"] = recordTask.SegmentIndex,
|
||||
["outputFilePath"] = recordTask.OutputFilePath
|
||||
}
|
||||
};
|
||||
|
||||
if (report is not null)
|
||||
{
|
||||
payload["report"] = new Dictionary<string, object?>
|
||||
{
|
||||
["date"] = report.Date,
|
||||
["summary"] = new Dictionary<string, object?>
|
||||
{
|
||||
["activeLiveRoomCount"] = report.Summary.ActiveLiveRoomCount,
|
||||
["sessionCount"] = report.Summary.SessionCount,
|
||||
["segmentCount"] = report.Summary.SegmentCount,
|
||||
["totalDurationSeconds"] = report.Summary.TotalDurationSeconds,
|
||||
["warningCount"] = report.Summary.WarningCount,
|
||||
["errorCount"] = report.Summary.ErrorCount,
|
||||
["totalDanmakuCount"] = report.Summary.TotalDanmakuCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> BuildTemplateVariables(
|
||||
IReadOnlyDictionary<string, object?> payload,
|
||||
DailyReviewReportDto? report)
|
||||
{
|
||||
var variables = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["appName"] = payload["appName"],
|
||||
["eventType"] = payload["eventType"],
|
||||
["sentAtUtc"] = payload["sentAtUtc"],
|
||||
["summary"] = payload["summary"],
|
||||
["detail"] = payload["detail"],
|
||||
["source"] = payload["source"]
|
||||
};
|
||||
|
||||
if (payload.TryGetValue("liveRoom", out var liveRoomPayload) &&
|
||||
liveRoomPayload is IReadOnlyDictionary<string, object?> liveRoom)
|
||||
{
|
||||
foreach (var pair in liveRoom)
|
||||
{
|
||||
variables[$"liveRoom.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.TryGetValue("recordTask", out var recordTaskPayload) &&
|
||||
recordTaskPayload is IReadOnlyDictionary<string, object?> recordTask)
|
||||
{
|
||||
foreach (var pair in recordTask)
|
||||
{
|
||||
variables[$"recordTask.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (report is not null)
|
||||
{
|
||||
variables["report.date"] = report.Date;
|
||||
variables["report.summary.activeLiveRoomCount"] = report.Summary.ActiveLiveRoomCount;
|
||||
variables["report.summary.sessionCount"] = report.Summary.SessionCount;
|
||||
variables["report.summary.segmentCount"] = report.Summary.SegmentCount;
|
||||
variables["report.summary.totalDurationSeconds"] = report.Summary.TotalDurationSeconds;
|
||||
variables["report.summary.warningCount"] = report.Summary.WarningCount;
|
||||
variables["report.summary.errorCount"] = report.Summary.ErrorCount;
|
||||
variables["report.summary.totalDanmakuCount"] = report.Summary.TotalDanmakuCount;
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
private static string Truncate(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length <= maxLength ? trimmed : $"{trimmed[..maxLength]}...";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user