feat: add transcode workspace and media browser
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
VideoCamera,
|
||||
Document,
|
||||
Tickets,
|
||||
FolderOpened,
|
||||
RefreshRight,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
|
||||
@@ -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
@@ -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> = {
|
||||
|
||||
@@ -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: {
|
||||
const payload: PushDailyReviewRequest = {
|
||||
date: selectedDate.value,
|
||||
utcOffsetMinutes: getLocalUtcOffsetMinutes()
|
||||
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);
|
||||
}
|
||||
});
|
||||
ElMessage.success(data.message);
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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="{ "event": "{{eventType}}", "summary": "{{summary}}", "roomId": "{{liveRoom.roomId}}" }"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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(
|
||||
var sampleLiveRoom = new LiveRoom(
|
||||
Domain.Enums.LivePlatformType.Douyin,
|
||||
"https://live.douyin.com/676493068539",
|
||||
"676493068539",
|
||||
"https://live.douyin.com/676493068539",
|
||||
DateTimeOffset.UtcNow),
|
||||
recordTask: null);
|
||||
"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.");
|
||||
if (swallowErrors)
|
||||
{
|
||||
return "Webhook URL is empty.";
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(settings.WebhookTimeoutSeconds, 1, 300));
|
||||
throw new InvalidOperationException("Webhook URL is empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var body = BuildRequestBody(settings.WebhookBodyTemplate, payload, variables);
|
||||
using var httpClient = CreateHttpClient(settings.WebhookTimeoutSeconds);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, settings.WebhookUrl.Trim())
|
||||
{
|
||||
Content = JsonContent.Create(payload)
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
foreach (var header in ParseHeaders(settings.WebhookHeaders))
|
||||
{
|
||||
if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
{
|
||||
request.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
ApplyHeaders(request.Headers, settings.WebhookHeaders);
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var detail = BuildResponseDetail(settings.WebhookUrl, response, responseBody);
|
||||
return new WebhookSendResult(response.IsSuccessStatusCode, detail);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Webhook returned {(int)response.StatusCode} {response.ReasonPhrase}. {Truncate(responseBody, 600)}");
|
||||
}
|
||||
|
||||
private static object BuildPayload(
|
||||
string eventType,
|
||||
string summary,
|
||||
string? detail,
|
||||
LiveRoom? liveRoom,
|
||||
RecordTask? recordTask,
|
||||
string? source = null)
|
||||
{
|
||||
return new
|
||||
{
|
||||
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
|
||||
return $"status={(int)response.StatusCode}; body={Truncate(responseBody, 600)}";
|
||||
}
|
||||
};
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (swallowErrors)
|
||||
{
|
||||
_logger.LogWarning(ex, "Webhook send failed");
|
||||
return ex.Message;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KeyValuePair<string, string>> ParseHeaders(string rawHeaders)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken);
|
||||
var mediaUrl = _linkGenerator.GetUriByAction(
|
||||
HttpContext,
|
||||
action: nameof(MediaController.GetRecordTaskMedia),
|
||||
controller: "Media",
|
||||
values: new { ticket = "placeholder" })
|
||||
?? $"{Request.Scheme}://{Request.Host}/media/record-tasks/placeholder";
|
||||
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;
|
||||
}
|
||||
catch { }
|
||||
|
||||
result.Message = result.WebhookSent ? "日报已通过 Webhook 推送。" : "日报推送失败,请检查 Webhook 配置。";
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DailyReviewPushResultDto
|
||||
await _webhookNotificationService.SendDailyReviewAsync(report, cancellationToken);
|
||||
return new DailyReviewPushChannelResultDto
|
||||
{
|
||||
public bool WebhookSent { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
Channel = "webhook",
|
||||
Success = true,
|
||||
Message = "Webhook daily review sent successfully."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DailyReviewPushChannelResultDto
|
||||
{
|
||||
Channel = "webhook",
|
||||
Success = false,
|
||||
Message = $"Webhook daily review failed: {ex.Message}",
|
||||
Detail = ex.InnerException?.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user