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
@@ -10,6 +10,7 @@ import {
VideoCamera,
Document,
Tickets,
FolderOpened,
RefreshRight,
Setting,
SwitchButton,
+12
View File
@@ -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",
+66 -5
View File
@@ -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<number, string> = {
0: "未知",
1: "未开播",
@@ -579,11 +633,18 @@ export const qualityLabelMap: Record<string, string> = {
SD: "标清"
};
export const qualityDisplayLabelMap: Record<string, string> = {
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<string, string> = {
+74 -24
View File
@@ -1,22 +1,31 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { useViewport } from "@/composables/useViewport";
import { ElMessage } from "element-plus";
import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { DailyReviewReport } from "@/types";
import { useViewport } from "@/composables/useViewport";
import type {
DailyReviewPushResult,
DailyReviewReport,
PushDailyReviewRequest
} from "@/types";
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const pushing = ref(false);
const pushDialogVisible = ref(false);
const loadError = ref("");
const selectedDate = ref(defaultReviewDate());
const report = ref<DailyReviewReport | null>(null);
const roomTableHeight = computed(() => (isMobile.value ? undefined : 420));
const momentTableHeight = computed(() => (isMobile.value ? undefined : 360));
const pushChannels = reactive({
webhook: true,
email: false
});
async function loadReport() {
loading.value = true;
@@ -40,16 +49,39 @@ async function loadReport() {
}
}
function openPushDialog() {
pushDialogVisible.value = true;
}
async function pushReport() {
const channels = Object.entries(pushChannels)
.filter(([, enabled]) => enabled)
.map(([channel]) => channel);
if (channels.length === 0) {
ElMessage.warning("请至少选择一种推送方式。");
return;
}
pushing.value = true;
try {
const { data } = await apiClient.post("/reports/daily/push", null, {
params: {
date: selectedDate.value,
utcOffsetMinutes: getLocalUtcOffsetMinutes()
}
});
ElMessage.success(data.message);
const payload: PushDailyReviewRequest = {
date: selectedDate.value,
utcOffsetMinutes: getLocalUtcOffsetMinutes(),
channels
};
const { data } = await apiClient.post<DailyReviewPushResult>("/reports/daily/push", payload);
const successCount = data.results.filter((item) => item.success).length;
const summary = data.results.map((item) => `${item.channel}: ${item.message}`).join(" | ");
if (successCount === data.results.length) {
ElMessage.success(summary);
} else if (successCount > 0) {
ElMessage.warning(summary);
} else {
ElMessage.error(summary);
}
pushDialogVisible.value = false;
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "日报推送失败。"));
} finally {
@@ -107,7 +139,7 @@ onMounted(loadReport);
<div>
<h1 class="page-title">回顾日报</h1>
<p class="page-subtitle">
按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook
按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook 或邮件
</p>
</div>
@@ -122,7 +154,7 @@ onMounted(loadReport);
@change="loadReport"
/>
<el-button @click="loadReport">刷新日报</el-button>
<el-button type="primary" :loading="pushing" :disabled="!report" @click="pushReport">推送日报</el-button>
<el-button type="primary" :disabled="!report" @click="openPushDialog">推送日报</el-button>
</el-space>
</div>
@@ -275,6 +307,24 @@ onMounted(loadReport);
</div>
</el-card>
</template>
<el-dialog
v-model="pushDialogVisible"
title="推送日报"
width="420px"
destroy-on-close
>
<div class="push-dialog">
<p class="push-dialog__hint">选择要推送的通道第一版支持 Webhook 和邮件可同时勾选</p>
<el-checkbox v-model="pushChannels.webhook">Webhook</el-checkbox>
<el-checkbox v-model="pushChannels.email">邮件</el-checkbox>
</div>
<template #footer>
<el-button @click="pushDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="pushing" @click="pushReport">开始推送</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -292,16 +342,6 @@ onMounted(loadReport);
border-radius: 14px;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
}
.highlight-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -373,6 +413,17 @@ onMounted(loadReport);
flex-wrap: nowrap;
}
.push-dialog {
display: grid;
gap: 14px;
}
.push-dialog__hint {
margin: 0;
color: var(--text-secondary);
line-height: 1.7;
}
@media (max-width: 1100px) {
.highlight-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -405,4 +456,3 @@ onMounted(loadReport);
}
}
</style>
+330
View File
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { FolderOpened, VideoPlay, Document, RefreshRight } from "@element-plus/icons-vue";
import apiClient, { buildApiUrl, getApiErrorMessage } from "@/api/client";
import type { MediaBrowserItem, MediaBrowserResponse, TranscodeMediaFileResult } from "@/types";
const loading = ref(false);
const transcodePath = ref<string | null>(null);
const loadError = ref("");
const browser = ref<MediaBrowserResponse | null>(null);
const previewVisible = ref(false);
const previewTitle = ref("");
const previewUrl = ref("");
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
async function loadDirectory(path = "") {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<MediaBrowserResponse>("/media/browser", {
params: { path }
});
browser.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "录制目录加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function openDirectory(path: string) {
loadDirectory(path);
}
function refreshCurrentDirectory() {
loadDirectory(browser.value?.currentPath ?? "");
}
async function transcodeFile(item: MediaBrowserItem) {
transcodePath.value = item.relativePath;
try {
const { data } = await apiClient.post<TranscodeMediaFileResult>("/media/transcode-file", {
relativePath: item.relativePath
});
ElMessage[data.success ? "success" : "warning"](data.message);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "目录转码启动失败。"));
} finally {
transcodePath.value = null;
}
}
function previewVideo(item: MediaBrowserItem) {
previewTitle.value = item.name;
previewUrl.value = buildApiUrl("/media/file", { path: item.relativePath });
previewVisible.value = true;
}
function openFile(item: MediaBrowserItem, download = false) {
const url = buildApiUrl("/media/file", {
path: item.relativePath,
download: download ? "true" : undefined
});
window.open(url, "_blank", "noopener,noreferrer");
}
function iconForItem(item: MediaBrowserItem) {
switch (item.type) {
case "directory":
return FolderOpened;
case "mp4":
return VideoPlay;
default:
return Document;
}
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatFileSize(bytes?: number) {
if (typeof bytes !== "number") {
return "-";
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function typeLabel(type: string) {
switch (type) {
case "directory":
return "目录";
case "mp4":
return "MP4";
case "ts":
return "TS";
case "xml":
return "XML";
default:
return "其他";
}
}
onMounted(() => {
loadDirectory();
});
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">文件浏览</div>
<h1 class="page-title">录制目录</h1>
<p class="page-subtitle">
从平台目录开始浏览录制文件这里可以直接预览 MP4查看 XML并对单个 .ts 文件发起转码
</p>
</div>
<el-space wrap class="header-actions">
<el-button :icon="RefreshRight" @click="refreshCurrentDirectory">刷新目录</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">{{ currentPathLabel }}</h3>
<p class="section-subtitle">目录浏览严格限制在录制输出根目录内不会暴露系统其它路径</p>
</div>
</div>
<div class="breadcrumb-row">
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="breadcrumb in browser?.breadcrumbs ?? []"
:key="breadcrumb.relativePath || 'root'"
>
<button class="breadcrumb-button" type="button" @click="openDirectory(breadcrumb.relativePath)">
{{ breadcrumb.label }}
</button>
</el-breadcrumb-item>
</el-breadcrumb>
<el-button
v-if="browser?.parentPath !== undefined && browser?.parentPath !== null"
size="small"
@click="openDirectory(browser?.parentPath || '')"
>
返回上级
</el-button>
</div>
<el-skeleton v-if="loading && !browser" animated :rows="8" />
<el-empty v-else-if="browser && browser.items.length === 0" description="当前目录为空" />
<div v-else-if="browser" class="table-scroll-shell">
<el-table :data="browser.items" class="premium-table" table-layout="auto">
<el-table-column label="名称" min-width="260">
<template #default="{ row }">
<div class="file-cell">
<el-icon class="file-cell__icon"><component :is="iconForItem(row)" /></el-icon>
<div>
<div class="cell-title">{{ row.name }}</div>
<div class="cell-subtitle">{{ row.relativePath }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="110">
<template #default="{ row }">
{{ typeLabel(row.type) }}
</template>
</el-table-column>
<el-table-column label="大小" width="120">
<template #default="{ row }">
{{ formatFileSize(row.sizeBytes) }}
</template>
</el-table-column>
<el-table-column label="修改时间" width="180">
<template #default="{ row }">
{{ formatDate(row.modifiedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="320" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button v-if="row.type === 'directory'" size="small" @click="openDirectory(row.relativePath)">进入目录</el-button>
<el-button v-if="row.type === 'mp4'" size="small" @click="previewVideo(row)">预览视频</el-button>
<el-button v-if="row.type === 'xml'" size="small" @click="openFile(row)">查看 XML</el-button>
<el-button
v-if="row.type !== 'directory'"
size="small"
plain
@click="openFile(row, true)"
>
下载
</el-button>
<el-button
v-if="row.canTranscode"
size="small"
type="primary"
plain
:loading="transcodePath === row.relativePath"
@click="transcodeFile(row)"
>
转码为 MP4
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-dialog v-model="previewVisible" :title="previewTitle" width="min(980px, 92vw)" destroy-on-close>
<video v-if="previewUrl" class="preview-player" :src="previewUrl" controls preload="metadata" />
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.breadcrumb-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
}
.breadcrumb-button {
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
}
.file-cell {
display: flex;
align-items: flex-start;
gap: 12px;
}
.file-cell__icon {
margin-top: 3px;
color: var(--accent);
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.cell-subtitle {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
overflow-wrap: anywhere;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preview-player {
width: 100%;
max-height: 72vh;
border-radius: 12px;
background: #09121d;
}
@media (max-width: 720px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.breadcrumb-row {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+2
View File
@@ -588,6 +588,8 @@ onBeforeUnmount(() => {
</div>
<div class="page-toolbar">
<el-button @click="router.push({ name: 'transcode-tasks' })">转码任务</el-button>
<el-button @click="router.push({ name: 'media-browser' })">录制目录</el-button>
<el-button @click="loadSessions()">刷新列表</el-button>
<el-button
type="danger"
+47 -1
View File
@@ -179,6 +179,7 @@ const form = reactive<SystemSettings>({
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<WebhookTestResult>("/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);
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="自定义 JSON Body 模板">
<el-input
v-model="form.webhookBodyTemplate"
type="textarea"
:rows="8"
placeholder="{&#10; &quot;event&quot;: &quot;{{eventType}}&quot;,&#10; &quot;summary&quot;: &quot;{{summary}}&quot;,&#10; &quot;roomId&quot;: &quot;{{liveRoom.roomId}}&quot;&#10;}"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="template-help">
<div class="template-help__label">Webhook 变量</div>
<div class="token-list">
<span v-for="token in webhookTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
</div>
<div class="action-strip">
<div class="helper-text">测试会发送一份样例 live_started 负载直接使用当前表单里的 URL请求头和超时配置</div>
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { TranscodeTaskItem } from "@/types";
import { formatQualityLabel, outputFormatLabelMap, taskStatusLabelMap } from "@/types";
import { useViewport } from "@/composables/useViewport";
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const transcodeStartingTaskId = ref<string | null>(null);
const loadError = ref("");
const items = ref<TranscodeTaskItem[]>([]);
const tableHeight = computed(() => (isMobile.value ? undefined : 560));
let refreshTimer: number | null = null;
const activeCount = computed(() => items.value.filter((item) => Boolean(item.task.postProcessStage)).length);
const manualCount = computed(() => items.value.filter((item) => item.canManualTranscode).length);
const mp4Count = computed(() => items.value.filter((item) => item.task.outputFormat === 0).length);
async function loadItems() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<TranscodeTaskItem[]>("/transcode-tasks");
items.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "转码任务加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
async function startManualTranscode(taskId: string) {
transcodeStartingTaskId.value = taskId;
try {
await apiClient.post(`/record-tasks/${taskId}/transcode`);
ElMessage.success("已开始手动转码,请稍后刷新查看进度。");
await loadItems();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "手动转码启动失败。"));
} finally {
transcodeStartingTaskId.value = null;
}
}
function openTask(taskId: string) {
router.push({ name: "record-task-detail", params: { id: taskId } });
}
function openSession(sessionId: string) {
router.push({ name: "record-session-detail", params: { id: sessionId } });
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatFileSize(bytes?: number) {
if (typeof bytes !== "number") {
return "-";
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function formatProgress(item: TranscodeTaskItem) {
const percent = item.task.postProcessProgressPercent;
if (typeof percent === "number" && Number.isFinite(percent)) {
return `${percent.toFixed(0)}%`;
}
return item.task.postProcessDetail || "-";
}
function resolveStageLabel(item: TranscodeTaskItem) {
if (item.task.postProcessStage) {
return item.task.postProcessStage;
}
if (item.canManualTranscode) {
return "可手动转码";
}
return taskStatusLabelMap[item.task.status] ?? String(item.task.status);
}
function setupAutoRefresh() {
if (typeof window === "undefined") {
return;
}
refreshTimer = window.setInterval(() => {
if (document.visibilityState === "visible") {
loadItems();
}
}, 15000);
}
onMounted(async () => {
await loadItems();
setupAutoRefresh();
});
onBeforeUnmount(() => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer);
}
});
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">后处理工作台</div>
<h1 class="page-title">转码任务</h1>
<p class="page-subtitle">
这里聚焦 MP4 收尾修复转码和保留中间 .ts 的任务方便从录制视角里拆出来单独处理
</p>
</div>
<el-space wrap class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回录制任务</el-button>
<el-button @click="router.push({ name: 'media-browser' })">录制目录</el-button>
<el-button @click="loadItems">刷新列表</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">转码任务</div>
<div class="stat-card__value">{{ items.length }}</div>
<div class="stat-card__hint">当前可观察的后处理 / 转码任务总数</div>
</div>
<div class="stat-card">
<div class="stat-card__label">处理中</div>
<div class="stat-card__value">{{ activeCount }}</div>
<div class="stat-card__hint">有后处理阶段和进度的任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">可手动转码</div>
<div class="stat-card__value">{{ manualCount }}</div>
<div class="stat-card__hint">保留中间 .ts 或可补转码的任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">MP4 输出</div>
<div class="stat-card__value">{{ mp4Count }}</div>
<div class="stat-card__hint">目标输出格式为 MP4 的任务</div>
</div>
</div>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">转码列表</h3>
<p class="section-subtitle">保留录制上下文但把注意力集中在当前阶段输入输出和手动转码入口上</p>
</div>
</div>
<el-empty v-if="!loading && items.length === 0" description="当前没有需要关注的转码任务" />
<div v-else class="table-scroll-shell">
<el-table :data="items" :height="tableHeight" class="premium-table" table-layout="auto">
<el-table-column label="直播间 / 分片" min-width="260">
<template #default="{ row }">
<div class="cell-title">{{ row.task.liveRoomTitle }}</div>
<div class="cell-subtitle">会话 {{ row.task.recordSessionId.slice(0, 8) }} / 分片 #{{ row.task.segmentIndex }}</div>
</template>
</el-table-column>
<el-table-column label="画质" width="110">
<template #default="{ row }">
{{ formatQualityLabel(row.task.preferredQuality) }}
</template>
</el-table-column>
<el-table-column label="输出格式" width="110">
<template #default="{ row }">
{{ outputFormatLabelMap[row.task.outputFormat] }}
</template>
</el-table-column>
<el-table-column label="当前阶段" min-width="180">
<template #default="{ row }">
<div class="stage-cell">
<div class="stage-cell__title">{{ resolveStageLabel(row) }}</div>
<div class="stage-cell__detail">{{ formatProgress(row) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="输入 / 输出" min-width="320">
<template #default="{ row }">
<div class="path-cell">
<code>{{ row.sourceFilePath || "-" }}</code>
<code>{{ row.result?.filePath || row.task.outputFilePath || "-" }}</code>
<span class="path-cell__meta">
{{ formatFileSize(row.result?.fileSizeBytes) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="最近时间" width="170">
<template #default="{ row }">
{{ formatDate(row.task.endedAt || row.task.startedAt || row.task.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" @click="openTask(row.task.id)">查看分片</el-button>
<el-button size="small" @click="openSession(row.task.recordSessionId)">查看会话</el-button>
<el-button
v-if="row.canManualTranscode"
size="small"
type="primary"
plain
:loading="transcodeStartingTaskId === row.task.id"
@click="startManualTranscode(row.task.id)"
>
手动转码
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.cell-subtitle {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
}
.stage-cell,
.path-cell {
display: grid;
gap: 6px;
}
.stage-cell__title {
font-weight: 700;
color: var(--text-primary);
}
.stage-cell__detail,
.path-cell__meta {
color: var(--text-secondary);
font-size: 13px;
}
.path-cell code {
overflow-wrap: anywhere;
color: var(--text-primary);
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (max-width: 720px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
}
</style>
@@ -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);
}
@@ -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<WebhookTestResultDto> SendTestAsync(
SendTestWebhookRequest request,
CancellationToken cancellationToken = default);
Task SendDailyReviewAsync(
DailyReviewReportDto report,
CancellationToken cancellationToken = default);
}
@@ -32,6 +32,10 @@ public interface IFfmpegService
Task<bool> StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
Task<LiveRecorder.Application.Models.Media.TranscodeMediaFileResultDto> StartManualFinalizeFileAsync(
string sourceFilePath,
CancellationToken cancellationToken = default);
Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default);
bool IsRunning(Guid recordSessionId);
@@ -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<MediaBrowserBreadcrumbDto> Breadcrumbs { get; init; }
public required IReadOnlyList<MediaBrowserItemDto> 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; }
}
@@ -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; }
}
@@ -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<string> 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<DailyReviewPushChannelResultDto> Results { get; init; }
}
@@ -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;
}
@@ -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<MediaBrowserResponseDto> 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<TranscodeMediaFileResultDto> 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<string> 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<MediaBrowserBreadcrumbDto> BuildBreadcrumbs(string relativePath)
{
var breadcrumbs = new List<MediaBrowserBreadcrumbDto>
{
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;
}
}
@@ -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);
@@ -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<IReadOnlyList<TranscodeTaskItemDto>> 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;
}
@@ -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]}...";
}
}
@@ -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<ActionResult<MediaBrowserResponseDto>> Browse(
[FromQuery] string? path,
CancellationToken cancellationToken)
{
return Ok(await _mediaBrowserService.BrowseAsync(path, cancellationToken));
}
[HttpGet("file")]
public async Task<IActionResult> 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<ActionResult<TranscodeMediaFileResultDto>> 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"
};
}
}
@@ -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<ActionResult<RecordPreviewTicketDto>> 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")]
@@ -41,40 +41,108 @@ public sealed class ReportsController : ControllerBase
[HttpPost("daily/push")]
public async Task<ActionResult<DailyReviewPushResultDto>> 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<DailyReviewPushChannelResultDto>(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<DailyReviewPushChannelResultDto> 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<DailyReviewPushChannelResultDto> 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;
}
@@ -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<ActionResult<IReadOnlyList<TranscodeTaskItemDto>>> List(CancellationToken cancellationToken) =>
Ok(await _transcodeTaskService.ListAsync(cancellationToken));
}
+2
View File
@@ -163,6 +163,8 @@ builder.Services.AddScoped<LiveRoomStatusService>();
builder.Services.AddScoped<LiveRoomRecordingSettingsResolver>();
builder.Services.AddScoped<RecordService>();
builder.Services.AddScoped<RecordSessionService>();
builder.Services.AddScoped<TranscodeTaskService>();
builder.Services.AddScoped<MediaBrowserService>();
builder.Services.AddScoped<SessionAnalyticsService>();
builder.Services.AddScoped<RecoveryService>();
builder.Services.AddScoped<RetentionCleanupService>();