diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index eec6fa3..0194a03 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -10,6 +10,7 @@ import { VideoCamera, Document, Tickets, + FolderOpened, RefreshRight, Setting, SwitchButton, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bcbc306..25a833e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,8 +5,10 @@ const LoginView = () => import("@/views/LoginView.vue"); const MainLayout = () => import("@/components/layout/MainLayout.vue"); const LiveRoomsView = () => import("@/views/LiveRoomsView.vue"); const RecordTasksView = () => import("@/views/RecordTasksView.vue"); +const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue"); const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue"); const RecordSessionDetailView = () => import("@/views/RecordSessionDetailView.vue"); +const MediaBrowserView = () => import("@/views/MediaBrowserView.vue"); const DailyReviewsView = () => import("@/views/DailyReviewsView.vue"); const LogsView = () => import("@/views/LogsView.vue"); const RecoveryView = () => import("@/views/RecoveryView.vue"); @@ -39,6 +41,16 @@ const router = createRouter({ name: "record-tasks", component: RecordTasksView }, + { + path: "transcode-tasks", + name: "transcode-tasks", + component: TranscodeTasksView + }, + { + path: "media-browser", + name: "media-browser", + component: MediaBrowserView + }, { path: "record-tasks/:id", name: "record-task-detail", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 73198de..77c7cfc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -351,6 +351,23 @@ export interface DailyReviewMoment { danmakuCount: number; } +export interface PushDailyReviewRequest { + date?: string | null; + utcOffsetMinutes: number; + channels: string[]; +} + +export interface DailyReviewPushChannelResult { + channel: string; + success: boolean; + message: string; +} + +export interface DailyReviewPushResult { + date: string; + results: DailyReviewPushChannelResult[]; +} + export interface SystemSettings { ffmpegPath: string; outputRoot: string; @@ -424,6 +441,7 @@ export interface SystemSettings { webhookTimeoutSeconds: number; notifyWebhookOnLiveStarted: boolean; notifyWebhookOnException: boolean; + webhookBodyTemplate: string; douyinUserAgent: string; douyinReferer: string; douyinCookie: string; @@ -524,6 +542,42 @@ export interface RecoveryActionResult { messages: string[]; } +export interface TranscodeTaskItem { + task: RecordTask; + result?: RecordResult; + sourceFilePath?: string; + canManualTranscode: boolean; +} + +export interface MediaBrowserBreadcrumb { + label: string; + relativePath: string; +} + +export interface MediaBrowserItem { + name: string; + relativePath: string; + type: string; + sizeBytes?: number; + modifiedAt?: string; + canTranscode: boolean; + canPreview: boolean; +} + +export interface MediaBrowserResponse { + currentPath: string; + parentPath?: string | null; + breadcrumbs: MediaBrowserBreadcrumb[]; + items: MediaBrowserItem[]; +} + +export interface TranscodeMediaFileResult { + success: boolean; + message: string; + sourcePath?: string; + outputPath?: string; +} + export const availabilityLabelMap: Record = { 0: "未知", 1: "未开播", @@ -579,11 +633,18 @@ export const qualityLabelMap: Record = { SD: "标清" }; +export const qualityDisplayLabelMap: Record = { + origin: "原画", + FULL_HD: "超清", + HD: "高清", + SD: "标清" +}; + export const qualityOptionList = [ - { value: "origin", label: qualityLabelMap.origin }, - { value: "FULL_HD", label: qualityLabelMap.FULL_HD }, - { value: "HD", label: qualityLabelMap.HD }, - { value: "SD", label: qualityLabelMap.SD } + { value: "origin", label: qualityDisplayLabelMap.origin }, + { value: "FULL_HD", label: qualityDisplayLabelMap.FULL_HD }, + { value: "HD", label: qualityDisplayLabelMap.HD }, + { value: "SD", label: qualityDisplayLabelMap.SD } ] as const; export function formatQualityLabel(value?: string | null) { @@ -591,7 +652,7 @@ export function formatQualityLabel(value?: string | null) { return "-"; } - return qualityLabelMap[value] ?? value; + return qualityDisplayLabelMap[value] ?? value; } export const autoStartDecisionLabelMap: Record = { diff --git a/frontend/src/views/DailyReviewsView.vue b/frontend/src/views/DailyReviewsView.vue index 8bcd3c4..a50aa14 100644 --- a/frontend/src/views/DailyReviewsView.vue +++ b/frontend/src/views/DailyReviewsView.vue @@ -1,22 +1,31 @@ - + + + + diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue index 8dc02f1..d81b898 100644 --- a/frontend/src/views/RecordTasksView.vue +++ b/frontend/src/views/RecordTasksView.vue @@ -588,6 +588,8 @@ onBeforeUnmount(() => {
+ 转码任务 + 录制目录 刷新列表 ({ webhookTimeoutSeconds: 15, notifyWebhookOnLiveStarted: true, notifyWebhookOnException: true, + webhookBodyTemplate: "", douyinUserAgent: "", douyinReferer: "https://live.douyin.com/", douyinCookie: "" @@ -217,6 +218,34 @@ const emailTemplateTokens = [ "{{occurredAtUtc}}" ]; +const webhookTemplateTokens = [ + "{{appName}}", + "{{eventType}}", + "{{sentAtUtc}}", + "{{summary}}", + "{{detail}}", + "{{source}}", + "{{liveRoom.id}}", + "{{liveRoom.platform}}", + "{{liveRoom.roomId}}", + "{{liveRoom.title}}", + "{{liveRoom.anchorName}}", + "{{liveRoom.sourceUrl}}", + "{{recordTask.id}}", + "{{recordTask.recordSessionId}}", + "{{recordTask.status}}", + "{{recordTask.segmentIndex}}", + "{{recordTask.outputFilePath}}", + "{{report.date}}", + "{{report.summary.activeLiveRoomCount}}", + "{{report.summary.sessionCount}}", + "{{report.summary.segmentCount}}", + "{{report.summary.totalDurationSeconds}}", + "{{report.summary.warningCount}}", + "{{report.summary.errorCount}}", + "{{report.summary.totalDanmakuCount}}" +]; + const eventScriptEnvironmentExamples = [ { name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "全部事件" }, { name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "全部事件" }, @@ -404,7 +433,8 @@ async function testWebhook() { const { data } = await apiClient.post("/settings/test-webhook", { webhookUrl: form.webhookUrl, webhookHeaders: form.webhookHeaders, - webhookTimeoutSeconds: form.webhookTimeoutSeconds + webhookTimeoutSeconds: form.webhookTimeoutSeconds, + webhookBodyTemplate: form.webhookBodyTemplate }); webhookTestResult.value = data; ElMessage[data.success ? "success" : "warning"](data.message); @@ -1331,8 +1361,24 @@ onMounted(loadSettings); /> + + + + + +
+
Webhook 变量
+
+ {{ token }} +
+
测试会发送一份样例 live_started 负载,直接使用当前表单里的 URL、请求头和超时配置。
diff --git a/frontend/src/views/TranscodeTasksView.vue b/frontend/src/views/TranscodeTasksView.vue new file mode 100644 index 0000000..471b2c7 --- /dev/null +++ b/frontend/src/views/TranscodeTasksView.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs b/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs index d834d45..872dd9d 100644 --- a/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs +++ b/src/LiveRecorder.Application/Abstractions/Notifications/IEmailNotificationService.cs @@ -1,5 +1,6 @@ using LiveRecorder.Domain.Entities; using LiveRecorder.Application.Models.Settings; +using LiveRecorder.Application.Models.Reports; namespace LiveRecorder.Application.Abstractions.Notifications; @@ -16,4 +17,6 @@ public interface IEmailNotificationService CancellationToken cancellationToken = default); Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default); + + Task SendDailyReviewAsync(DailyReviewReportDto report, CancellationToken cancellationToken = default); } diff --git a/src/LiveRecorder.Application/Abstractions/Notifications/IWebhookNotificationService.cs b/src/LiveRecorder.Application/Abstractions/Notifications/IWebhookNotificationService.cs index 60015fb..16ba6b4 100644 --- a/src/LiveRecorder.Application/Abstractions/Notifications/IWebhookNotificationService.cs +++ b/src/LiveRecorder.Application/Abstractions/Notifications/IWebhookNotificationService.cs @@ -1,4 +1,5 @@ using LiveRecorder.Application.Models.Settings; +using LiveRecorder.Application.Models.Reports; using LiveRecorder.Domain.Entities; namespace LiveRecorder.Application.Abstractions.Notifications; @@ -18,4 +19,8 @@ public interface IWebhookNotificationService Task SendTestAsync( SendTestWebhookRequest request, CancellationToken cancellationToken = default); + + Task SendDailyReviewAsync( + DailyReviewReportDto report, + CancellationToken cancellationToken = default); } diff --git a/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs b/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs index d94ba23..2a5ac88 100644 --- a/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs +++ b/src/LiveRecorder.Application/Abstractions/Recording/IFfmpegService.cs @@ -32,6 +32,10 @@ public interface IFfmpegService Task StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default); + Task StartManualFinalizeFileAsync( + string sourceFilePath, + CancellationToken cancellationToken = default); + Task ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default); bool IsRunning(Guid recordSessionId); diff --git a/src/LiveRecorder.Application/Models/Media/MediaBrowserModels.cs b/src/LiveRecorder.Application/Models/Media/MediaBrowserModels.cs new file mode 100644 index 0000000..82d9a82 --- /dev/null +++ b/src/LiveRecorder.Application/Models/Media/MediaBrowserModels.cs @@ -0,0 +1,52 @@ +namespace LiveRecorder.Application.Models.Media; + +public sealed class MediaBrowserBreadcrumbDto +{ + public required string Label { get; init; } + + public string RelativePath { get; init; } = string.Empty; +} + +public sealed class MediaBrowserItemDto +{ + public required string Name { get; init; } + + public required string RelativePath { get; init; } + + public required string Type { get; init; } + + public long? SizeBytes { get; init; } + + public DateTimeOffset? ModifiedAt { get; init; } + + public bool CanTranscode { get; init; } + + public bool CanPreview { get; init; } +} + +public sealed class MediaBrowserResponseDto +{ + public required string CurrentPath { get; init; } + + public string? ParentPath { get; init; } + + public required IReadOnlyList Breadcrumbs { get; init; } + + public required IReadOnlyList Items { get; init; } +} + +public sealed class TranscodeMediaFileRequest +{ + public string RelativePath { get; set; } = string.Empty; +} + +public sealed class TranscodeMediaFileResultDto +{ + public bool Success { get; init; } + + public required string Message { get; init; } + + public string? SourcePath { get; init; } + + public string? OutputPath { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/RecordTasks/TranscodeTaskModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/TranscodeTaskModels.cs new file mode 100644 index 0000000..2718715 --- /dev/null +++ b/src/LiveRecorder.Application/Models/RecordTasks/TranscodeTaskModels.cs @@ -0,0 +1,12 @@ +namespace LiveRecorder.Application.Models.RecordTasks; + +public sealed class TranscodeTaskItemDto +{ + public required RecordTaskDto Task { get; init; } + + public RecordResultDto? Result { get; init; } + + public string? SourceFilePath { get; init; } + + public bool CanManualTranscode { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/Reports/DailyReviewModels.cs b/src/LiveRecorder.Application/Models/Reports/DailyReviewModels.cs index f40c18b..217539b 100644 --- a/src/LiveRecorder.Application/Models/Reports/DailyReviewModels.cs +++ b/src/LiveRecorder.Application/Models/Reports/DailyReviewModels.cs @@ -124,3 +124,30 @@ public sealed class DailyReviewMomentDto public int DanmakuCount { get; init; } } + +public sealed class PushDailyReviewRequest +{ + public string? Date { get; set; } + + public int UtcOffsetMinutes { get; set; } + + public IReadOnlyList Channels { get; set; } = []; +} + +public sealed class DailyReviewPushChannelResultDto +{ + public required string Channel { get; init; } + + public bool Success { get; init; } + + public required string Message { get; init; } + + public string? Detail { get; init; } +} + +public sealed class DailyReviewPushResultDto +{ + public required string Date { get; init; } + + public required IReadOnlyList Results { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs index c2ffa89..823f442 100644 --- a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs +++ b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs @@ -211,6 +211,8 @@ public sealed class SystemSettingsDto public string WebhookHeaders { get; set; } = string.Empty; + public string WebhookBodyTemplate { get; set; } = string.Empty; + public int WebhookTimeoutSeconds { get; set; } = 15; public bool NotifyWebhookOnLiveStarted { get; set; } = true; @@ -392,6 +394,8 @@ public sealed class UpdateSystemSettingsRequest public string WebhookHeaders { get; set; } = string.Empty; + public string WebhookBodyTemplate { get; set; } = string.Empty; + public int WebhookTimeoutSeconds { get; set; } = 15; public bool NotifyWebhookOnLiveStarted { get; set; } = true; @@ -490,6 +494,8 @@ public sealed class SendTestWebhookRequest public string WebhookHeaders { get; set; } = string.Empty; + public string WebhookBodyTemplate { get; set; } = string.Empty; + public int WebhookTimeoutSeconds { get; set; } = 15; } diff --git a/src/LiveRecorder.Application/Services/MediaBrowserService.cs b/src/LiveRecorder.Application/Services/MediaBrowserService.cs new file mode 100644 index 0000000..ed620cf --- /dev/null +++ b/src/LiveRecorder.Application/Services/MediaBrowserService.cs @@ -0,0 +1,221 @@ +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Abstractions.Settings; +using LiveRecorder.Application.Models.Media; + +namespace LiveRecorder.Application.Services; + +public sealed class MediaBrowserService +{ + private static readonly string[] PreviewableExtensions = [".mp4"]; + private static readonly string[] TranscodableExtensions = [".ts"]; + + private readonly ISystemSettingsService _systemSettingsService; + private readonly IFfmpegService _ffmpegService; + + public MediaBrowserService( + ISystemSettingsService systemSettingsService, + IFfmpegService ffmpegService) + { + _systemSettingsService = systemSettingsService; + _ffmpegService = ffmpegService; + } + + public async Task BrowseAsync(string? relativePath, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var rootPath = ResolveOutputRoot(settings.OutputRoot); + var normalizedRelativePath = NormalizeRelativePath(relativePath); + var targetPath = ResolveScopedPath(rootPath, normalizedRelativePath); + + if (!Directory.Exists(targetPath)) + { + throw new DirectoryNotFoundException("The requested media directory does not exist."); + } + + var directories = Directory + .EnumerateDirectories(targetPath) + .Select(directoryPath => + { + var info = new DirectoryInfo(directoryPath); + return new MediaBrowserItemDto + { + Name = info.Name, + RelativePath = Path.GetRelativePath(rootPath, info.FullName).Replace('\\', '/'), + Type = "directory", + ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue + ? null + : new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero), + CanPreview = false, + CanTranscode = false + }; + }); + + var files = Directory + .EnumerateFiles(targetPath) + .Select(filePath => + { + var info = new FileInfo(filePath); + var extension = info.Extension.ToLowerInvariant(); + return new MediaBrowserItemDto + { + Name = info.Name, + RelativePath = Path.GetRelativePath(rootPath, info.FullName).Replace('\\', '/'), + Type = ResolveItemType(extension), + SizeBytes = info.Length, + ModifiedAt = info.LastWriteTimeUtc == DateTime.MinValue + ? null + : new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero), + CanPreview = PreviewableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase), + CanTranscode = TranscodableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) + }; + }); + + var items = directories + .Concat(files) + .OrderBy(static item => item.Type != "directory") + .ThenBy(static item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new MediaBrowserResponseDto + { + CurrentPath = normalizedRelativePath, + ParentPath = string.IsNullOrWhiteSpace(normalizedRelativePath) + ? null + : GetParentRelativePath(normalizedRelativePath), + Breadcrumbs = BuildBreadcrumbs(normalizedRelativePath), + Items = items + }; + } + + public async Task TranscodeFileAsync( + TranscodeMediaFileRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var rootPath = ResolveOutputRoot(settings.OutputRoot); + var normalizedRelativePath = NormalizeRelativePath(request.RelativePath); + var sourcePath = ResolveScopedPath(rootPath, normalizedRelativePath); + + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException("The selected .ts file does not exist.", normalizedRelativePath); + } + + if (!sourcePath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Only .ts files can be transcoded from the media browser."); + } + + return await _ffmpegService.StartManualFinalizeFileAsync(sourcePath, cancellationToken); + } + + public async Task ResolveFilePathAsync(string? relativePath, CancellationToken cancellationToken = default) + { + var settings = await _systemSettingsService.GetAsync(cancellationToken); + var rootPath = ResolveOutputRoot(settings.OutputRoot); + var normalizedRelativePath = NormalizeRelativePath(relativePath); + var filePath = ResolveScopedPath(rootPath, normalizedRelativePath); + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException("The requested file does not exist.", normalizedRelativePath); + } + + return filePath; + } + + internal static string ResolveOutputRoot(string outputRoot) => + Path.IsPathRooted(outputRoot) + ? outputRoot + : Path.GetFullPath(outputRoot, AppContext.BaseDirectory); + + internal static string NormalizeRelativePath(string? relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return string.Empty; + } + + return relativePath + .Replace('\\', '/') + .Trim('/') + .Trim(); + } + + internal static string ResolveScopedPath(string rootPath, string relativePath) + { + var combinedPath = string.IsNullOrWhiteSpace(relativePath) + ? rootPath + : Path.GetFullPath(Path.Combine(rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar))); + + var normalizedRoot = Path.GetFullPath(rootPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedCombined = Path.GetFullPath(combinedPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + var isRoot = string.Equals(normalizedCombined, normalizedRoot, StringComparison.OrdinalIgnoreCase); + var isChild = normalizedCombined.StartsWith( + normalizedRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + if (!isRoot && !isChild) + { + throw new InvalidOperationException("The requested path is outside the recording output root."); + } + + return normalizedCombined; + } + + private static string ResolveItemType(string extension) => + extension switch + { + ".mp4" => "mp4", + ".ts" => "ts", + ".xml" => "xml", + _ => "other" + }; + + private static string? GetParentRelativePath(string relativePath) + { + var parts = relativePath + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length <= 1) + { + return string.Empty; + } + + return string.Join('/', parts.Take(parts.Length - 1)); + } + + private static IReadOnlyList BuildBreadcrumbs(string relativePath) + { + var breadcrumbs = new List + { + new() + { + Label = "平台目录", + RelativePath = string.Empty + } + }; + + if (string.IsNullOrWhiteSpace(relativePath)) + { + return breadcrumbs; + } + + var current = string.Empty; + foreach (var part in relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + current = string.IsNullOrWhiteSpace(current) ? part : $"{current}/{part}"; + breadcrumbs.Add(new MediaBrowserBreadcrumbDto + { + Label = part, + RelativePath = current + }); + } + + return breadcrumbs; + } +} diff --git a/src/LiveRecorder.Application/Services/SystemSettingsService.cs b/src/LiveRecorder.Application/Services/SystemSettingsService.cs index dfb3898..a9a5f3c 100644 --- a/src/LiveRecorder.Application/Services/SystemSettingsService.cs +++ b/src/LiveRecorder.Application/Services/SystemSettingsService.cs @@ -89,6 +89,7 @@ public sealed class SystemSettingsService : ISystemSettingsService private const string EnableWebhookNotificationKey = "notification.webhook.enabled"; private const string WebhookUrlKey = "notification.webhook.url"; private const string WebhookHeadersKey = "notification.webhook.headers"; + private const string WebhookBodyTemplateKey = "notification.webhook.body_template"; private const string WebhookTimeoutSecondsKey = "notification.webhook.timeout_seconds"; private const string NotifyWebhookOnLiveStartedKey = "notification.webhook.notify_live_started"; private const string NotifyWebhookOnExceptionKey = "notification.webhook.notify_exception"; @@ -264,6 +265,7 @@ public sealed class SystemSettingsService : ISystemSettingsService EnableWebhookNotification = bool.TryParse(GetValue(lookup, EnableWebhookNotificationKey, "false"), out var enableWebhookNotification) && enableWebhookNotification, WebhookUrl = GetValue(lookup, WebhookUrlKey, string.Empty), WebhookHeaders = GetValue(lookup, WebhookHeadersKey, string.Empty), + WebhookBodyTemplate = GetValue(lookup, WebhookBodyTemplateKey, string.Empty), WebhookTimeoutSeconds = GetIntValue(lookup, WebhookTimeoutSecondsKey, 15, 1, 300), NotifyWebhookOnLiveStarted = bool.TryParse(GetValue(lookup, NotifyWebhookOnLiveStartedKey, "true"), out var notifyWebhookOnLiveStarted) && notifyWebhookOnLiveStarted, NotifyWebhookOnException = bool.TryParse(GetValue(lookup, NotifyWebhookOnExceptionKey, "true"), out var notifyWebhookOnException) && notifyWebhookOnException, @@ -384,6 +386,7 @@ public sealed class SystemSettingsService : ISystemSettingsService await UpsertAsync(EnableWebhookNotificationKey, request.EnableWebhookNotification.ToString(), now, cancellationToken); await UpsertAsync(WebhookUrlKey, request.WebhookUrl.Trim(), now, cancellationToken); await UpsertAsync(WebhookHeadersKey, request.WebhookHeaders, now, cancellationToken); + await UpsertAsync(WebhookBodyTemplateKey, request.WebhookBodyTemplate, now, cancellationToken); await UpsertAsync(WebhookTimeoutSecondsKey, Math.Clamp(request.WebhookTimeoutSeconds, 1, 300).ToString(), now, cancellationToken); await UpsertAsync(NotifyWebhookOnLiveStartedKey, request.NotifyWebhookOnLiveStarted.ToString(), now, cancellationToken); await UpsertAsync(NotifyWebhookOnExceptionKey, request.NotifyWebhookOnException.ToString(), now, cancellationToken); diff --git a/src/LiveRecorder.Application/Services/TranscodeTaskService.cs b/src/LiveRecorder.Application/Services/TranscodeTaskService.cs new file mode 100644 index 0000000..77c963d --- /dev/null +++ b/src/LiveRecorder.Application/Services/TranscodeTaskService.cs @@ -0,0 +1,134 @@ +using LiveRecorder.Application.Abstractions.Persistence; +using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; + +namespace LiveRecorder.Application.Services; + +public sealed class TranscodeTaskService +{ + private readonly IRecordTaskRepository _recordTaskRepository; + private readonly IFfmpegService _ffmpegService; + + public TranscodeTaskService( + IRecordTaskRepository recordTaskRepository, + IFfmpegService ffmpegService) + { + _recordTaskRepository = recordTaskRepository; + _ffmpegService = ffmpegService; + } + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var tasks = await _recordTaskRepository.ListAsync(null, cancellationToken); + if (tasks.Count == 0) + { + return []; + } + + var runtimeStates = _ffmpegService.GetTaskRuntimeStates(tasks.Select(static item => item.Id).ToArray()); + + return tasks + .Select(task => + { + runtimeStates.TryGetValue(task.Id, out var runtimeState); + var mappedTask = RecordModelMapper.MapTask(task, runtimeState); + var sourceFilePath = ResolveManualTranscodeSourcePath(task); + var canManualTranscode = CanManuallyTranscode(task, runtimeState, sourceFilePath); + + return new TranscodeTaskItemDto + { + Task = mappedTask, + Result = task.Result is null ? null : RecordModelMapper.MapResult(task.Result), + SourceFilePath = sourceFilePath, + CanManualTranscode = canManualTranscode + }; + }) + .Where(static item => ShouldInclude(item)) + .OrderByDescending(static item => item.Task.CreatedAt) + .ToArray(); + } + + private static bool ShouldInclude(TranscodeTaskItemDto item) + { + if (!string.IsNullOrWhiteSpace(item.Task.PostProcessStage)) + { + return true; + } + + if (item.CanManualTranscode) + { + return true; + } + + return item.Task.OutputFormat == RecordOutputFormat.Mp4 && + item.Task.Status == RecordTaskStatus.Processing; + } + + private static bool CanManuallyTranscode( + RecordTask recordTask, + RecordTaskRuntimeState? runtimeState, + string? sourceFilePath) + { + if (recordTask.OutputFormat != RecordOutputFormat.Mp4 || + runtimeState is not null || + IsActiveStatus(recordTask.Status)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(sourceFilePath) && File.Exists(sourceFilePath)) + { + return true; + } + + var resultPath = recordTask.Result?.FilePath?.ToLowerInvariant() ?? string.Empty; + var errorText = $"{recordTask.ErrorMessage ?? string.Empty} {recordTask.Result?.ErrorMessage ?? string.Empty}".ToLowerInvariant(); + return resultPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase) || + errorText.Contains("finaliz", StringComparison.Ordinal) || + errorText.Contains("intermediate ts", StringComparison.Ordinal); + } + + private static string? ResolveManualTranscodeSourcePath(RecordTask recordTask) + { + if (!string.IsNullOrWhiteSpace(recordTask.Result?.FilePath) && + recordTask.Result.FilePath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) + { + return recordTask.Result.FilePath; + } + + if (!string.IsNullOrWhiteSpace(recordTask.OutputFilePath)) + { + var normalizedOutput = NormalizeAbsolutePath(recordTask.OutputFilePath); + if (normalizedOutput.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) + { + return normalizedOutput; + } + + var candidate = Path.ChangeExtension(normalizedOutput, ".ts"); + if (File.Exists(candidate)) + { + return candidate; + } + + var singleFileCandidate = Path.Combine( + Path.GetDirectoryName(normalizedOutput) ?? string.Empty, + $"{Path.GetFileNameWithoutExtension(normalizedOutput)}.recording.ts"); + if (File.Exists(singleFileCandidate)) + { + return singleFileCandidate; + } + } + + return null; + } + + private static string NormalizeAbsolutePath(string path) => + Path.IsPathRooted(path) + ? path + : Path.GetFullPath(path, AppContext.BaseDirectory); + + private static bool IsActiveStatus(RecordTaskStatus status) => + status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping or RecordTaskStatus.Processing; +} diff --git a/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs b/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs index 0992a4a..bd19ef4 100644 --- a/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs +++ b/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs @@ -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 + ? "
  • No live rooms recorded for this day.
  • " + : string.Join( + string.Empty, + report.Rooms + .OrderByDescending(static item => item.TotalDurationSeconds) + .Take(8) + .Select(item => + $"
  • {WebUtility.HtmlEncode(item.AnchorName ?? item.LiveRoomTitleFallback())} ({WebUtility.HtmlEncode(item.PlatformName)} / {WebUtility.HtmlEncode(item.RoomId)}) - sessions {item.SessionCount}, segments {item.SegmentCount}, duration {summaryDuration(item.TotalDurationSeconds)}, danmaku {item.DanmakuCount}
  • ")); + + var body = $$""" +
    +

    Daily review

    +

    Date: {{WebUtility.HtmlEncode(report.Date)}}

    +
      +
    • Active live rooms: {{summary.ActiveLiveRoomCount}}
    • +
    • Sessions: {{summary.SessionCount}}
    • +
    • Segments: {{summary.SegmentCount}}
    • +
    • Total duration: {{summaryDuration(summary.TotalDurationSeconds)}}
    • +
    • Warnings / Errors: {{summary.WarningCount}} / {{summary.ErrorCount}}
    • +
    • Total danmaku: {{summary.TotalDanmakuCount}}
    • +
    +

    Top live rooms

    +
      + {{roomsMarkup}} +
    +
    +"""; + + 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!; } diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs index c3d94d3..a2f1e45 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs @@ -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 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(); + 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(); + + 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(); + 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 ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default) { using var scope = _serviceScopeFactory.CreateScope(); diff --git a/src/LiveRecorder.Infrastructure/Services/WebhookNotificationService.cs b/src/LiveRecorder.Infrastructure/Services/WebhookNotificationService.cs index 48ded2f..07ea588 100644 --- a/src/LiveRecorder.Infrastructure/Services/WebhookNotificationService.cs +++ b/src/LiveRecorder.Infrastructure/Services/WebhookNotificationService.cs @@ -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*(?[a-zA-Z0-9_.]+)\\s*\\}\\}\"", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex TokenRegex = new( + "\\{\\{\\s*(?[a-zA-Z0-9_.]+)\\s*\\}\\}", + RegexOptions.Compiled | RegexOptions.CultureInvariant); - private readonly IHttpClientFactory _httpClientFactory; private readonly ISystemSettingsService _systemSettingsService; - private readonly ISystemLogService _systemLogService; private readonly ILogger _logger; public WebhookNotificationService( - IHttpClientFactory httpClientFactory, ISystemSettingsService systemSettingsService, - ISystemLogService systemLogService, ILogger 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 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 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 SendInternalAsync( - SystemSettingsDto settings, - object payload, - CancellationToken cancellationToken) + Dictionary payload, + IReadOnlyDictionary 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> 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>(); - 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(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 payload, + IReadOnlyDictionary 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 BuildDefaultPayload( + string eventType, + string summary, + string? detail, + string source, + LiveRoom? liveRoom, + RecordTask? recordTask, + DailyReviewReportDto? report) + { + var payload = new Dictionary + { + ["appName"] = AppName, + ["eventType"] = eventType, + ["sentAtUtc"] = DateTimeOffset.UtcNow.ToString("O"), + ["summary"] = summary, + ["detail"] = detail, + ["source"] = source, + ["liveRoom"] = liveRoom is null ? null : new Dictionary + { + ["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 + { + ["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 + { + ["date"] = report.Date, + ["summary"] = new Dictionary + { + ["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 BuildTemplateVariables( + IReadOnlyDictionary payload, + DailyReviewReportDto? report) + { + var variables = new Dictionary(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 liveRoom) + { + foreach (var pair in liveRoom) + { + variables[$"liveRoom.{pair.Key}"] = pair.Value; + } + } + + if (payload.TryGetValue("recordTask", out var recordTaskPayload) && + recordTaskPayload is IReadOnlyDictionary 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]}..."; + } } diff --git a/src/LiveRecorder.WebApi/Controllers/MediaBrowserController.cs b/src/LiveRecorder.WebApi/Controllers/MediaBrowserController.cs new file mode 100644 index 0000000..f3520c5 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/MediaBrowserController.cs @@ -0,0 +1,60 @@ +using LiveRecorder.Application.Models.Media; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/media")] +public sealed class MediaBrowserController : ControllerBase +{ + private readonly MediaBrowserService _mediaBrowserService; + + public MediaBrowserController(MediaBrowserService mediaBrowserService) + { + _mediaBrowserService = mediaBrowserService; + } + + [HttpGet("browser")] + public async Task> Browse( + [FromQuery] string? path, + CancellationToken cancellationToken) + { + return Ok(await _mediaBrowserService.BrowseAsync(path, cancellationToken)); + } + + [HttpGet("file")] + public async Task GetFile( + [FromQuery] string path, + [FromQuery] bool download = false, + CancellationToken cancellationToken = default) + { + var filePath = await _mediaBrowserService.ResolveFilePathAsync(path, cancellationToken); + var contentType = ResolveContentType(filePath); + var fileName = Path.GetFileName(filePath); + return download + ? PhysicalFile(filePath, contentType, fileName) + : PhysicalFile(filePath, contentType, enableRangeProcessing: contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)); + } + + [HttpPost("transcode-file")] + public async Task> TranscodeFile( + [FromBody] TranscodeMediaFileRequest request, + CancellationToken cancellationToken) + { + return Ok(await _mediaBrowserService.TranscodeFileAsync(request, cancellationToken)); + } + + private static string ResolveContentType(string filePath) + { + return Path.GetExtension(filePath).ToLowerInvariant() switch + { + ".mp4" => "video/mp4", + ".ts" => "video/mp2t", + ".xml" => "application/xml", + ".json" => "application/json", + ".txt" => "text/plain; charset=utf-8", + _ => "application/octet-stream" + }; + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs index afd03b8..2cac459 100644 --- a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs +++ b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs @@ -1,5 +1,6 @@ using LiveRecorder.Application.Models.RecordTasks; using LiveRecorder.Application.Services; +using LiveRecorder.Application.Abstractions.Recording; using LiveRecorder.Infrastructure.Services; using Microsoft.AspNetCore.Mvc; @@ -11,15 +12,18 @@ public sealed class RecordTasksController : ControllerBase { private readonly RecordService _recordService; private readonly RecordUploadService _recordUploadService; + private readonly IRecordMediaService _recordMediaService; private readonly LinkGenerator _linkGenerator; public RecordTasksController( RecordService recordService, RecordUploadService recordUploadService, + IRecordMediaService recordMediaService, LinkGenerator linkGenerator) { _recordService = recordService; _recordUploadService = recordUploadService; + _recordMediaService = recordMediaService; _linkGenerator = linkGenerator; } @@ -57,15 +61,19 @@ public sealed class RecordTasksController : ControllerBase [HttpPost("{id:guid}/preview-ticket")] public async Task> CreatePreviewTicket(Guid id, CancellationToken cancellationToken) { - var baseUrl = _linkGenerator.GetUriByAction( - HttpContext, - action: nameof(MediaController.GetRecordTaskMedia), - controller: "Media", - values: new { ticket = "placeholder" }) - ?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/placeholder"; + var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken); + var mediaUrl = _linkGenerator.GetUriByAction( + HttpContext, + action: nameof(MediaController.GetRecordTaskMedia), + controller: "Media", + values: new { ticket = previewTicket.Ticket }) + ?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/{previewTicket.Ticket}"; - var mediaBaseUrl = baseUrl[..baseUrl.LastIndexOf('/')]; - return Ok(await _recordService.CreatePreviewTicketAsync(id, mediaBaseUrl, cancellationToken)); + return Ok(new RecordPreviewTicketDto + { + Url = mediaUrl, + ExpiresAt = previewTicket.ExpiresAt + }); } [HttpPost("{id:guid}/transcode")] diff --git a/src/LiveRecorder.WebApi/Controllers/ReportsController.cs b/src/LiveRecorder.WebApi/Controllers/ReportsController.cs index 7ef4384..1c8f145 100644 --- a/src/LiveRecorder.WebApi/Controllers/ReportsController.cs +++ b/src/LiveRecorder.WebApi/Controllers/ReportsController.cs @@ -41,40 +41,108 @@ public sealed class ReportsController : ControllerBase [HttpPost("daily/push")] public async Task> PushDaily( - [FromQuery] string? date, - [FromQuery] int utcOffsetMinutes = 0, + [FromBody] PushDailyReviewRequest request, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(request); + var reviewDate = DateOnly.FromDateTime(DateTime.Today.AddDays(-1)); - if (!string.IsNullOrWhiteSpace(date) && - DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate)) + if (!string.IsNullOrWhiteSpace(request.Date) && + DateOnly.TryParseExact(request.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate)) { reviewDate = parsedDate; } - var report = await _sessionAnalyticsService.GetDailyReviewAsync(reviewDate, utcOffsetMinutes, cancellationToken); - var s = report.Summary; - var summary = $"回顾日报 {reviewDate:yyyy-MM-dd}\n直播间: {s.ActiveLiveRoomCount}, 会话: {s.SessionCount}, 分片: {s.SegmentCount}, 录制时长: {s.TotalDurationSeconds / 3600.0:F1}h, 弹幕: {s.TotalDanmakuCount}"; + var channels = request.Channels + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => item.Trim().ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (channels.Length == 0) + { + return BadRequest("At least one push channel is required."); + } - var result = new DailyReviewPushResultDto(); + var report = await _sessionAnalyticsService.GetDailyReviewAsync(reviewDate, request.UtcOffsetMinutes, cancellationToken); + var results = new List(channels.Length); + + foreach (var channel in channels) + { + switch (channel) + { + case "webhook": + results.Add(await PushViaWebhookAsync(report, cancellationToken)); + break; + case "email": + results.Add(await PushViaEmailAsync(report, cancellationToken)); + break; + default: + results.Add(new DailyReviewPushChannelResultDto + { + Channel = channel, + Success = false, + Message = "Unsupported push channel." + }); + break; + } + } + + return Ok(new DailyReviewPushResultDto + { + Date = report.Date, + Results = results + }); + } + + private async Task PushViaWebhookAsync( + DailyReviewReportDto report, + CancellationToken cancellationToken) + { try { - await _webhookNotificationService.SendExceptionAsync( - "DailyReview", - summary, - System.Text.Json.JsonSerializer.Serialize(report), - cancellationToken: cancellationToken); - result.WebhookSent = true; + await _webhookNotificationService.SendDailyReviewAsync(report, cancellationToken); + return new DailyReviewPushChannelResultDto + { + Channel = "webhook", + Success = true, + Message = "Webhook daily review sent successfully." + }; } - catch { } + catch (Exception ex) + { + return new DailyReviewPushChannelResultDto + { + Channel = "webhook", + Success = false, + Message = $"Webhook daily review failed: {ex.Message}", + Detail = ex.InnerException?.Message + }; + } + } - result.Message = result.WebhookSent ? "日报已通过 Webhook 推送。" : "日报推送失败,请检查 Webhook 配置。"; - return Ok(result); + private async Task PushViaEmailAsync( + DailyReviewReportDto report, + CancellationToken cancellationToken) + { + try + { + await _emailNotificationService.SendDailyReviewAsync(report, cancellationToken); + return new DailyReviewPushChannelResultDto + { + Channel = "email", + Success = true, + Message = "Email daily review sent successfully." + }; + } + catch (Exception ex) + { + return new DailyReviewPushChannelResultDto + { + Channel = "email", + Success = false, + Message = $"Email daily review failed: {ex.Message}", + Detail = ex.InnerException?.Message + }; + } } } - -public sealed class DailyReviewPushResultDto -{ - public bool WebhookSent { get; set; } - public string Message { get; set; } = string.Empty; -} diff --git a/src/LiveRecorder.WebApi/Controllers/TranscodeTasksController.cs b/src/LiveRecorder.WebApi/Controllers/TranscodeTasksController.cs new file mode 100644 index 0000000..1c23906 --- /dev/null +++ b/src/LiveRecorder.WebApi/Controllers/TranscodeTasksController.cs @@ -0,0 +1,21 @@ +using LiveRecorder.Application.Models.RecordTasks; +using LiveRecorder.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LiveRecorder.WebApi.Controllers; + +[ApiController] +[Route("api/transcode-tasks")] +public sealed class TranscodeTasksController : ControllerBase +{ + private readonly TranscodeTaskService _transcodeTaskService; + + public TranscodeTasksController(TranscodeTaskService transcodeTaskService) + { + _transcodeTaskService = transcodeTaskService; + } + + [HttpGet] + public async Task>> List(CancellationToken cancellationToken) => + Ok(await _transcodeTaskService.ListAsync(cancellationToken)); +} diff --git a/src/LiveRecorder.WebApi/Program.cs b/src/LiveRecorder.WebApi/Program.cs index cdcc4f0..472a8f8 100644 --- a/src/LiveRecorder.WebApi/Program.cs +++ b/src/LiveRecorder.WebApi/Program.cs @@ -163,6 +163,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();