feat: add transcode workspace and media browser

This commit is contained in:
2026-05-05 02:14:52 +08:00
parent f0f9ed3456
commit 56432a9ece
26 changed files with 2013 additions and 267 deletions
@@ -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]}...";
}
}