feat: add live room metadata, uploads, proxies and backups

This commit is contained in:
2026-04-26 17:35:51 +08:00
parent 85f24f8a84
commit d2b2f714e0
38 changed files with 2371 additions and 116 deletions
+172 -5
View File
@@ -31,7 +31,7 @@ const { themeMode, density, resolvedTheme, sidebarCollapsed, cycleThemeMode, tog
const mobileNavVisible = ref(false);
const displayName = computed(() => authStore.user?.displayName ?? "Operator");
const displayName = computed(() => authStore.user?.displayName ?? "管理员");
const navigationGroups = [
{
@@ -133,6 +133,12 @@ const currentThemeIcon = computed(() => {
return Monitor;
});
const shouldShowGlobalBackendAlert = computed(() => backendUnavailable.value && route.name !== "record-tasks");
function isNavItemActive(index: string) {
return route.path === index || route.path.startsWith(`${index}/`);
}
watch(
() => route.fullPath,
() => {
@@ -166,7 +172,25 @@ async function handleLogout() {
<div class="app-sidebar__scroll">
<section v-for="group in navigationGroups" :key="group.key" class="app-nav-group">
<div v-if="!sidebarCollapsed" class="app-nav-group__title">{{ group.title }}</div>
<el-menu :default-active="route.path" router class="app-nav-menu" :collapse="sidebarCollapsed">
<div v-if="sidebarCollapsed" class="app-nav-icon-list">
<el-tooltip
v-for="item in group.items"
:key="item.index"
:content="item.label"
placement="right"
:show-after="120"
>
<button
type="button"
class="app-nav-icon-item"
:class="{ 'is-active': isNavItemActive(item.index) }"
@click="router.push(item.index)"
>
<el-icon><component :is="item.icon" /></el-icon>
</button>
</el-tooltip>
</div>
<el-menu v-else :default-active="route.path" router class="app-nav-menu">
<el-menu-item v-for="item in group.items" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.label }}</template>
@@ -175,6 +199,50 @@ async function handleLogout() {
</section>
</div>
<div class="app-sidebar__panel">
<template v-if="!sidebarCollapsed">
<div class="app-sidebar__workspace">
<div class="app-sidebar__workspace-eyebrow">控制中心</div>
<div class="app-sidebar__workspace-title">Live Recorder</div>
<div class="app-sidebar__workspace-subtitle">专业直播录制运维控制台</div>
</div>
<div class="app-sidebar__selectors">
<el-select v-model="themeMode" size="small">
<el-option label="跟随系统" value="system" />
<el-option label="浅色" value="light" />
<el-option label="深色" value="dark" />
</el-select>
<el-select v-model="density" size="small">
<el-option label="舒适密度" value="comfortable" />
<el-option label="紧凑密度" value="compact" />
</el-select>
</div>
<div class="app-sidebar__panel-row">
<div class="app-sidebar__status">
<span class="app-status-dot" :class="`app-status-dot--${resolvedTheme}`"></span>
<span>{{ resolvedTheme === "dark" ? "深色控制台" : "浅色控制台" }}</span>
</div>
<el-button class="app-sidebar__utility" :icon="Fold" text @click="toggleSidebarCollapsed">收起导航</el-button>
</div>
<div class="app-sidebar__panel-row">
<div class="app-account">
<div class="app-account__label">当前账户</div>
<div class="app-account__name">{{ displayName }}</div>
</div>
<el-button class="app-sidebar__logout" :icon="SwitchButton" text @click="handleLogout">退出登录</el-button>
</div>
</template>
<template v-else>
<el-button class="app-sidebar__icon-button" :icon="Expand" text @click="toggleSidebarCollapsed" />
<el-button class="app-sidebar__icon-button" :icon="currentThemeIcon" text @click="cycleThemeMode" />
<el-button class="app-sidebar__icon-button" :icon="SwitchButton" text @click="handleLogout" />
</template>
</div>
<div class="app-sidebar__footer">
<div class="app-sidebar__status">
<span class="app-status-dot" :class="`app-status-dot--${resolvedTheme}`"></span>
@@ -262,7 +330,7 @@ async function handleLogout() {
@click="toggleSidebarCollapsed"
/>
<div class="app-topbar__copy">
<div class="app-topbar__eyebrow">Control Room</div>
<div class="app-topbar__eyebrow">控制中心</div>
<div class="app-topbar__title">Live Recorder</div>
<div class="app-topbar__subtitle">专业直播录制运维控制台</div>
</div>
@@ -293,7 +361,7 @@ async function handleLogout() {
<el-main class="app-main">
<el-alert
v-if="backendUnavailable"
v-if="shouldShowGlobalBackendAlert"
class="backend-alert"
type="error"
:closable="false"
@@ -317,6 +385,7 @@ async function handleLogout() {
display: flex;
flex-direction: column;
gap: 18px;
overflow-y: auto;
padding: 20px 16px;
border-right: 1px solid var(--border-subtle);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.16));
@@ -375,7 +444,8 @@ async function handleLogout() {
}
.app-sidebar__scroll {
display: grid;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
@@ -417,6 +487,81 @@ async function handleLogout() {
box-shadow: inset 0 0 0 1px rgba(47, 111, 180, 0.18);
}
.app-nav-icon-list {
display: grid;
gap: 8px;
}
.app-nav-icon-item {
display: grid;
place-items: center;
width: 100%;
min-height: 44px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.app-nav-icon-item:hover {
background: var(--accent-soft);
color: var(--text-primary);
}
.app-nav-icon-item.is-active {
background: linear-gradient(180deg, rgba(47, 111, 180, 0.16), rgba(47, 111, 180, 0.08));
color: var(--accent);
box-shadow: inset 0 0 0 1px rgba(47, 111, 180, 0.18);
}
.app-sidebar__panel {
display: grid;
gap: 12px;
margin-top: 6px;
padding: 14px;
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: var(--surface);
}
.app-sidebar__workspace-eyebrow {
margin-bottom: 6px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.app-sidebar__workspace-title {
color: var(--text-primary);
font-size: 18px;
font-weight: 760;
letter-spacing: -0.04em;
line-height: 1.1;
}
.app-sidebar__workspace-subtitle {
margin-top: 8px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
}
.app-sidebar__selectors {
display: grid;
gap: 10px;
}
.app-sidebar__panel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.app-sidebar__footer {
display: grid;
gap: 12px;
@@ -427,11 +572,20 @@ async function handleLogout() {
background: var(--surface);
}
.app-sidebar__footer {
display: none;
}
.app-sidebar--collapsed .app-sidebar__footer {
padding-inline: 10px;
justify-items: center;
}
.app-sidebar--collapsed .app-sidebar__panel {
padding-inline: 10px;
justify-items: center;
}
.app-sidebar__status {
display: flex;
align-items: center;
@@ -475,15 +629,24 @@ async function handleLogout() {
justify-content: flex-start;
}
.app-sidebar__utility {
justify-content: flex-end;
}
.app-sidebar--collapsed .app-sidebar__logout {
justify-content: center;
}
.app-sidebar__icon-button {
justify-content: center;
}
.app-shell__main {
min-width: 0;
}
.app-topbar {
display: none;
height: auto;
padding: 18px var(--content-padding) 0;
}
@@ -617,6 +780,10 @@ async function handleLogout() {
}
@media (max-width: 1023px) {
.app-topbar {
display: block;
}
.app-topbar {
padding: 14px var(--content-padding-mobile) 0;
}
+117 -27
View File
@@ -24,10 +24,17 @@ export interface LiveRoom {
anchorId?: string;
avatarUrl?: string;
coverUrl?: string;
remark?: string;
isPinned: boolean;
alias?: string;
isPriority: boolean;
pollingIntervalSecondsOverride?: number;
originalLiveRoomUrl: string;
overrides: LiveRoomSettingsOverrides;
effectiveSettings: LiveRoomEffectiveSettings;
isEnabled: boolean;
availabilityStatus: number;
currentRecordingState: number;
lastAutoStartDecisionCode?: string;
lastAutoStartDecisionSummary?: string;
lastAutoStartDecisionDetail?: string;
@@ -100,6 +107,14 @@ export interface LiveRoomEffectiveSettings {
danmakuRetryDelayMaxSeconds: number;
}
export interface UpdateLiveRoomMetadataRequest {
remark?: string | null;
isPinned: boolean;
alias?: string | null;
isPriority: boolean;
pollingIntervalSecondsOverride?: number | null;
}
export interface RecordTask {
id: string;
liveRoomId: string;
@@ -133,6 +148,13 @@ export interface RecordResult {
danmakuMessageCount: number;
finalStatus: number;
errorMessage?: string;
uploadStatus: number;
lastUploadProvider?: string;
remoteVideoPath?: string;
remoteDanmakuPath?: string;
lastUploadedAt?: string;
uploadErrorMessage?: string;
deletedLocalFilesAfterUpload: boolean;
createdAt: string;
}
@@ -184,6 +206,23 @@ export interface DeleteCompletedRecordTasksResult {
warnings: string[];
}
export interface RecordArtifactUploadItemResult {
recordTaskId: string;
success: boolean;
message: string;
provider?: string;
remoteVideoPath?: string;
remoteDanmakuPath?: string;
deletedLocalFilesAfterUpload: boolean;
}
export interface RecordArtifactUploadBatchResult {
requestedCount: number;
successCount: number;
failedCount: number;
items: RecordArtifactUploadItemResult[];
}
export interface RecordPreviewTicket {
url: string;
expiresAt: string;
@@ -333,6 +372,16 @@ export interface SystemSettings {
enableBackgroundPolling: boolean;
autoStartRecordingOnLive: boolean;
pollingIntervalSeconds: number;
useAliasForStorage: boolean;
enableFileUpload: boolean;
enableAutoUpload: boolean;
deleteLocalFilesAfterUpload: boolean;
uploadTarget: number;
douyinProxy: PlatformProxySettings;
bilibiliProxy: PlatformProxySettings;
huyaProxy: PlatformProxySettings;
webDavUpload: WebDavUploadSettings;
s3Upload: S3UploadSettings;
enableEventScripts: boolean;
liveStartedScriptMode: string;
liveStartedScriptPath: string;
@@ -370,6 +419,28 @@ export interface SystemSettings {
douyinCookie: string;
}
export interface PlatformProxySettings {
enabled: boolean;
proxyUrl: string;
}
export interface WebDavUploadSettings {
endpoint: string;
basePath: string;
username: string;
password: string;
}
export interface S3UploadSettings {
endpoint: string;
bucket: string;
region: string;
accessKey: string;
secretKey: string;
prefix: string;
forcePathStyle: boolean;
}
export interface EventScriptTestResult {
success: boolean;
message: string;
@@ -444,29 +515,35 @@ export interface RecoveryActionResult {
}
export const availabilityLabelMap: Record<number, string> = {
0: "Unknown",
1: "Offline",
2: "Live"
0: "未知",
1: "未开播",
2: "开播中"
};
export const currentRecordingStateLabelMap: Record<number, string> = {
0: "未开播",
1: "开播中",
2: "录制中"
};
export const taskStatusLabelMap: Record<number, string> = {
0: "Pending",
1: "Starting",
2: "Running",
3: "Stopping",
4: "Completed",
5: "Failed",
6: "Stopped",
7: "Processing"
0: "待处理",
1: "启动中",
2: "录制中",
3: "停止中",
4: "已完成",
5: "失败",
6: "已停止",
7: "处理中"
};
export const sessionStatusLabelMap = taskStatusLabelMap;
export const logLevelLabelMap: Record<number, string> = {
0: "Trace",
1: "Info",
2: "Warning",
3: "Error"
0: "跟踪",
1: "信息",
2: "警告",
3: "错误"
};
export const outputFormatLabelMap: Record<number, string> = {
@@ -475,28 +552,41 @@ export const outputFormatLabelMap: Record<number, string> = {
};
export const saveModeLabelMap: Record<number, string> = {
0: "Single File",
1: "Segmented"
0: "单文件",
1: "分段"
};
export const recordingTemplateLabelMap: Record<number, string> = {
0: "Stream Copy",
1: "Balanced MP4",
2: "Archive TS"
0: "直接封装",
1: "均衡 MP4",
2: "归档 TS"
};
export const autoStartDecisionLabelMap: Record<string, string> = {
started: "Started",
skipped_disabled: "Disabled",
skipped_storage: "Low Storage",
skipped_active_session: "Active Session",
skipped_offline: "Offline",
failed_startup: "Startup Failed"
started: "已启动",
skipped_disabled: "已禁用",
skipped_storage: "存储不足",
skipped_active_session: "已有活动会话",
skipped_offline: "房间未开播",
skipped_debounce: "触发防抖中",
failed_startup: "启动失败"
};
export const platformLabelMap: Record<number, string> = {
0: "Unknown",
0: "未知",
1: "Douyin",
2: "Bilibili",
3: "Huya"
};
export const uploadTargetLabelMap: Record<number, string> = {
0: "不上传",
1: "WebDAV",
2: "S3"
};
export const uploadStatusLabelMap: Record<number, string> = {
0: "未上传",
1: "已上传",
2: "上传失败"
};
+14 -14
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useViewport } from "@/composables/useViewport";
@@ -86,10 +86,9 @@ onMounted(loadReport);
<div class="page-stack">
<div class="page-header">
<div>
<h1 class="page-title">回顾日报</h1>
<h1 class="page-title">鍥為鏃ユ姤</h1>
<p class="page-subtitle">
按天聚合录制时长异常和弹幕热度日报只做页面内查看不会主动推送
</p>
鎸夊ぉ鑱氬悎褰曞埗鏃堕暱銆佸紓甯稿拰寮瑰箷鐑害銆傛棩鎶ュ彧鍋氶闈㈠唴鏌ョ湅锛屼笉浼氫富鍔ㄦ帹閫併? </p>
</div>
<el-space wrap class="header-actions">
@@ -102,7 +101,7 @@ onMounted(loadReport);
:disabled-date="disableFutureDates"
@change="loadReport"
/>
<el-button @click="loadReport">刷新日报</el-button>
<el-button @click="loadReport">鍒锋柊鏃ユ姤</el-button>
</el-space>
</div>
@@ -135,7 +134,7 @@ onMounted(loadReport);
<div class="stat-card">
<div class="stat-card__label">警告 / 错误</div>
<div class="stat-card__value">{{ report.summary.warningCount }} / {{ report.summary.errorCount }}</div>
<div class="stat-card__hint">来自当天 Warning / Error 系统日志</div>
<div class="stat-card__hint">来自当天警告 / 错误系统日志</div>
</div>
<div class="stat-card">
<div class="stat-card__label">弹幕事件</div>
@@ -148,7 +147,7 @@ onMounted(loadReport);
<div class="section-header">
<div>
<h3 class="section-title">直播间汇总</h3>
<p class="section-subtitle">每个房间当天的录制规模异常和弹幕数量</p>
<p class="section-subtitle">每个房间当天的录制规模异常数量和弹幕活跃度都会聚合在这里</p>
</div>
</div>
@@ -184,7 +183,7 @@ onMounted(loadReport);
<div class="section-header">
<div>
<h3 class="section-title">重点会话</h3>
<p class="section-subtitle">最长最热和异常最多的会话会出现在这里</p>
<p class="section-subtitle">最长最热和异常最多的会话会集中展示在这里</p>
</div>
</div>
@@ -204,15 +203,15 @@ onMounted(loadReport);
<span>{{ formatDuration(item.durationSeconds) }}</span>
</div>
<div class="highlight-item__stats">
<span>分片 {{ item.segmentCount }}</span>
<span>弹幕 {{ item.danmakuCount }}</span>
<span>警告 {{ item.warningCount }}</span>
<span>错误 {{ item.errorCount }}</span>
<span>鍒嗙墖 {{ item.segmentCount }}</span>
<span>寮瑰箷 {{ item.danmakuCount }}</span>
<span> {{ item.warningCount }}</span>
<span>閿欒 {{ item.errorCount }}</span>
</div>
<div class="highlight-item__summary">{{ item.summary || "-" }}</div>
<div class="highlight-item__footer">
<span>{{ formatDate(item.startedAt || item.endedAt) }}</span>
<el-button size="small" @click="openSession(item.recordSessionId)">查看会话</el-button>
<el-button size="small" @click="openSession(item.recordSessionId)">鏌ョ湅浼氳瘽</el-button>
</div>
</section>
</div>
@@ -222,7 +221,7 @@ onMounted(loadReport);
<div class="section-header">
<div>
<h3 class="section-title">热点时刻</h3>
<p class="section-subtitle">基于弹幕 XML 的分钟级活跃度帮助快速定位当天最热的时间窗</p>
<p class="section-subtitle">基于弹幕 XML 的分钟级活跃度帮助快速定位当天最热的时间窗</p>
</div>
</div>
@@ -385,3 +384,4 @@ onMounted(loadReport);
}
}
</style>
+166 -9
View File
@@ -7,6 +7,7 @@ import type { BatchLiveRoomsResult, ImportLiveRoomsResult, LiveRoom, RecordTask
import {
autoStartDecisionLabelMap,
availabilityLabelMap,
currentRecordingStateLabelMap,
outputFormatLabelMap,
recordingTemplateLabelMap,
saveModeLabelMap
@@ -56,6 +57,11 @@ const recordForm = reactive({
});
const settingsForm = reactive({
remark: "",
isPinned: false,
alias: "",
isPriority: false,
pollingIntervalSecondsOverride: "",
preferredQualityOverride: inheritValue as string | number,
outputFormatOverride: inheritValue as string | number,
saveModeOverride: inheritValue as string | number,
@@ -97,7 +103,7 @@ const tableHeight = computed(() => (isMobile.value ? undefined : 700));
const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px"));
const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
const recordDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 440px)" : "440px"));
const settingsDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
const settingsDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 760px)" : "840px"));
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 46vw, 560px)"));
let autoRefreshTimer: number | null = null;
@@ -311,6 +317,28 @@ function openRecordDialog(room: LiveRoom) {
recordDialogVisible.value = true;
}
async function copyRoomLink(room: LiveRoom) {
const url = room.originalLiveRoomUrl || room.sourceUrl;
try {
await navigator.clipboard.writeText(url);
} catch {
const input = document.createElement("textarea");
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
ElMessage.success("直播间链接已复制。");
}
function openOriginalRoom(room: LiveRoom) {
const url = room.originalLiveRoomUrl || room.sourceUrl;
window.open(url, "_blank", "noopener,noreferrer");
}
async function startRecording() {
startLoading.value = true;
@@ -330,6 +358,11 @@ async function startRecording() {
function openSettingsDialog(room: LiveRoom) {
settingsRoom.value = room;
settingsForm.remark = room.remark ?? "";
settingsForm.isPinned = room.isPinned;
settingsForm.alias = room.alias ?? "";
settingsForm.isPriority = room.isPriority;
settingsForm.pollingIntervalSecondsOverride = nullableNumberToInput(room.pollingIntervalSecondsOverride);
settingsForm.preferredQualityOverride = room.overrides.preferredQuality ?? inheritValue;
settingsForm.outputFormatOverride = room.overrides.outputFormat ?? inheritValue;
settingsForm.saveModeOverride = room.overrides.saveMode ?? inheritValue;
@@ -353,6 +386,14 @@ async function saveRoomSettings() {
settingsLoading.value = true;
try {
await apiClient.put<LiveRoom>(`/live-rooms/${settingsRoom.value.id}/metadata`, {
remark: nullableString(settingsForm.remark),
isPinned: settingsForm.isPinned,
alias: nullableString(settingsForm.alias),
isPriority: settingsForm.isPriority,
pollingIntervalSecondsOverride: parseNullableInt(settingsForm.pollingIntervalSecondsOverride)
});
const { data } = await apiClient.put<LiveRoom>(`/live-rooms/${settingsRoom.value.id}/settings`, {
preferredQualityOverride: nullableStringFromSelect(settingsForm.preferredQualityOverride),
outputFormatOverride: nullableNumberFromSelect(settingsForm.outputFormatOverride),
@@ -446,6 +487,18 @@ function availabilityTagType(status: number) {
return "warning";
}
function currentRecordingStateTagType(state: number) {
if (state === 2) {
return "danger";
}
if (state === 1) {
return "success";
}
return "info";
}
function getRoomAvatarText(room: LiveRoom) {
const source = room.anchorName?.trim() || room.title?.trim() || room.roomId;
return source.slice(0, 1).toUpperCase();
@@ -468,7 +521,7 @@ function autoStartDecisionTagType(code?: string) {
return "danger";
}
if (code === "skipped_storage" || code === "skipped_active_session") {
if (code === "skipped_storage" || code === "skipped_active_session" || code === "skipped_debounce") {
return "warning";
}
@@ -479,6 +532,17 @@ function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatPollingInterval(override?: number) {
return typeof override === "number" && Number.isFinite(override)
? `${override}`
: "跟随全局";
}
function nullableString(value: string) {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function nullableBooleanToInput(value?: boolean | null) {
if (value === true) {
return "true";
@@ -540,7 +604,7 @@ onBeforeUnmount(() => {
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Live Monitoring</div>
<div class="page-kicker">直播监控</div>
<h1 class="page-title">直播间管理</h1>
<p class="page-subtitle">
直播间会长期保留在系统里单房间配置优先于全局配置未设置的项会自动回退到系统设置
@@ -606,15 +670,19 @@ onBeforeUnmount(() => {
<div>
<div class="data-card__title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
<div v-if="row.alias && row.alias !== row.anchorName" class="cell-subtitle">别名{{ row.alias }}</div>
<div v-if="row.remark" class="cell-subtitle">{{ row.remark }}</div>
<div class="cell-mono">{{ row.roomId }}</div>
</div>
</div>
<div class="badge-row">
<el-tag :type="availabilityTagType(row.availabilityStatus)">
{{ availabilityLabelMap[row.availabilityStatus] }}
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)">
{{ currentRecordingStateLabelMap[row.currentRecordingState] }}
</el-tag>
<el-tag effect="plain">{{ row.platformName }}</el-tag>
<el-tag v-if="row.isPinned" size="small" effect="plain">置顶</el-tag>
<el-tag v-if="row.isPriority" size="small" effect="plain" type="danger">重点</el-tag>
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
@@ -629,6 +697,10 @@ onBeforeUnmount(() => {
<dt>最近巡检</dt>
<dd>{{ formatDate(row.lastCheckedAt) }}</dd>
</div>
<div>
<dt>检测间隔</dt>
<dd>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>房间配置</dt>
<dd>
@@ -659,6 +731,8 @@ onBeforeUnmount(() => {
<div class="data-card__actions">
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
录制
</el-button>
@@ -691,6 +765,13 @@ onBeforeUnmount(() => {
<template #default="{ row }">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div v-if="row.alias && row.alias !== row.anchorName" class="cell-subtitle">别名{{ row.alias }}</div>
<div v-if="row.remark" class="cell-subtitle">{{ row.remark }}</div>
<div class="config-summary">
<span v-if="row.isPinned">置顶</span>
<span v-if="row.isPriority">重点</span>
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
</div>
<div class="monospace cell-mono">{{ row.roomId }}</div>
</template>
</el-table-column>
@@ -703,8 +784,8 @@ onBeforeUnmount(() => {
<el-table-column label="直播状态" width="120">
<template #default="{ row }">
<el-tag :type="availabilityTagType(row.availabilityStatus)">
{{ availabilityLabelMap[row.availabilityStatus] }}
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)">
{{ currentRecordingStateLabelMap[row.currentRecordingState] }}
</el-tag>
</template>
</el-table-column>
@@ -769,6 +850,8 @@ onBeforeUnmount(() => {
<div class="room-actions-cell">
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
开始录制
</el-button>
@@ -935,6 +1018,34 @@ onBeforeUnmount(() => {
</div>
<el-form label-position="top" class="settings-grid">
<div class="settings-section-title">房间信息</div>
<el-form-item label="备注">
<el-input v-model="settingsForm.remark" placeholder="给这个直播间补充说明,便于识别" />
</el-form-item>
<el-form-item label="主播别名">
<el-input v-model="settingsForm.alias" placeholder="默认跟随首次解析到的主播昵称" />
</el-form-item>
<el-form-item label="单房间检测间隔(秒)">
<el-input
v-model="settingsForm.pollingIntervalSecondsOverride"
placeholder="留空则跟随全局巡检间隔"
/>
</el-form-item>
<div class="settings-toggle-pair">
<div class="settings-toggle-item">
<span class="settings-toggle-item__label">置顶常用直播间</span>
<el-switch v-model="settingsForm.isPinned" />
</div>
<div class="settings-toggle-item">
<span class="settings-toggle-item__label">重点主播</span>
<el-switch v-model="settingsForm.isPriority" />
</div>
</div>
<div class="settings-section-title">录制覆盖</div>
<el-form-item label="默认画质">
<el-select v-model="settingsForm.preferredQualityOverride">
<el-option label="跟随全局" :value="inheritValue" />
@@ -1242,8 +1353,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
flex-wrap: wrap;
}
.room-actions-cell :deep(.el-button) {
@@ -1267,6 +1377,49 @@ onBeforeUnmount(() => {
gap: 0 16px;
}
.settings-section-title {
grid-column: 1 / -1;
margin: 4px 0 8px;
padding-top: 8px;
border-top: 1px solid var(--border-subtle);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.settings-section-title:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.settings-toggle-pair {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-toggle-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 52px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.settings-toggle-item__label {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
}
.dialog-form {
display: grid;
}
@@ -1377,6 +1530,10 @@ onBeforeUnmount(() => {
.settings-grid {
grid-template-columns: 1fr;
}
.settings-toggle-pair {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
+3 -3
View File
@@ -71,7 +71,7 @@ onMounted(loadLogs);
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Diagnostics</div>
<div class="page-kicker">排障中心</div>
<h1 class="page-title">系统日志</h1>
<p class="page-subtitle">
面向排障的原始事件流这里保留最近发生的关键日志可以按直播间任务和级别快速收缩范围
@@ -92,12 +92,12 @@ onMounted(loadLogs);
<div class="stat-card__hint">按当前筛选条件拉取到的日志条目</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Warning</div>
<div class="stat-card__label">警告</div>
<div class="stat-card__value">{{ totalWarnings }}</div>
<div class="stat-card__hint">用于快速观察近期是否出现成批预警</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Error</div>
<div class="stat-card__label">错误</div>
<div class="stat-card__value">{{ totalErrors }}</div>
<div class="stat-card__hint">优先定位对录制流程有破坏性的异常事件</div>
</div>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useViewport } from "@/composables/useViewport";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type {
RecordArtifactUploadBatchResult,
RecordSessionDetail,
RecordSessionTimelineEvent,
RecordSessionHeatBucket,
@@ -26,6 +28,7 @@ const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const uploadLoading = ref(false);
const loadError = ref("");
const detail = ref<RecordSessionDetail | null>(null);
const visibleLayers = ref(["session", "segments", "processing", "danmaku", "automation"]);
@@ -60,6 +63,21 @@ async function loadDetail() {
}
}
async function uploadSessionArtifacts() {
uploadLoading.value = true;
try {
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${props.id}/upload`);
const message = `会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}`;
ElMessage[data.failedCount === 0 ? "success" : "warning"](message);
await loadDetail();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "会话上传失败,请稍后重试。"));
} finally {
uploadLoading.value = false;
}
}
function filterTimelineEvents(layer: string) {
if (!detail.value || !visibleLayers.value.includes(layer)) {
return [];
@@ -222,6 +240,7 @@ onMounted(loadDetail);
<el-space class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
<el-button @click="loadDetail">刷新</el-button>
<el-button :loading="uploadLoading" @click="uploadSessionArtifacts">上传会话</el-button>
</el-space>
</div>
+40 -2
View File
@@ -4,12 +4,13 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { RecordPreviewTicket, RecordTaskDetail } from "@/types";
import type { RecordArtifactUploadItemResult, RecordPreviewTicket, RecordTaskDetail } from "@/types";
import {
logLevelLabelMap,
outputFormatLabelMap,
sessionStatusLabelMap,
taskStatusLabelMap
taskStatusLabelMap,
uploadStatusLabelMap
} from "@/types";
const props = defineProps<{
@@ -20,6 +21,7 @@ const router = useRouter();
const loading = ref(false);
const previewLoading = ref(false);
const manualTranscodeLoading = ref(false);
const uploadLoading = ref(false);
const detail = ref<RecordTaskDetail | null>(null);
const loadError = ref("");
const previewUrl = ref("");
@@ -127,6 +129,20 @@ async function startManualTranscode() {
}
}
async function uploadTaskArtifacts() {
uploadLoading.value = true;
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${props.id}/upload`);
ElMessage[data.success ? "success" : "warning"](data.message);
await loadDetailAndPreview();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "上传任务文件失败,请稍后重试。"));
} finally {
uploadLoading.value = false;
}
}
function statusTagType(status: number) {
if (status === 2) {
return "success";
@@ -204,6 +220,7 @@ onMounted(loadDetailAndPreview);
<el-space class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
<el-button @click="loadDetailAndPreview">刷新</el-button>
<el-button :loading="uploadLoading" @click="uploadTaskArtifacts">上传文件</el-button>
</el-space>
</div>
@@ -292,6 +309,27 @@ onMounted(loadDetailAndPreview);
<el-descriptions-item label="最终状态">
{{ detail.result ? sessionStatusLabelMap[detail.result.finalStatus] : "-" }}
</el-descriptions-item>
<el-descriptions-item label="上传状态">
{{ detail.result ? uploadStatusLabelMap[detail.result.uploadStatus] : "-" }}
</el-descriptions-item>
<el-descriptions-item label="上传提供方">
{{ detail.result?.lastUploadProvider || "-" }}
</el-descriptions-item>
<el-descriptions-item label="远端视频路径">
<span class="monospace">{{ detail.result?.remoteVideoPath || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="远端弹幕路径">
<span class="monospace">{{ detail.result?.remoteDanmakuPath || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="最近上传时间">
{{ formatDate(detail.result?.lastUploadedAt) }}
</el-descriptions-item>
<el-descriptions-item label="上传错误">
{{ detail.result?.uploadErrorMessage || "-" }}
</el-descriptions-item>
<el-descriptions-item label="上传后删本地">
{{ detail.result?.deletedLocalFilesAfterUpload ? "是" : "否" }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDate(detail.result?.createdAt) }}
</el-descriptions-item>
+62 -2
View File
@@ -9,7 +9,13 @@ import apiClient, {
} from "@/api/client";
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
import { useViewport } from "@/composables/useViewport";
import type { DeleteCompletedRecordTasksResult, RecordSession, RecordTask } from "@/types";
import type {
DeleteCompletedRecordTasksResult,
RecordArtifactUploadBatchResult,
RecordArtifactUploadItemResult,
RecordSession,
RecordTask
} from "@/types";
import {
outputFormatLabelMap,
saveModeLabelMap,
@@ -21,6 +27,8 @@ const router = useRouter();
const loading = ref(false);
const deleting = ref(false);
const stoppingSessionId = ref<string | null>(null);
const uploadingSessionId = ref<string | null>(null);
const uploadingTaskId = ref<string | null>(null);
const deleteDialogVisible = ref(false);
const deleteDialogMode = ref<"tasks" | "sessions">("tasks");
const deleteDialogTaskIds = ref<string[]>([]);
@@ -211,6 +219,36 @@ async function stopSession(session: RecordSession) {
}
}
async function uploadSession(session: RecordSession) {
uploadingSessionId.value = session.id;
try {
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${session.id}/upload`);
ElMessage[data.failedCount === 0 ? "success" : "warning"](
`会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}`
);
await loadSessions({ resetPanels: false, resetSelection: false });
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "会话上传失败,请稍后重试。"));
} finally {
uploadingSessionId.value = null;
}
}
async function uploadTask(task: RecordTask) {
uploadingTaskId.value = task.id;
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${task.id}/upload`);
ElMessage[data.success ? "success" : "warning"](data.message);
await loadSessions({ resetPanels: false, resetSelection: false });
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "分片上传失败,请稍后重试。"));
} finally {
uploadingTaskId.value = null;
}
}
function openDetail(task: RecordTask) {
router.push({ name: "record-task-detail", params: { id: task.id } });
}
@@ -391,7 +429,7 @@ onBeforeUnmount(() => {
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Recording Workspace</div>
<div class="page-kicker">录制工作台</div>
<h1 class="page-title">录制任务</h1>
<p class="page-subtitle">
按直播会话聚合展示分片任务列表会自动接收状态转码进度和分片变更保留手动刷新入口用于兜底
@@ -498,6 +536,9 @@ onBeforeUnmount(() => {
<div class="data-card__actions">
<el-button size="small" @click.stop="openSessionDetail(session)">查看会话</el-button>
<el-button size="small" :loading="uploadingSessionId === session.id" @click.stop="uploadSession(session)">
上传会话
</el-button>
<el-button
v-if="isActiveStatus(session.status)"
size="small"
@@ -552,6 +593,14 @@ onBeforeUnmount(() => {
<div class="data-card__actions">
<el-button size="small" @click="openDetail(task)">详情</el-button>
<el-button
v-if="isDeletableTask(task)"
size="small"
:loading="uploadingTaskId === task.id"
@click="uploadTask(task)"
>
上传
</el-button>
<el-button
v-if="isDeletableTask(task)"
size="small"
@@ -621,6 +670,9 @@ onBeforeUnmount(() => {
<el-button @click.stop="openSessionDetail(session)">
查看会话
</el-button>
<el-button :loading="uploadingSessionId === session.id" @click.stop="uploadSession(session)">
上传会话
</el-button>
<el-button
v-if="isActiveStatus(session.status)"
type="danger"
@@ -705,6 +757,14 @@ onBeforeUnmount(() => {
<template #default="{ row }">
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button
v-if="isDeletableTask(row)"
size="small"
:loading="uploadingTaskId === row.id"
@click="uploadTask(row)"
>
上传
</el-button>
<el-button
v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex"
size="small"
+1 -1
View File
@@ -164,7 +164,7 @@ onMounted(loadOverview);
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">Recovery</div>
<div class="page-kicker">恢复处理</div>
<h1 class="page-title">恢复中心</h1>
<p class="page-subtitle">
这里集中处理没有自动拉起的直播间和可继续的 MP4 转码任务上方的存储状态直接复用当前输出目录的保护检查结果
+297
View File
@@ -17,6 +17,9 @@ const saving = ref(false);
const testingEmail = ref(false);
const testingWebhook = ref(false);
const runningRetentionCleanup = ref(false);
const exportingSettings = ref(false);
const importingSettings = ref(false);
const settingsImportInput = ref<HTMLInputElement | null>(null);
const scriptTesting = reactive<Record<ScriptEventType, boolean>>({
live_started: false,
live_ended: false,
@@ -59,6 +62,38 @@ const form = reactive<SystemSettings>({
enableBackgroundPolling: true,
autoStartRecordingOnLive: true,
pollingIntervalSeconds: 60,
useAliasForStorage: false,
enableFileUpload: false,
enableAutoUpload: false,
deleteLocalFilesAfterUpload: false,
uploadTarget: 0,
douyinProxy: {
enabled: false,
proxyUrl: ""
},
bilibiliProxy: {
enabled: false,
proxyUrl: ""
},
huyaProxy: {
enabled: false,
proxyUrl: ""
},
webDavUpload: {
endpoint: "",
basePath: "",
username: "",
password: ""
},
s3Upload: {
endpoint: "",
bucket: "",
region: "",
accessKey: "",
secretKey: "",
prefix: "",
forcePathStyle: false
},
enableEventScripts: false,
liveStartedScriptMode: "path",
liveStartedScriptPath: "",
@@ -189,6 +224,12 @@ const eventScriptModeOptions = [
{ label: "脚本文本", value: "inline" }
];
const uploadTargetOptions = [
{ label: "不上传", value: 0 },
{ label: "WebDAV", value: 1 },
{ label: "S3", value: 2 }
];
const canSendTestEmail = computed(() =>
Boolean(form.emailSmtpHost.trim() && form.emailFromAddress.trim() && form.emailToAddresses.trim())
);
@@ -314,6 +355,57 @@ async function runRetentionCleanup() {
}
}
async function exportSettingsBackup() {
exportingSettings.value = true;
try {
const { data, headers } = await apiClient.get<Blob>("/settings/export", {
responseType: "blob"
});
const blob = data instanceof Blob ? data : new Blob([data], { type: "application/json;charset=utf-8" });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = parseDownloadFileName(headers["content-disposition"]) ?? `live-recorder-settings-${Date.now()}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
ElMessage.success("系统设置备份已导出。");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
} finally {
exportingSettings.value = false;
}
}
function triggerImportSettings() {
settingsImportInput.value?.click();
}
async function importSettingsBackup(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) {
return;
}
importingSettings.value = true;
try {
const payload = JSON.parse(await file.text());
const { data } = await apiClient.post<SystemSettings>("/settings/import", payload);
Object.assign(form, data);
ElMessage.success("系统设置已从备份导入。");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
} finally {
importingSettings.value = false;
}
}
function buildScriptTestRequest(eventType: ScriptEventType) {
if (eventType === "live_started") {
return {
@@ -366,6 +458,20 @@ function scriptTestStateType(result?: EventScriptTestResult | null) {
return result?.success ? "success" : "warning";
}
function parseDownloadFileName(contentDisposition?: string) {
if (!contentDisposition) {
return null;
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1]);
}
const plainMatch = contentDisposition.match(/filename=\"?([^\"]+)\"?/i);
return plainMatch?.[1] ?? null;
}
onMounted(loadSettings);
</script>
@@ -379,8 +485,20 @@ onMounted(loadSettings);
</p>
</div>
<div class="page-toolbar">
<el-button :loading="exportingSettings" @click="exportSettingsBackup">导出配置</el-button>
<el-button :loading="importingSettings" @click="triggerImportSettings">导入配置</el-button>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
</div>
<input
ref="settingsImportInput"
type="file"
accept="application/json,.json"
class="settings-import-input"
@change="importSettingsBackup"
/>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
@@ -664,6 +782,181 @@ onMounted(loadSettings);
</el-form>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">上传与归档</h3>
<p class="section-subtitle">自动或手动把视频和对应弹幕 XML 上传到单一目标端并按开关决定是否在成功后删除本地文件</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用上传">
<el-switch v-model="form.enableFileUpload" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="自动上传">
<el-switch v-model="form.enableAutoUpload" :disabled="!form.enableFileUpload" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="上传后删本地">
<el-switch v-model="form.deleteLocalFilesAfterUpload" :disabled="!form.enableFileUpload" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="别名用于目录">
<el-switch v-model="form.useAliasForStorage" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="上传目标">
<el-select v-model="form.uploadTarget" :disabled="!form.enableFileUpload">
<el-option
v-for="option in uploadTargetOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div v-if="form.enableFileUpload && form.uploadTarget === 1" class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">WebDAV 目标</h4>
<p class="template-section__subtitle">按录制相对路径创建远端目录并上传视频与弹幕文件</p>
</div>
</div>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Endpoint">
<el-input v-model="form.webDavUpload.endpoint" placeholder="https://dav.example.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="基础路径">
<el-input v-model="form.webDavUpload.basePath" placeholder="/live-recorder" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用户名">
<el-input v-model="form.webDavUpload.username" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="密码">
<el-input v-model="form.webDavUpload.password" type="password" show-password />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<div v-if="form.enableFileUpload && form.uploadTarget === 2" class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">S3 目标</h4>
<p class="template-section__subtitle">支持自定义 EndpointBucketRegion 和路径前缀适合对象存储兼容服务</p>
</div>
</div>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Endpoint">
<el-input v-model="form.s3Upload.endpoint" placeholder="https://s3.example.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Bucket">
<el-input v-model="form.s3Upload.bucket" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Region">
<el-input v-model="form.s3Upload.region" placeholder="auto / us-east-1" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="前缀">
<el-input v-model="form.s3Upload.prefix" placeholder="live-recorder/" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Access Key">
<el-input v-model="form.s3Upload.accessKey" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Secret Key">
<el-input v-model="form.s3Upload.secretKey" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Force Path Style">
<el-switch v-model="form.s3Upload.forcePathStyle" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<div class="helper-panel">
自动上传固定处理视频文件 + 对应弹幕 XML只有两者都上传成功并且你打开上传后删本地系统才会清理本地文件
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">平台代理</h3>
<p class="section-subtitle">代理仅作用于平台状态查询取流和平台侧请求不影响邮件Webhook 和文件上传</p>
<div class="event-script-grid">
<div class="event-script-section">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">Douyin</h4>
<p class="event-script-section__subtitle">适合单独给抖音状态查询和取流请求走代理</p>
</div>
<el-switch v-model="form.douyinProxy.enabled" />
</div>
<el-form-item label="代理地址">
<el-input v-model="form.douyinProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
<div class="event-script-section">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">Bilibili</h4>
<p class="event-script-section__subtitle">适合单独给 Bilibili 平台请求启用或关闭代理</p>
</div>
<el-switch v-model="form.bilibiliProxy.enabled" />
</div>
<el-form-item label="代理地址">
<el-input v-model="form.bilibiliProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
<div class="event-script-section event-script-section--full">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">Huya</h4>
<p class="event-script-section__subtitle">虎牙平台单独配置不会和其它平台共用代理状态</p>
</div>
<el-switch v-model="form.huyaProxy.enabled" />
</div>
<el-form-item label="代理地址">
<el-input v-model="form.huyaProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
<p class="section-subtitle">
@@ -1092,6 +1385,10 @@ onMounted(loadSettings);
border-radius: 14px;
}
.settings-import-input {
display: none;
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -55,6 +55,8 @@ public interface IRecordSessionRepository
Task<IReadOnlyList<RecordSession>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<Guid>> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default);
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
@@ -12,5 +12,7 @@ public static class AutoStartDecisionCodes
public const string SkippedOffline = "skipped_offline";
public const string SkippedDebounce = "skipped_debounce";
public const string FailedStartup = "failed_startup";
}
@@ -108,6 +108,18 @@ public sealed class LiveRoomDto
public string? CoverUrl { get; init; }
public string? Remark { get; init; }
public bool IsPinned { get; init; }
public string? Alias { get; init; }
public bool IsPriority { get; init; }
public int? PollingIntervalSecondsOverride { get; init; }
public required string OriginalLiveRoomUrl { get; init; }
public required LiveRoomSettingsOverridesDto Overrides { get; init; }
public required LiveRoomEffectiveSettingsDto EffectiveSettings { get; init; }
@@ -116,6 +128,8 @@ public sealed class LiveRoomDto
public required LiveRoomAvailabilityStatus AvailabilityStatus { get; init; }
public required LiveRoomCurrentRecordingState CurrentRecordingState { get; init; }
public string? LastAutoStartDecisionCode { get; init; }
public string? LastAutoStartDecisionSummary { get; init; }
@@ -131,6 +145,19 @@ public sealed class LiveRoomDto
public DateTimeOffset UpdatedAt { get; init; }
}
public sealed class UpdateLiveRoomMetadataRequest
{
public string? Remark { get; set; }
public bool IsPinned { get; set; }
public string? Alias { get; set; }
public bool IsPriority { get; set; }
public int? PollingIntervalSecondsOverride { get; set; }
}
public sealed class SetLiveRoomEnabledRequest
{
public bool IsEnabled { get; set; }
@@ -30,6 +30,20 @@ public sealed class RecordResultDto
public string? ErrorMessage { get; init; }
public required RecordArtifactUploadStatus UploadStatus { get; init; }
public string? LastUploadProvider { get; init; }
public string? RemoteVideoPath { get; init; }
public string? RemoteDanmakuPath { get; init; }
public DateTimeOffset? LastUploadedAt { get; init; }
public string? UploadErrorMessage { get; init; }
public bool DeletedLocalFilesAfterUpload { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
@@ -113,3 +127,31 @@ public sealed class RecordPreviewTicketDto
public DateTimeOffset ExpiresAt { get; init; }
}
public sealed class RecordArtifactUploadItemResultDto
{
public Guid RecordTaskId { get; init; }
public bool Success { get; init; }
public required string Message { get; init; }
public string? Provider { get; init; }
public string? RemoteVideoPath { get; init; }
public string? RemoteDanmakuPath { get; init; }
public bool DeletedLocalFilesAfterUpload { get; init; }
}
public sealed class RecordArtifactUploadBatchResultDto
{
public int RequestedCount { get; init; }
public int SuccessCount { get; init; }
public int FailedCount { get; init; }
public required IReadOnlyList<RecordArtifactUploadItemResultDto> Items { get; init; }
}
@@ -9,6 +9,41 @@ public static class EventScriptSourceModes
public const string Inline = "inline";
}
public sealed class PlatformProxySettingsDto
{
public bool Enabled { get; set; }
public string ProxyUrl { get; set; } = string.Empty;
}
public sealed class WebDavUploadSettingsDto
{
public string Endpoint { get; set; } = string.Empty;
public string BasePath { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public sealed class S3UploadSettingsDto
{
public string Endpoint { get; set; } = string.Empty;
public string Bucket { get; set; } = string.Empty;
public string Region { get; set; } = string.Empty;
public string AccessKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string Prefix { get; set; } = string.Empty;
public bool ForcePathStyle { get; set; }
}
public sealed class SystemSettingsDto
{
public string FfmpegPath { get; set; } = "ffmpeg";
@@ -59,6 +94,26 @@ public sealed class SystemSettingsDto
public int PollingIntervalSeconds { get; set; } = 60;
public bool UseAliasForStorage { get; set; }
public bool EnableFileUpload { get; set; }
public bool EnableAutoUpload { get; set; }
public bool DeleteLocalFilesAfterUpload { get; set; }
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
public S3UploadSettingsDto S3Upload { get; set; } = new();
public bool EnableEventScripts { get; set; } = false;
public string LiveStartedScriptMode { get; set; } = EventScriptSourceModes.Path;
@@ -214,6 +269,26 @@ public sealed class UpdateSystemSettingsRequest
public int PollingIntervalSeconds { get; set; } = 60;
public bool UseAliasForStorage { get; set; }
public bool EnableFileUpload { get; set; }
public bool EnableAutoUpload { get; set; }
public bool DeleteLocalFilesAfterUpload { get; set; }
public UploadTargetType UploadTarget { get; set; } = UploadTargetType.None;
public PlatformProxySettingsDto DouyinProxy { get; set; } = new();
public PlatformProxySettingsDto BilibiliProxy { get; set; } = new();
public PlatformProxySettingsDto HuyaProxy { get; set; } = new();
public WebDavUploadSettingsDto WebDavUpload { get; set; } = new();
public S3UploadSettingsDto S3Upload { get; set; } = new();
public bool EnableEventScripts { get; set; } = false;
public string LiveStartedScriptMode { get; set; } = EventScriptSourceModes.Path;
@@ -431,3 +506,8 @@ public sealed class RetentionCleanupResultDto
public required IReadOnlyList<string> Warnings { get; init; }
}
public sealed class ImportSystemSettingsRequest
{
public SystemSettingsDto? Settings { get; set; }
}
@@ -14,6 +14,7 @@ namespace LiveRecorder.Application.Services;
public sealed class LiveRoomService
{
private readonly ILiveRoomRepository _liveRoomRepository;
private readonly IRecordSessionRepository _recordSessionRepository;
private readonly ILivePlatformAdapterFactory _livePlatformAdapterFactory;
private readonly LiveRoomStatusService _liveRoomStatusService;
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
@@ -25,6 +26,7 @@ public sealed class LiveRoomService
public LiveRoomService(
ILiveRoomRepository liveRoomRepository,
IRecordSessionRepository recordSessionRepository,
ILivePlatformAdapterFactory livePlatformAdapterFactory,
LiveRoomStatusService liveRoomStatusService,
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
@@ -35,6 +37,7 @@ public sealed class LiveRoomService
ISystemLogService systemLogService)
{
_liveRoomRepository = liveRoomRepository;
_recordSessionRepository = recordSessionRepository;
_livePlatformAdapterFactory = livePlatformAdapterFactory;
_liveRoomStatusService = liveRoomStatusService;
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
@@ -49,9 +52,13 @@ public sealed class LiveRoomService
{
var rooms = await _liveRoomRepository.ListAsync(cancellationToken);
var effectiveSettings = await BuildEffectiveSettingsLookupAsync(rooms, cancellationToken);
var activeLiveRoomIds = await _recordSessionRepository.ListActiveLiveRoomIdsAsync(cancellationToken);
return rooms
.OrderByDescending(static item => item.UpdatedAt)
.Select(item => Map(item, effectiveSettings[item.Id]))
.OrderByDescending(static item => item.IsPinned)
.ThenByDescending(static item => item.IsPriority)
.ThenByDescending(item => GetCurrentRecordingState(item, activeLiveRoomIds.Contains(item.Id)))
.ThenByDescending(static item => item.UpdatedAt)
.Select(item => Map(item, effectiveSettings[item.Id], activeLiveRoomIds.Contains(item.Id)))
.ToList();
}
@@ -64,7 +71,8 @@ public sealed class LiveRoomService
}
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
return Map(room, effectiveSettings, hasActiveSession);
}
public async Task<LiveRoomDto> CreateAsync(CreateLiveRoomRequest request, CancellationToken cancellationToken = default)
@@ -77,7 +85,8 @@ public sealed class LiveRoomService
request.AnchorName,
cancellationToken);
return Map(liveRoom, effectiveSettings);
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null;
return Map(liveRoom, effectiveSettings, hasActiveSession);
}
public async Task<ImportLiveRoomsResultDto> ImportAsync(
@@ -130,7 +139,10 @@ public sealed class LiveRoomService
AnchorName = anchorName,
Success = true,
Created = created,
LiveRoom = Map(liveRoom, effectiveSettings)
LiveRoom = Map(
liveRoom,
effectiveSettings,
await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken) is not null)
});
}
catch (Exception ex)
@@ -247,7 +259,8 @@ public sealed class LiveRoomService
await TryAutoStartRecordingAsync(room, liveStatus, cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
return Map(room, effectiveSettings, hasActiveSession);
}
public async Task<LiveRoomDto> SetEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default)
@@ -268,7 +281,8 @@ public sealed class LiveRoomService
cancellationToken: cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
return Map(room, effectiveSettings, hasActiveSession);
}
public async Task<BatchLiveRoomsResultDto> SetEnabledBatchAsync(
@@ -340,7 +354,40 @@ public sealed class LiveRoomService
cancellationToken: cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
return Map(room, effectiveSettings);
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
return Map(room, effectiveSettings, hasActiveSession);
}
public async Task<LiveRoomDto> UpdateMetadataAsync(
Guid id,
UpdateLiveRoomMetadataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var room = await _liveRoomRepository.GetByIdAsync(id, cancellationToken)
?? throw new KeyNotFoundException("Live room was not found.");
room.UpdateManagementMetadata(
request.Remark,
request.IsPinned,
request.Alias,
request.IsPriority,
ClampNullable(request.PollingIntervalSecondsOverride, 10, 3600),
DateTimeOffset.UtcNow);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"LiveRoom",
$"Live room metadata updated for room {room.RoomId}.",
liveRoomId: room.Id,
cancellationToken: cancellationToken);
var effectiveSettings = await _liveRoomRecordingSettingsResolver.ResolveAsync(room, cancellationToken);
var hasActiveSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(room.Id, cancellationToken) is not null;
return Map(room, effectiveSettings, hasActiveSession);
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
@@ -470,7 +517,7 @@ public sealed class LiveRoomService
}
}
private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings) => new()
private LiveRoomDto Map(LiveRoom room, RecordingExecutionSettings effectiveSettings, bool hasActiveSession) => new()
{
Id = room.Id,
Platform = room.Platform,
@@ -483,10 +530,17 @@ public sealed class LiveRoomService
AnchorId = room.AnchorId,
AvatarUrl = room.AvatarUrl,
CoverUrl = room.CoverUrl,
Remark = room.Remark,
IsPinned = room.IsPinned,
Alias = room.Alias,
IsPriority = room.IsPriority,
PollingIntervalSecondsOverride = room.PollingIntervalSecondsOverride,
OriginalLiveRoomUrl = string.IsNullOrWhiteSpace(room.NormalizedUrl) ? room.SourceUrl : room.NormalizedUrl,
Overrides = _liveRoomRecordingSettingsResolver.BuildOverridesDto(room),
EffectiveSettings = _liveRoomRecordingSettingsResolver.BuildEffectiveDto(effectiveSettings),
IsEnabled = room.IsEnabled,
AvailabilityStatus = room.AvailabilityStatus,
CurrentRecordingState = GetCurrentRecordingState(room, hasActiveSession),
LastAutoStartDecisionCode = room.LastAutoStartDecisionCode,
LastAutoStartDecisionSummary = room.LastAutoStartDecisionSummary,
LastAutoStartDecisionDetail = room.LastAutoStartDecisionDetail,
@@ -496,6 +550,18 @@ public sealed class LiveRoomService
UpdatedAt = room.UpdatedAt
};
private static LiveRoomCurrentRecordingState GetCurrentRecordingState(LiveRoom room, bool hasActiveSession)
{
if (room.AvailabilityStatus != LiveRoomAvailabilityStatus.Live)
{
return LiveRoomCurrentRecordingState.Offline;
}
return hasActiveSession
? LiveRoomCurrentRecordingState.Recording
: LiveRoomCurrentRecordingState.Live;
}
private static int? ClampNullable(int? value, int min, int max) =>
value.HasValue ? Math.Clamp(value.Value, min, max) : null;
@@ -42,6 +42,13 @@ internal static class RecordModelMapper
DanmakuMessageCount = recordResult.DanmakuMessageCount,
FinalStatus = recordResult.FinalStatus,
ErrorMessage = recordResult.ErrorMessage,
UploadStatus = recordResult.UploadStatus,
LastUploadProvider = recordResult.LastUploadProvider,
RemoteVideoPath = recordResult.RemoteVideoPath,
RemoteDanmakuPath = recordResult.RemoteDanmakuPath,
LastUploadedAt = recordResult.LastUploadedAt,
UploadErrorMessage = recordResult.UploadErrorMessage,
DeletedLocalFilesAfterUpload = recordResult.DeletedLocalFilesAfterUpload,
CreatedAt = recordResult.CreatedAt
};
@@ -15,6 +15,8 @@ namespace LiveRecorder.Application.Services;
public sealed class RecordService
{
private static readonly TimeSpan StartRecordingDebounceWindow = TimeSpan.FromSeconds(10);
private readonly ILiveRoomRepository _liveRoomRepository;
private readonly IRecordSessionRepository _recordSessionRepository;
private readonly IRecordTaskRepository _recordTaskRepository;
@@ -146,6 +148,34 @@ public sealed class RecordService
throw new InvalidOperationException("The live room is disabled. Enable it before starting a recording.");
}
var debounceTriggeredAt = DateTimeOffset.UtcNow;
if (liveRoom.LastStartRecordingTriggeredAt.HasValue &&
debounceTriggeredAt - liveRoom.LastStartRecordingTriggeredAt.Value < StartRecordingDebounceWindow)
{
if (trackAutoStartDecision)
{
await UpdateAutoStartDecisionAsync(
liveRoom,
AutoStartDecisionCodes.SkippedDebounce,
"Auto-start skipped because start recording debounce is active.",
$"lastTriggeredAt={liveRoom.LastStartRecordingTriggeredAt:O}",
cancellationToken);
}
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"RecordSession",
"Recording start skipped because the debounce window is still active.",
$"windowSeconds={(int)StartRecordingDebounceWindow.TotalSeconds}",
liveRoomId: liveRoom.Id,
cancellationToken: cancellationToken);
throw new InvalidOperationException("Recording start was triggered too recently. Please wait a few seconds and try again.");
}
liveRoom.MarkStartRecordingTriggered(debounceTriggeredAt);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await ReconcileActiveSessionsAsync(liveRoom.Id, cancellationToken);
var activeSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken);
if (activeSession is not null)
@@ -204,6 +234,7 @@ public sealed class RecordService
var outputFormat = request.OutputFormat ?? effectiveSettings.OutputFormat;
var saveMode = effectiveSettings.SaveMode;
var now = DateTimeOffset.UtcNow;
var storageAnchorName = GetStorageAnchorName(liveRoom, settings);
var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
await _recordSessionRepository.AddAsync(recordSession, cancellationToken);
@@ -251,7 +282,7 @@ public sealed class RecordService
settings.OutputFileNameTemplate,
liveRoom.Platform,
liveRoom.RoomId,
liveRoom.AnchorName,
storageAnchorName,
liveRoom.Title,
outputFormat,
saveMode,
@@ -774,6 +805,18 @@ public sealed class RecordService
return Path.Combine(folder, $"{fileNameStem}.{extension}");
}
private static string? GetStorageAnchorName(LiveRoom liveRoom, Application.Models.Settings.SystemSettingsDto settings)
{
if (!settings.UseAliasForStorage)
{
return liveRoom.AnchorName;
}
return string.IsNullOrWhiteSpace(liveRoom.Alias)
? liveRoom.AnchorName
: liveRoom.Alias;
}
internal static string ResolveSegmentOutputPath(
string outputPathPattern,
RecordOutputFormat outputFormat,
@@ -32,6 +32,28 @@ public sealed class SystemSettingsService : ISystemSettingsService
private const string EnableBackgroundPollingKey = "scheduler.enable_background_polling";
private const string AutoStartRecordingOnLiveKey = "scheduler.auto_start_recording_on_live";
private const string PollingIntervalSecondsKey = "scheduler.polling_interval_seconds";
private const string UseAliasForStorageKey = "recording.use_alias_for_storage";
private const string EnableFileUploadKey = "upload.enabled";
private const string EnableAutoUploadKey = "upload.auto_upload";
private const string DeleteLocalFilesAfterUploadKey = "upload.delete_local_files_after_upload";
private const string UploadTargetKey = "upload.target";
private const string WebDavEndpointKey = "upload.webdav.endpoint";
private const string WebDavBasePathKey = "upload.webdav.base_path";
private const string WebDavUsernameKey = "upload.webdav.username";
private const string WebDavPasswordKey = "upload.webdav.password";
private const string S3EndpointKey = "upload.s3.endpoint";
private const string S3BucketKey = "upload.s3.bucket";
private const string S3RegionKey = "upload.s3.region";
private const string S3AccessKeyKey = "upload.s3.access_key";
private const string S3SecretKeyKey = "upload.s3.secret_key";
private const string S3PrefixKey = "upload.s3.prefix";
private const string S3ForcePathStyleKey = "upload.s3.force_path_style";
private const string DouyinProxyEnabledKey = "platform_proxy.douyin.enabled";
private const string DouyinProxyUrlKey = "platform_proxy.douyin.url";
private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled";
private const string BilibiliProxyUrlKey = "platform_proxy.bilibili.url";
private const string HuyaProxyEnabledKey = "platform_proxy.huya.enabled";
private const string HuyaProxyUrlKey = "platform_proxy.huya.url";
private const string EnableEventScriptsKey = "event_scripts.enabled";
private const string LiveStartedScriptModeKey = "event_scripts.live_started.mode";
private const string LiveStartedScriptPathKey = "event_scripts.live_started.path";
@@ -117,6 +139,45 @@ public sealed class SystemSettingsService : ISystemSettingsService
EnableBackgroundPolling = bool.TryParse(GetValue(lookup, EnableBackgroundPollingKey, "true"), out var enableBackgroundPolling) && enableBackgroundPolling,
AutoStartRecordingOnLive = bool.TryParse(GetValue(lookup, AutoStartRecordingOnLiveKey, "true"), out var autoStartRecordingOnLive) && autoStartRecordingOnLive,
PollingIntervalSeconds = GetIntValue(lookup, PollingIntervalSecondsKey, 60, 10, 3600),
UseAliasForStorage = bool.TryParse(GetValue(lookup, UseAliasForStorageKey, "false"), out var useAliasForStorage) && useAliasForStorage,
EnableFileUpload = bool.TryParse(GetValue(lookup, EnableFileUploadKey, "false"), out var enableFileUpload) && enableFileUpload,
EnableAutoUpload = bool.TryParse(GetValue(lookup, EnableAutoUploadKey, "false"), out var enableAutoUpload) && enableAutoUpload,
DeleteLocalFilesAfterUpload = bool.TryParse(GetValue(lookup, DeleteLocalFilesAfterUploadKey, "false"), out var deleteLocalFilesAfterUpload) && deleteLocalFilesAfterUpload,
UploadTarget = Enum.TryParse(GetValue(lookup, UploadTargetKey, "None"), true, out UploadTargetType uploadTarget)
? uploadTarget
: UploadTargetType.None,
DouyinProxy = new PlatformProxySettingsDto
{
Enabled = bool.TryParse(GetValue(lookup, DouyinProxyEnabledKey, "false"), out var douyinProxyEnabled) && douyinProxyEnabled,
ProxyUrl = GetValue(lookup, DouyinProxyUrlKey, string.Empty)
},
BilibiliProxy = new PlatformProxySettingsDto
{
Enabled = bool.TryParse(GetValue(lookup, BilibiliProxyEnabledKey, "false"), out var bilibiliProxyEnabled) && bilibiliProxyEnabled,
ProxyUrl = GetValue(lookup, BilibiliProxyUrlKey, string.Empty)
},
HuyaProxy = new PlatformProxySettingsDto
{
Enabled = bool.TryParse(GetValue(lookup, HuyaProxyEnabledKey, "false"), out var huyaProxyEnabled) && huyaProxyEnabled,
ProxyUrl = GetValue(lookup, HuyaProxyUrlKey, string.Empty)
},
WebDavUpload = new WebDavUploadSettingsDto
{
Endpoint = GetValue(lookup, WebDavEndpointKey, string.Empty),
BasePath = GetValue(lookup, WebDavBasePathKey, string.Empty),
Username = GetValue(lookup, WebDavUsernameKey, string.Empty),
Password = GetValue(lookup, WebDavPasswordKey, string.Empty)
},
S3Upload = new S3UploadSettingsDto
{
Endpoint = GetValue(lookup, S3EndpointKey, string.Empty),
Bucket = GetValue(lookup, S3BucketKey, string.Empty),
Region = GetValue(lookup, S3RegionKey, string.Empty),
AccessKey = GetValue(lookup, S3AccessKeyKey, string.Empty),
SecretKey = GetValue(lookup, S3SecretKeyKey, string.Empty),
Prefix = GetValue(lookup, S3PrefixKey, string.Empty),
ForcePathStyle = bool.TryParse(GetValue(lookup, S3ForcePathStyleKey, "false"), out var s3ForcePathStyle) && s3ForcePathStyle
},
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
LiveStartedScriptMode = GetEventScriptMode(lookup, LiveStartedScriptModeKey, LiveStartedScriptPathKey, LiveStartedScriptContentKey),
LiveStartedScriptPath = GetValue(lookup, LiveStartedScriptPathKey, string.Empty),
@@ -199,6 +260,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
ArgumentNullException.ThrowIfNull(request);
var now = DateTimeOffset.UtcNow;
var douyinProxy = request.DouyinProxy ?? new PlatformProxySettingsDto();
var bilibiliProxy = request.BilibiliProxy ?? new PlatformProxySettingsDto();
var huyaProxy = request.HuyaProxy ?? new PlatformProxySettingsDto();
var webDavUpload = request.WebDavUpload ?? new WebDavUploadSettingsDto();
var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
await UpsertAsync(FfmpegPathKey, request.FfmpegPath.Trim(), now, cancellationToken);
await UpsertAsync(OutputRootKey, request.OutputRoot.Trim(), now, cancellationToken);
@@ -240,6 +306,28 @@ public sealed class SystemSettingsService : ISystemSettingsService
await UpsertAsync(EnableBackgroundPollingKey, request.EnableBackgroundPolling.ToString(), now, cancellationToken);
await UpsertAsync(AutoStartRecordingOnLiveKey, request.AutoStartRecordingOnLive.ToString(), now, cancellationToken);
await UpsertAsync(PollingIntervalSecondsKey, request.PollingIntervalSeconds.ToString(), now, cancellationToken);
await UpsertAsync(UseAliasForStorageKey, request.UseAliasForStorage.ToString(), now, cancellationToken);
await UpsertAsync(EnableFileUploadKey, request.EnableFileUpload.ToString(), now, cancellationToken);
await UpsertAsync(EnableAutoUploadKey, request.EnableAutoUpload.ToString(), now, cancellationToken);
await UpsertAsync(DeleteLocalFilesAfterUploadKey, request.DeleteLocalFilesAfterUpload.ToString(), now, cancellationToken);
await UpsertAsync(UploadTargetKey, request.UploadTarget.ToString(), now, cancellationToken);
await UpsertAsync(WebDavEndpointKey, webDavUpload.Endpoint.Trim(), now, cancellationToken);
await UpsertAsync(WebDavBasePathKey, webDavUpload.BasePath.Trim(), now, cancellationToken);
await UpsertAsync(WebDavUsernameKey, webDavUpload.Username.Trim(), now, cancellationToken);
await UpsertAsync(WebDavPasswordKey, webDavUpload.Password, now, cancellationToken);
await UpsertAsync(S3EndpointKey, s3Upload.Endpoint.Trim(), now, cancellationToken);
await UpsertAsync(S3BucketKey, s3Upload.Bucket.Trim(), now, cancellationToken);
await UpsertAsync(S3RegionKey, s3Upload.Region.Trim(), now, cancellationToken);
await UpsertAsync(S3AccessKeyKey, s3Upload.AccessKey.Trim(), now, cancellationToken);
await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
await UpsertAsync(DouyinProxyEnabledKey, douyinProxy.Enabled.ToString(), now, cancellationToken);
await UpsertAsync(DouyinProxyUrlKey, douyinProxy.ProxyUrl.Trim(), now, cancellationToken);
await UpsertAsync(BilibiliProxyEnabledKey, bilibiliProxy.Enabled.ToString(), now, cancellationToken);
await UpsertAsync(BilibiliProxyUrlKey, bilibiliProxy.ProxyUrl.Trim(), now, cancellationToken);
await UpsertAsync(HuyaProxyEnabledKey, huyaProxy.Enabled.ToString(), now, cancellationToken);
await UpsertAsync(HuyaProxyUrlKey, huyaProxy.ProxyUrl.Trim(), now, cancellationToken);
await UpsertAsync(EnableEventScriptsKey, request.EnableEventScripts.ToString(), now, cancellationToken);
await UpsertAsync(LiveStartedScriptModeKey, NormalizeEventScriptMode(request.LiveStartedScriptMode), now, cancellationToken);
await UpsertAsync(LiveStartedScriptPathKey, request.LiveStartedScriptPath.Trim(), now, cancellationToken);
@@ -46,6 +46,16 @@ public class LiveRoom
public string? CoverUrl { get; private set; }
public string? Remark { get; private set; }
public bool IsPinned { get; private set; }
public string? Alias { get; private set; }
public bool IsPriority { get; private set; }
public int? PollingIntervalSecondsOverride { get; private set; }
public string? PreferredQualityOverride { get; private set; }
public RecordOutputFormat? OutputFormatOverride { get; private set; }
@@ -90,6 +100,8 @@ public class LiveRoom
public DateTimeOffset? LastCheckedAt { get; private set; }
public DateTimeOffset? LastStartRecordingTriggeredAt { get; private set; }
public ICollection<RecordTask> RecordTasks { get; private set; } = new List<RecordTask>();
public void UpdateSource(string sourceUrl, string normalizedUrl, DateTimeOffset updatedAt)
@@ -112,6 +124,7 @@ public class LiveRoom
AnchorId = PreferIncomingValue(anchorId, AnchorId);
AvatarUrl = PreferIncomingValue(avatarUrl, AvatarUrl);
CoverUrl = PreferIncomingValue(coverUrl, CoverUrl);
Alias ??= NormalizeNullable(anchorName);
UpdatedAt = updatedAt;
}
@@ -151,6 +164,28 @@ public class LiveRoom
LastAutoStartDecisionAt = decidedAt;
}
public void UpdateManagementMetadata(
string? remark,
bool isPinned,
string? alias,
bool isPriority,
int? pollingIntervalSecondsOverride,
DateTimeOffset updatedAt)
{
Remark = NormalizeNullable(remark);
IsPinned = isPinned;
Alias = NormalizeNullable(alias) ?? NormalizeNullable(AnchorName);
IsPriority = isPriority;
PollingIntervalSecondsOverride = pollingIntervalSecondsOverride;
UpdatedAt = updatedAt;
}
public void MarkStartRecordingTriggered(DateTimeOffset triggeredAt)
{
LastStartRecordingTriggeredAt = triggeredAt;
UpdatedAt = triggeredAt;
}
public void UpdateRecordingSettingsOverrides(
string? preferredQualityOverride,
RecordOutputFormat? outputFormatOverride,
@@ -51,6 +51,20 @@ public class RecordResult
public string? ErrorMessage { get; private set; }
public RecordArtifactUploadStatus UploadStatus { get; private set; }
public string? LastUploadProvider { get; private set; }
public string? RemoteVideoPath { get; private set; }
public string? RemoteDanmakuPath { get; private set; }
public DateTimeOffset? LastUploadedAt { get; private set; }
public string? UploadErrorMessage { get; private set; }
public bool DeletedLocalFilesAfterUpload { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public void Update(
@@ -70,4 +84,32 @@ public class RecordResult
FinalStatus = finalStatus;
ErrorMessage = errorMessage;
}
public void MarkUploadSucceeded(
string provider,
string? remoteVideoPath,
string? remoteDanmakuPath,
bool deletedLocalFilesAfterUpload,
DateTimeOffset uploadedAt)
{
UploadStatus = RecordArtifactUploadStatus.Succeeded;
LastUploadProvider = NormalizeNullable(provider);
RemoteVideoPath = NormalizeNullable(remoteVideoPath);
RemoteDanmakuPath = NormalizeNullable(remoteDanmakuPath);
LastUploadedAt = uploadedAt;
UploadErrorMessage = null;
DeletedLocalFilesAfterUpload = deletedLocalFilesAfterUpload;
}
public void MarkUploadFailed(string provider, string? errorMessage, DateTimeOffset uploadedAt)
{
UploadStatus = RecordArtifactUploadStatus.Failed;
LastUploadProvider = NormalizeNullable(provider);
LastUploadedAt = uploadedAt;
UploadErrorMessage = NormalizeNullable(errorMessage);
DeletedLocalFilesAfterUpload = false;
}
private static string? NormalizeNullable(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,8 @@
namespace LiveRecorder.Domain.Enums;
public enum LiveRoomCurrentRecordingState
{
Offline = 0,
Live = 1,
Recording = 2
}
@@ -0,0 +1,8 @@
namespace LiveRecorder.Domain.Enums;
public enum RecordArtifactUploadStatus
{
NotUploaded = 0,
Succeeded = 1,
Failed = 2
}
@@ -0,0 +1,8 @@
namespace LiveRecorder.Domain.Enums;
public enum UploadTargetType
{
None = 0,
WebDav = 1,
S3 = 2
}
@@ -56,6 +56,28 @@ public sealed class DatabaseInitializer
["scheduler.enable_background_polling"] = "True",
["scheduler.auto_start_recording_on_live"] = "True",
["scheduler.polling_interval_seconds"] = "60",
["recording.use_alias_for_storage"] = "False",
["upload.enabled"] = "False",
["upload.auto_upload"] = "False",
["upload.delete_local_files_after_upload"] = "False",
["upload.target"] = "None",
["upload.webdav.endpoint"] = string.Empty,
["upload.webdav.base_path"] = string.Empty,
["upload.webdav.username"] = string.Empty,
["upload.webdav.password"] = string.Empty,
["upload.s3.endpoint"] = string.Empty,
["upload.s3.bucket"] = string.Empty,
["upload.s3.region"] = string.Empty,
["upload.s3.access_key"] = string.Empty,
["upload.s3.secret_key"] = string.Empty,
["upload.s3.prefix"] = string.Empty,
["upload.s3.force_path_style"] = "False",
["platform_proxy.douyin.enabled"] = "False",
["platform_proxy.douyin.url"] = string.Empty,
["platform_proxy.bilibili.enabled"] = "False",
["platform_proxy.bilibili.url"] = string.Empty,
["platform_proxy.huya.enabled"] = "False",
["platform_proxy.huya.url"] = string.Empty,
["event_scripts.enabled"] = "False",
["event_scripts.live_started.path"] = string.Empty,
["event_scripts.live_ended.path"] = string.Empty,
@@ -171,6 +193,12 @@ public sealed class DatabaseInitializer
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionSummary TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionDetail TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastAutoStartDecisionAt TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN Remark TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN IsPinned INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN Alias TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN IsPriority INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN PollingIntervalSecondsOverride INTEGER NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE LiveRooms ADD COLUMN LastStartRecordingTriggeredAt TEXT NULL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
"""
@@ -199,6 +227,13 @@ public sealed class DatabaseInitializer
await ExecuteAddColumnAsync("ALTER TABLE RecordTasks ADD COLUMN SegmentIndex INTEGER NOT NULL DEFAULT 1;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuFilePath TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DanmakuMessageCount INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN UploadStatus INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN LastUploadProvider TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN RemoteVideoPath TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN RemoteDanmakuPath TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN LastUploadedAt TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN UploadErrorMessage TEXT NULL;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE RecordResults ADD COLUMN DeletedLocalFilesAfterUpload INTEGER NOT NULL DEFAULT 0;", cancellationToken);
await ExecuteAddColumnAsync("ALTER TABLE SystemLogEntries ADD COLUMN RecordSessionId TEXT NULL;", cancellationToken);
await _dbContext.Database.ExecuteSqlRawAsync(
@@ -44,6 +44,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.Property(static x => x.AnchorId).HasMaxLength(128);
builder.Property(static x => x.AvatarUrl).HasMaxLength(512);
builder.Property(static x => x.CoverUrl).HasMaxLength(512);
builder.Property(static x => x.Remark).HasMaxLength(512);
builder.Property(static x => x.Alias).HasMaxLength(128);
builder.Property(static x => x.PreferredQualityOverride).HasMaxLength(64);
builder.Property(static x => x.OutputFormatOverride).HasConversion<int?>();
builder.Property(static x => x.SaveModeOverride).HasConversion<int?>();
@@ -53,6 +55,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.Property(static x => x.LastAutoStartDecisionDetail).HasMaxLength(2048);
builder.Property(static x => x.IsEnabled).HasDefaultValue(true);
builder.Property(static x => x.HasSentLiveNotificationForCurrentSession).HasDefaultValue(false);
builder.Property(static x => x.IsPinned).HasDefaultValue(false);
builder.Property(static x => x.IsPriority).HasDefaultValue(false);
});
modelBuilder.Entity<RecordTask>(builder =>
@@ -98,9 +102,14 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.ToTable("RecordResults");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.FinalStatus).HasConversion<int>();
builder.Property(static x => x.UploadStatus).HasConversion<int>();
builder.Property(static x => x.FilePath).HasMaxLength(2048);
builder.Property(static x => x.DanmakuFilePath).HasMaxLength(2048);
builder.Property(static x => x.ErrorMessage).HasMaxLength(2048);
builder.Property(static x => x.LastUploadProvider).HasMaxLength(32);
builder.Property(static x => x.RemoteVideoPath).HasMaxLength(2048);
builder.Property(static x => x.RemoteDanmakuPath).HasMaxLength(2048);
builder.Property(static x => x.UploadErrorMessage).HasMaxLength(2048);
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
builder.HasOne(static x => x.RecordTask)
.WithOne(static x => x.Result)
@@ -173,6 +173,16 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
.ToList();
}
public async Task<IReadOnlyCollection<Guid>> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default) =>
await _dbContext.RecordSessions
.AsNoTracking()
.Where(item => item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping)
.Select(item => item.LiveRoomId)
.Distinct()
.ToListAsync(cancellationToken);
public Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default) =>
_dbContext.RecordSessions
.Include(item => item.LiveRoom)
@@ -2,6 +2,8 @@ using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
@@ -13,17 +15,17 @@ public sealed class BilibiliHttpClient
"""^(?:(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?)?(?<id>\d+)\/?(?:[#\?].*)?$""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private readonly IHttpClientFactory _httpClientFactory;
private readonly PlatformHttpClientFactory _platformHttpClientFactory;
private readonly BilibiliWbiSigner _wbiSigner;
private readonly ILogger<BilibiliHttpClient> _logger;
private readonly string _buvid3 = BilibiliRequestDefaults.GenerateBuvid3();
public BilibiliHttpClient(
IHttpClientFactory httpClientFactory,
PlatformHttpClientFactory platformHttpClientFactory,
BilibiliWbiSigner wbiSigner,
ILogger<BilibiliHttpClient> logger)
{
_httpClientFactory = httpClientFactory;
_platformHttpClientFactory = platformHttpClientFactory;
_wbiSigner = wbiSigner;
_logger = logger;
}
@@ -350,9 +352,11 @@ public sealed class BilibiliHttpClient
{
try
{
var response = await _httpClientFactory
.CreateClient(BilibiliRequestDefaults.ClientName)
.SendAsync(requestFactory(), completionOption, cancellationToken);
using var client = await _platformHttpClientFactory.CreateAsync(
LivePlatformType.Bilibili,
forceDirectConnection: false,
cancellationToken);
var response = await client.SendAsync(requestFactory(), completionOption, cancellationToken);
if (attempt < 3 && IsTransientStatusCode(response.StatusCode))
{
@@ -362,7 +366,7 @@ public sealed class BilibiliHttpClient
}
response.EnsureSuccessStatusCode();
return response;
return await BufferResponseAsync(response, cancellationToken);
}
catch (Exception ex) when (attempt < 3 && IsTransientException(ex, cancellationToken))
{
@@ -375,6 +379,38 @@ public sealed class BilibiliHttpClient
throw lastException ?? new InvalidOperationException("Bilibili request failed.");
}
private static async Task<HttpResponseMessage> BufferResponseAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var buffer = new MemoryStream();
await responseStream.CopyToAsync(buffer, cancellationToken);
var clone = new HttpResponseMessage(response.StatusCode)
{
ReasonPhrase = response.ReasonPhrase,
Version = response.Version,
RequestMessage = response.RequestMessage is null
? null
: new HttpRequestMessage(response.RequestMessage.Method, response.RequestMessage.RequestUri),
Content = new ByteArrayContent(buffer.ToArray())
};
foreach (var header in response.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var header in response.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
response.Dispose();
return clone;
}
private static string? ExtractRoomReference(string input)
{
var match = RoomIdRegex.Match(input);
@@ -5,8 +5,10 @@ using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Platforms.Douyin.Danmaku;
using LiveRecorder.Infrastructure.Platforms.Douyin.Signing;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -15,8 +17,6 @@ namespace LiveRecorder.Infrastructure.Platforms.Douyin;
public sealed class DouyinHttpClient
{
private const string DefaultClientName = "douyin";
private const string DirectClientName = "douyin-direct";
private const int MaxAttempts = 3;
private const int MsTokenLength = 184;
private static readonly Regex PaceStateRegex = new(
@@ -61,20 +61,20 @@ public sealed class DouyinHttpClient
"\"roomStore\":{[\\s\\S]*?\"roomInfo\":{[\\s\\S]*?\"anchor\":{[\\s\\S]*?\"nickname\":\"(?<nickname>[\\s\\S]*?)\"",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IHttpClientFactory _httpClientFactory;
private readonly PlatformHttpClientFactory _platformHttpClientFactory;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly DouyinXBogusSigner _xBogusSigner;
private readonly DouyinLiveWsSignatureSigner _liveWsSignatureSigner;
private readonly ILogger<DouyinHttpClient> _logger;
public DouyinHttpClient(
IHttpClientFactory httpClientFactory,
PlatformHttpClientFactory platformHttpClientFactory,
IServiceScopeFactory serviceScopeFactory,
DouyinXBogusSigner xBogusSigner,
DouyinLiveWsSignatureSigner liveWsSignatureSigner,
ILogger<DouyinHttpClient> logger)
{
_httpClientFactory = httpClientFactory;
_platformHttpClientFactory = platformHttpClientFactory;
_serviceScopeFactory = serviceScopeFactory;
_xBogusSigner = xBogusSigner;
_liveWsSignatureSigner = liveWsSignatureSigner;
@@ -523,7 +523,10 @@ BootstrapResolved:
{
using var request = requestFactory();
var useDirectConnection = preferDirectConnection;
var client = _httpClientFactory.CreateClient(useDirectConnection ? DirectClientName : DefaultClientName);
using var client = await _platformHttpClientFactory.CreateAsync(
LivePlatformType.Douyin,
forceDirectConnection: useDirectConnection,
cancellationToken);
try
{
@@ -537,7 +540,7 @@ BootstrapResolved:
attempt);
}
return response;
return await BufferResponseAsync(response, cancellationToken);
}
if (attempt < MaxAttempts && IsTransientStatusCode(response.StatusCode))
@@ -548,7 +551,7 @@ BootstrapResolved:
}
response.EnsureSuccessStatusCode();
return response;
return await BufferResponseAsync(response, cancellationToken);
}
catch (Exception ex) when (attempt < MaxAttempts && IsTransientTransportException(ex, cancellationToken))
{
@@ -573,8 +576,44 @@ BootstrapResolved:
}
using var lastRequest = requestFactory();
var finalClient = _httpClientFactory.CreateClient(preferDirectConnection ? DirectClientName : DefaultClientName);
return await finalClient.SendAsync(lastRequest, completionOption, cancellationToken);
using var finalClient = await _platformHttpClientFactory.CreateAsync(
LivePlatformType.Douyin,
forceDirectConnection: preferDirectConnection,
cancellationToken);
var finalResponse = await finalClient.SendAsync(lastRequest, completionOption, cancellationToken);
return await BufferResponseAsync(finalResponse, cancellationToken);
}
private static async Task<HttpResponseMessage> BufferResponseAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var buffer = new MemoryStream();
await responseStream.CopyToAsync(buffer, cancellationToken);
var clone = new HttpResponseMessage(response.StatusCode)
{
ReasonPhrase = response.ReasonPhrase,
Version = response.Version,
RequestMessage = response.RequestMessage is null
? null
: new HttpRequestMessage(response.RequestMessage.Method, response.RequestMessage.RequestUri),
Content = new ByteArrayContent(buffer.ToArray())
};
foreach (var header in response.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var header in response.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
response.Dispose();
return clone;
}
private static void ApplyDefaultHeaders(HttpRequestMessage request, SystemSettingsDto settings, string? refererRoomId)
@@ -418,6 +418,8 @@ public sealed partial class FfmpegService
previousResult,
previousEffectiveOutputPath,
now);
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
await recordUploadService.TryAutoUploadTaskAsync(previousTask.Id, CancellationToken.None);
}
}
catch (Exception ex)
@@ -761,6 +763,8 @@ public sealed partial class FfmpegService
recordResult,
effectiveOutputPath,
endedAt);
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
await recordUploadService.TryAutoUploadTaskAsync(currentTask.Id, CancellationToken.None);
}
await logService.WriteAsync(
@@ -894,6 +898,8 @@ public sealed partial class FfmpegService
recordResult,
effectiveOutputPath,
endedAt);
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
await recordUploadService.TryAutoUploadTaskAsync(recordTask.Id, CancellationToken.None);
}
if (!string.IsNullOrWhiteSpace(finalizationError))
@@ -1283,6 +1289,8 @@ public sealed partial class FfmpegService
recordResult,
effectiveOutputPath,
endedAt);
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
await recordUploadService.TryAutoUploadTaskAsync(recordTask.Id, CancellationToken.None);
}
await logService.WriteAsync(
@@ -25,11 +25,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
private static readonly TimeSpan OfflineForcedStopTimeout = TimeSpan.FromSeconds(8);
private static readonly TimeSpan ExceptionEmailCooldown = TimeSpan.FromHours(6);
private static readonly TimeSpan PollDispatchSpacing = TimeSpan.FromMilliseconds(400);
private static readonly TimeSpan MinimumIdleDelay = TimeSpan.FromSeconds(2);
private const int MaxConcurrentLiveRoomPolls = 2;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<LiveRoomPollingBackgroundService> _logger;
private readonly ConcurrentDictionary<string, DateTimeOffset> _exceptionEmailSentAt = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _nextPollDueAt = new();
public LiveRoomPollingBackgroundService(
IServiceScopeFactory serviceScopeFactory,
@@ -43,15 +45,13 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
{
while (!stoppingToken.IsCancellationRequested)
{
var delaySeconds = 60;
var delay = TimeSpan.FromSeconds(60);
try
{
using var scope = _serviceScopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(stoppingToken);
delaySeconds = settings.PollingIntervalSeconds;
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
var storageGuardService = scope.ServiceProvider.GetRequiredService<IStorageGuardService>();
if (storageGuardService.CheckCanStartOrResume(settings).HasEnoughSpace)
@@ -61,21 +61,64 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
if (!settings.EnableBackgroundPolling)
{
await DelayAsync(delaySeconds, stoppingToken);
await DelayAsync(TimeSpan.FromSeconds(Math.Clamp(settings.PollingIntervalSeconds, 10, 3600)), stoppingToken);
continue;
}
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var liveRoomIds = (await dbContext.LiveRooms
var now = DateTimeOffset.UtcNow;
var candidates = (await dbContext.LiveRooms
.AsNoTracking()
.Where(static item => item.IsEnabled)
.Select(static item => new { item.Id, item.UpdatedAt })
.Select(static item => new
{
item.Id,
item.UpdatedAt,
item.LastCheckedAt,
item.IsPriority,
item.PollingIntervalSecondsOverride
})
.ToListAsync(stoppingToken))
.OrderBy(static item => item.UpdatedAt)
.Select(static item => item.Id)
.ToList();
await PollLiveRoomsAsync(liveRoomIds, settings, stoppingToken);
var pollCandidates = candidates
.Select(item =>
{
var intervalSeconds = GetEffectivePollingIntervalSeconds(settings.PollingIntervalSeconds, item.PollingIntervalSecondsOverride);
var baselineDueAt = item.LastCheckedAt?.AddSeconds(intervalSeconds) ?? DateTimeOffset.MinValue;
var dueAt = _nextPollDueAt.TryGetValue(item.Id, out var scheduledDueAt) && scheduledDueAt > baselineDueAt
? scheduledDueAt
: baselineDueAt;
return new PollCandidate(item.Id, dueAt, item.IsPriority, item.UpdatedAt, intervalSeconds);
})
.ToList();
if (pollCandidates.Count == 0)
{
delay = TimeSpan.FromSeconds(Math.Clamp(settings.PollingIntervalSeconds, 10, 3600));
}
else
{
var dueCandidates = pollCandidates
.Where(item => item.DueAt <= now)
.OrderByDescending(static item => item.IsPriority)
.ThenBy(static item => item.DueAt)
.ThenBy(static item => item.UpdatedAt)
.ToList();
if (dueCandidates.Count == 0)
{
var nextDueAt = pollCandidates.Min(static item => item.DueAt);
delay = nextDueAt <= now
? MinimumIdleDelay
: ClampDelay(nextDueAt - now);
}
else
{
await PollLiveRoomsAsync(dueCandidates, settings, stoppingToken);
delay = MinimumIdleDelay;
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -117,35 +160,52 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
}
await DelayAsync(delaySeconds, stoppingToken);
await DelayAsync(delay, stoppingToken);
}
}
private static Task DelayAsync(int delaySeconds, CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(Math.Clamp(delaySeconds, 10, 3600)), cancellationToken);
private static Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(ClampDelay(delay), cancellationToken);
private static TimeSpan ClampDelay(TimeSpan delay)
{
if (delay <= TimeSpan.Zero)
{
return MinimumIdleDelay;
}
return delay < MinimumIdleDelay
? MinimumIdleDelay
: delay > TimeSpan.FromHours(1)
? TimeSpan.FromHours(1)
: delay;
}
private static int GetEffectivePollingIntervalSeconds(int globalIntervalSeconds, int? overrideIntervalSeconds) =>
Math.Clamp(overrideIntervalSeconds ?? globalIntervalSeconds, 10, 3600);
private async Task PollLiveRoomsAsync(
IReadOnlyList<Guid> liveRoomIds,
IReadOnlyList<PollCandidate> liveRooms,
SystemSettingsDto settings,
CancellationToken cancellationToken)
{
if (liveRoomIds.Count == 0)
if (liveRooms.Count == 0)
{
return;
}
using var semaphore = new SemaphoreSlim(Math.Min(MaxConcurrentLiveRoomPolls, liveRoomIds.Count));
var tasks = new List<Task>(liveRoomIds.Count);
using var semaphore = new SemaphoreSlim(Math.Min(MaxConcurrentLiveRoomPolls, liveRooms.Count));
var tasks = new List<Task>(liveRooms.Count);
for (var index = 0; index < liveRoomIds.Count; index++)
for (var index = 0; index < liveRooms.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
await semaphore.WaitAsync(cancellationToken);
var liveRoomId = liveRoomIds[index];
tasks.Add(PollLiveRoomWithReleaseAsync(liveRoomId, settings, semaphore, cancellationToken));
var liveRoom = liveRooms[index];
tasks.Add(PollLiveRoomWithReleaseAsync(liveRoom, settings, semaphore, cancellationToken));
if (index < liveRoomIds.Count - 1)
if (index < liveRooms.Count - 1)
{
await Task.Delay(PollDispatchSpacing, cancellationToken);
}
@@ -155,14 +215,14 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
private async Task PollLiveRoomWithReleaseAsync(
Guid liveRoomId,
PollCandidate liveRoom,
SystemSettingsDto settings,
SemaphoreSlim semaphore,
CancellationToken cancellationToken)
{
try
{
await PollLiveRoomAsync(liveRoomId, settings, cancellationToken);
await PollLiveRoomAsync(liveRoom.LiveRoomId, liveRoom.IntervalSeconds, settings, cancellationToken);
}
finally
{
@@ -170,7 +230,11 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
}
}
private async Task PollLiveRoomAsync(Guid liveRoomId, SystemSettingsDto settings, CancellationToken cancellationToken)
private async Task PollLiveRoomAsync(
Guid liveRoomId,
int intervalSeconds,
SystemSettingsDto settings,
CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
@@ -186,6 +250,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
var liveRoom = await dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
if (liveRoom is null || !liveRoom.IsEnabled)
{
_nextPollDueAt.TryRemove(liveRoomId, out _);
return;
}
@@ -341,6 +406,10 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
cancellationToken: cancellationToken);
}
}
finally
{
_nextPollDueAt[liveRoomId] = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(intervalSeconds, 10, 3600));
}
}
private static async Task CompleteActiveSessionsForOfflineRoomAsync(
@@ -642,4 +711,11 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService
var trimmed = value.Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
private sealed record PollCandidate(
Guid LiveRoomId,
DateTimeOffset DueAt,
bool IsPriority,
DateTimeOffset UpdatedAt,
int IntervalSeconds);
}
@@ -0,0 +1,78 @@
using System.Net;
using System.Net.Security;
using System.Security.Authentication;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Infrastructure.Services;
public sealed class PlatformHttpClientFactory
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
private readonly ISystemSettingsService _systemSettingsService;
public PlatformHttpClientFactory(ISystemSettingsService systemSettingsService)
{
_systemSettingsService = systemSettingsService;
}
public async Task<HttpClient> CreateAsync(
LivePlatformType platform,
bool forceDirectConnection,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var proxy = forceDirectConnection ? null : BuildProxy(platform, settings);
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
PooledConnectionLifetime = platform == LivePlatformType.Douyin
? TimeSpan.FromMinutes(2)
: TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 8,
ConnectTimeout = DefaultConnectTimeout,
UseCookies = false,
UseProxy = proxy is not null,
Proxy = proxy,
SslOptions = new SslClientAuthenticationOptions
{
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
}
};
return new HttpClient(handler, disposeHandler: true)
{
Timeout = DefaultTimeout,
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
}
private static IWebProxy? BuildProxy(LivePlatformType platform, SystemSettingsDto settings)
{
var proxySettings = platform switch
{
LivePlatformType.Douyin => settings.DouyinProxy,
LivePlatformType.Bilibili => settings.BilibiliProxy,
LivePlatformType.Huya => settings.HuyaProxy,
_ => null
};
if (proxySettings is null || !proxySettings.Enabled || string.IsNullOrWhiteSpace(proxySettings.ProxyUrl))
{
return null;
}
if (!Uri.TryCreate(proxySettings.ProxyUrl.Trim(), UriKind.Absolute, out var proxyUri) ||
(proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException($"The configured proxy URL for {platform} is invalid: {proxySettings.ProxyUrl}");
}
return new WebProxy(proxyUri);
}
}
@@ -0,0 +1,622 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Infrastructure.Services;
public sealed class RecordUploadService
{
private readonly LiveRecorderDbContext _dbContext;
private readonly ISystemSettingsService _systemSettingsService;
private readonly ISystemLogService _systemLogService;
public RecordUploadService(
LiveRecorderDbContext dbContext,
ISystemSettingsService systemSettingsService,
ISystemLogService systemLogService)
{
_dbContext = dbContext;
_systemSettingsService = systemSettingsService;
_systemLogService = systemLogService;
}
public async Task<RecordArtifactUploadItemResultDto?> TryAutoUploadTaskAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableFileUpload || !settings.EnableAutoUpload || settings.UploadTarget == UploadTargetType.None)
{
return null;
}
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: true, cancellationToken);
}
public async Task<RecordArtifactUploadItemResultDto> UploadTaskAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: false, cancellationToken);
}
public async Task<RecordArtifactUploadBatchResultDto> UploadSessionAsync(
Guid recordSessionId,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var session = await _dbContext.RecordSessions
.AsNoTracking()
.Include(item => item.RecordTasks)
.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
if (session is null)
{
return new RecordArtifactUploadBatchResultDto
{
RequestedCount = 0,
SuccessCount = 0,
FailedCount = 1,
Items =
[
new RecordArtifactUploadItemResultDto
{
RecordTaskId = Guid.Empty,
Success = false,
Message = "Recording session was not found."
}
]
};
}
var taskIds = session.RecordTasks
.OrderBy(static item => item.SegmentIndex)
.ThenBy(static item => item.CreatedAt)
.Select(static item => item.Id)
.ToArray();
var items = new List<RecordArtifactUploadItemResultDto>(taskIds.Length);
foreach (var taskId in taskIds)
{
items.Add(await UploadTaskInternalAsync(taskId, settings, automatic: false, cancellationToken));
}
return new RecordArtifactUploadBatchResultDto
{
RequestedCount = taskIds.Length,
SuccessCount = items.Count(static item => item.Success),
FailedCount = items.Count(static item => !item.Success),
Items = items
};
}
private async Task<RecordArtifactUploadItemResultDto> UploadTaskInternalAsync(
Guid recordTaskId,
SystemSettingsDto settings,
bool automatic,
CancellationToken cancellationToken)
{
var recordTask = await _dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.RecordSession)
.Include(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
if (recordTask?.LiveRoom is null || recordTask.RecordSession is null || recordTask.Result is null)
{
return CreateFailureResult(recordTaskId, "Recording result is not ready for upload.");
}
if (!settings.EnableFileUpload || settings.UploadTarget == UploadTargetType.None)
{
return CreateFailureResult(recordTaskId, "File upload is disabled or no upload target is configured.");
}
var recordResult = recordTask.Result;
var absoluteVideoPath = NormalizeAbsolutePath(recordResult.FilePath);
var absoluteDanmakuPath = NormalizeNullablePath(recordResult.DanmakuFilePath);
var hasVideoArtifact = !string.IsNullOrWhiteSpace(absoluteVideoPath) && File.Exists(absoluteVideoPath);
var hasDanmakuArtifact = !string.IsNullOrWhiteSpace(absoluteDanmakuPath) && File.Exists(absoluteDanmakuPath);
if (!hasVideoArtifact && !hasDanmakuArtifact)
{
if (recordResult.UploadStatus == RecordArtifactUploadStatus.Succeeded)
{
return new RecordArtifactUploadItemResultDto
{
RecordTaskId = recordTaskId,
Success = true,
Message = "Artifacts were uploaded previously and local files are no longer available.",
Provider = recordResult.LastUploadProvider,
RemoteVideoPath = recordResult.RemoteVideoPath,
RemoteDanmakuPath = recordResult.RemoteDanmakuPath,
DeletedLocalFilesAfterUpload = recordResult.DeletedLocalFilesAfterUpload
};
}
return CreateFailureResult(recordTaskId, "No local recording artifacts are available for upload.");
}
var uploader = CreateUploader(settings);
try
{
var remoteVideoPath = recordResult.RemoteVideoPath;
var remoteDanmakuPath = recordResult.RemoteDanmakuPath;
if (hasVideoArtifact)
{
var relativeVideoPath = BuildRelativeRemotePath(settings.OutputRoot, absoluteVideoPath!);
remoteVideoPath = await uploader.UploadFileAsync(absoluteVideoPath!, relativeVideoPath, cancellationToken);
}
if (hasDanmakuArtifact)
{
var relativeDanmakuPath = BuildRelativeRemotePath(settings.OutputRoot, absoluteDanmakuPath!);
remoteDanmakuPath = await uploader.UploadFileAsync(absoluteDanmakuPath!, relativeDanmakuPath, cancellationToken);
}
var deletedLocalFiles = false;
string? deletionWarning = null;
if (settings.DeleteLocalFilesAfterUpload)
{
try
{
deletedLocalFiles = TryDeleteUploadedArtifacts(absoluteVideoPath, hasVideoArtifact, absoluteDanmakuPath, hasDanmakuArtifact);
}
catch (Exception ex)
{
deletionWarning = ex.Message;
}
}
recordResult.MarkUploadSucceeded(
uploader.ProviderName,
remoteVideoPath,
remoteDanmakuPath,
deletedLocalFiles,
DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
deletionWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
"Upload",
automatic ? "Automatic artifact upload completed." : "Artifact upload completed.",
BuildUploadLogDetail(uploader.ProviderName, remoteVideoPath, remoteDanmakuPath, deletedLocalFiles, deletionWarning),
liveRoomId: recordTask.LiveRoomId,
recordSessionId: recordTask.RecordSessionId,
recordTaskId: recordTask.Id,
cancellationToken: cancellationToken);
return new RecordArtifactUploadItemResultDto
{
RecordTaskId = recordTask.Id,
Success = true,
Message = deletionWarning is null
? "Upload completed successfully."
: $"Upload completed, but local cleanup was not fully successful: {deletionWarning}",
Provider = uploader.ProviderName,
RemoteVideoPath = remoteVideoPath,
RemoteDanmakuPath = remoteDanmakuPath,
DeletedLocalFilesAfterUpload = deletedLocalFiles
};
}
catch (Exception ex)
{
recordResult.MarkUploadFailed(uploader.ProviderName, ex.Message, DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
automatic ? SystemLogLevel.Warning : SystemLogLevel.Error,
"Upload",
automatic ? "Automatic artifact upload failed." : "Artifact upload failed.",
ex.ToString(),
liveRoomId: recordTask.LiveRoomId,
recordSessionId: recordTask.RecordSessionId,
recordTaskId: recordTask.Id,
cancellationToken: cancellationToken);
return CreateFailureResult(recordTask.Id, ex.Message, uploader.ProviderName);
}
}
private static string BuildUploadLogDetail(
string provider,
string? remoteVideoPath,
string? remoteDanmakuPath,
bool deletedLocalFiles,
string? deletionWarning)
{
var builder = new StringBuilder();
builder.Append("provider=").Append(provider);
if (!string.IsNullOrWhiteSpace(remoteVideoPath))
{
builder.Append("; video=").Append(remoteVideoPath);
}
if (!string.IsNullOrWhiteSpace(remoteDanmakuPath))
{
builder.Append("; danmaku=").Append(remoteDanmakuPath);
}
builder.Append("; deletedLocalFiles=").Append(deletedLocalFiles);
if (!string.IsNullOrWhiteSpace(deletionWarning))
{
builder.Append("; cleanupWarning=").Append(deletionWarning);
}
return builder.ToString();
}
private static bool TryDeleteUploadedArtifacts(
string? absoluteVideoPath,
bool hasVideoArtifact,
string? absoluteDanmakuPath,
bool hasDanmakuArtifact)
{
var deletedAny = false;
if (hasVideoArtifact && !string.IsNullOrWhiteSpace(absoluteVideoPath) && File.Exists(absoluteVideoPath))
{
File.Delete(absoluteVideoPath);
deletedAny = true;
}
if (hasDanmakuArtifact && !string.IsNullOrWhiteSpace(absoluteDanmakuPath) && File.Exists(absoluteDanmakuPath))
{
File.Delete(absoluteDanmakuPath);
deletedAny = true;
}
return deletedAny;
}
private static string BuildRelativeRemotePath(string outputRoot, string absolutePath)
{
var absoluteOutputRoot = Path.GetFullPath(outputRoot, AppContext.BaseDirectory);
var relativePath = Path.GetRelativePath(absoluteOutputRoot, absolutePath);
if (relativePath.StartsWith("..", StringComparison.Ordinal))
{
relativePath = Path.GetFileName(absolutePath);
}
return relativePath
.Replace('\\', '/')
.TrimStart('/');
}
private static string NormalizeAbsolutePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
return Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, AppContext.BaseDirectory);
}
private static string? NormalizeNullablePath(string? path) =>
string.IsNullOrWhiteSpace(path) ? null : NormalizeAbsolutePath(path);
private static RecordArtifactUploadItemResultDto CreateFailureResult(
Guid recordTaskId,
string message,
string? provider = null) =>
new()
{
RecordTaskId = recordTaskId,
Success = false,
Message = message,
Provider = provider
};
private static IRecordArtifactUploader CreateUploader(SystemSettingsDto settings) =>
settings.UploadTarget switch
{
UploadTargetType.WebDav => new WebDavRecordArtifactUploader(settings.WebDavUpload),
UploadTargetType.S3 => new S3RecordArtifactUploader(settings.S3Upload),
_ => throw new InvalidOperationException("No supported upload target is configured.")
};
}
internal interface IRecordArtifactUploader
{
string ProviderName { get; }
Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken);
}
internal sealed class WebDavRecordArtifactUploader : IRecordArtifactUploader
{
private readonly WebDavUploadSettingsDto _settings;
public WebDavRecordArtifactUploader(WebDavUploadSettingsDto settings)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string ProviderName => "webdav";
public async Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.Endpoint))
{
throw new InvalidOperationException("WebDAV endpoint is not configured.");
}
if (!Uri.TryCreate(EnsureTrailingSlash(_settings.Endpoint.Trim()), UriKind.Absolute, out var endpointUri))
{
throw new InvalidOperationException("WebDAV endpoint is invalid.");
}
var remotePath = CombineRemotePath(_settings.BasePath, relativeRemotePath);
var fileUri = BuildWebDavUri(endpointUri, remotePath);
using var client = new HttpClient
{
Timeout = TimeSpan.FromMinutes(10)
};
var authHeader = BuildBasicAuthorization(_settings.Username, _settings.Password);
await EnsureCollectionsAsync(client, endpointUri, remotePath, authHeader, cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Put, fileUri);
if (!string.IsNullOrWhiteSpace(authHeader))
{
request.Headers.TryAddWithoutValidation("Authorization", authHeader);
}
request.Content = new StreamContent(File.OpenRead(localPath));
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Created and not HttpStatusCode.NoContent and not HttpStatusCode.OK)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
throw new InvalidOperationException($"WebDAV upload failed with status {(int)response.StatusCode}: {errorBody}");
}
return fileUri.ToString();
}
private static async Task EnsureCollectionsAsync(
HttpClient client,
Uri endpointUri,
string remotePath,
string? authorization,
CancellationToken cancellationToken)
{
var segments = remotePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length <= 1)
{
return;
}
var builder = new List<string>(segments.Length);
for (var index = 0; index < segments.Length - 1; index++)
{
builder.Add(segments[index]);
var collectionUri = BuildWebDavUri(endpointUri, string.Join('/', builder) + "/");
using var request = new HttpRequestMessage(new HttpMethod("MKCOL"), collectionUri);
if (!string.IsNullOrWhiteSpace(authorization))
{
request.Headers.TryAddWithoutValidation("Authorization", authorization);
}
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode is HttpStatusCode.Created or HttpStatusCode.MethodNotAllowed or HttpStatusCode.Conflict or HttpStatusCode.OK or HttpStatusCode.NoContent)
{
continue;
}
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new InvalidOperationException($"WebDAV MKCOL failed with status {(int)response.StatusCode}: {body}");
}
}
private static Uri BuildWebDavUri(Uri endpointUri, string remotePath)
{
var encodedPath = string.Join(
'/',
remotePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Uri.EscapeDataString));
return new Uri(endpointUri, encodedPath);
}
private static string CombineRemotePath(string? basePath, string relativeRemotePath)
{
var parts = new[]
{
basePath?.Trim(),
relativeRemotePath.Trim()
}
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item!.Trim('/'))
.ToArray();
return string.Join('/', parts);
}
private static string? BuildBasicAuthorization(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) && string.IsNullOrWhiteSpace(password))
{
return null;
}
var raw = $"{username}:{password}";
return $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))}";
}
private static string EnsureTrailingSlash(string value) =>
value.EndsWith("/", StringComparison.Ordinal) ? value : value + "/";
}
internal sealed class S3RecordArtifactUploader : IRecordArtifactUploader
{
private readonly S3UploadSettingsDto _settings;
public S3RecordArtifactUploader(S3UploadSettingsDto settings)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string ProviderName => "s3";
public async Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken)
{
ValidateSettings();
var objectKey = BuildObjectKey(_settings.Prefix, relativeRemotePath);
var requestUri = BuildRequestUri(_settings, objectKey);
var now = DateTimeOffset.UtcNow;
var amzDate = now.ToString("yyyyMMdd'T'HHmmss'Z'");
var dateStamp = now.ToString("yyyyMMdd");
var region = string.IsNullOrWhiteSpace(_settings.Region) ? "us-east-1" : _settings.Region.Trim();
var payloadHash = await ComputeFileHashAsync(localPath, cancellationToken);
var canonicalUri = BuildCanonicalUri(_settings, objectKey);
var hostHeader = requestUri.IsDefaultPort ? requestUri.Host : $"{requestUri.Host}:{requestUri.Port}";
const string signedHeaders = "host;x-amz-content-sha256;x-amz-date";
var canonicalHeaders = $"host:{hostHeader}\n" +
$"x-amz-content-sha256:{payloadHash}\n" +
$"x-amz-date:{amzDate}\n";
var canonicalRequest = $"PUT\n{canonicalUri}\n\n{canonicalHeaders}\n{signedHeaders}\n{payloadHash}";
var credentialScope = $"{dateStamp}/{region}/s3/aws4_request";
var stringToSign = "AWS4-HMAC-SHA256\n" +
$"{amzDate}\n" +
$"{credentialScope}\n" +
$"{ComputeSha256Hex(canonicalRequest)}";
var signature = ComputeAwsSignature(_settings.SecretKey, dateStamp, region, stringToSign);
var authorization = $"AWS4-HMAC-SHA256 Credential={_settings.AccessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}";
using var client = new HttpClient
{
Timeout = TimeSpan.FromMinutes(10)
};
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
request.Headers.TryAddWithoutValidation("x-amz-content-sha256", payloadHash);
request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
request.Headers.TryAddWithoutValidation("Authorization", authorization);
request.Content = new StreamContent(File.OpenRead(localPath));
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.OK and not HttpStatusCode.Created and not HttpStatusCode.NoContent)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
throw new InvalidOperationException($"S3 upload failed with status {(int)response.StatusCode}: {errorBody}");
}
return requestUri.ToString();
}
private void ValidateSettings()
{
if (string.IsNullOrWhiteSpace(_settings.Endpoint))
{
throw new InvalidOperationException("S3 endpoint is not configured.");
}
if (string.IsNullOrWhiteSpace(_settings.Bucket))
{
throw new InvalidOperationException("S3 bucket is not configured.");
}
if (string.IsNullOrWhiteSpace(_settings.AccessKey) || string.IsNullOrWhiteSpace(_settings.SecretKey))
{
throw new InvalidOperationException("S3 access key or secret key is not configured.");
}
}
private static Uri BuildRequestUri(S3UploadSettingsDto settings, string objectKey)
{
if (!Uri.TryCreate(settings.Endpoint.Trim(), UriKind.Absolute, out var endpointUri))
{
throw new InvalidOperationException("S3 endpoint is invalid.");
}
var encodedKey = string.Join('/', objectKey.Split('/').Select(Uri.EscapeDataString));
if (settings.ForcePathStyle)
{
var basePath = endpointUri.AbsolutePath.TrimEnd('/');
var combinedPath = $"{basePath}/{Uri.EscapeDataString(settings.Bucket.Trim())}/{encodedKey}".Replace("//", "/", StringComparison.Ordinal);
return new UriBuilder(endpointUri)
{
Path = combinedPath
}.Uri;
}
return new UriBuilder(endpointUri)
{
Host = $"{settings.Bucket.Trim()}.{endpointUri.Host}",
Path = encodedKey
}.Uri;
}
private static string BuildCanonicalUri(S3UploadSettingsDto settings, string objectKey)
{
var encodedKey = string.Join('/', objectKey.Split('/').Select(Uri.EscapeDataString));
if (settings.ForcePathStyle)
{
return "/" + Uri.EscapeDataString(settings.Bucket.Trim()) + "/" + encodedKey;
}
return "/" + encodedKey;
}
private static string BuildObjectKey(string? prefix, string relativeRemotePath)
{
var parts = new[]
{
prefix?.Trim(),
relativeRemotePath.Trim()
}
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item!.Trim('/'))
.ToArray();
return string.Join('/', parts);
}
private static async Task<string> ComputeFileHashAsync(string localPath, CancellationToken cancellationToken)
{
using var stream = File.OpenRead(localPath);
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
return ConvertToHex(hash);
}
private static string ComputeSha256Hex(string content)
{
using var sha256 = SHA256.Create();
return ConvertToHex(sha256.ComputeHash(Encoding.UTF8.GetBytes(content)));
}
private static string ComputeAwsSignature(string secretKey, string dateStamp, string region, string stringToSign)
{
var secret = Encoding.UTF8.GetBytes("AWS4" + secretKey);
var dateKey = ComputeHmac(secret, dateStamp);
var regionKey = ComputeHmac(dateKey, region);
var serviceKey = ComputeHmac(regionKey, "s3");
var signingKey = ComputeHmac(serviceKey, "aws4_request");
return ConvertToHex(ComputeHmac(signingKey, stringToSign));
}
private static byte[] ComputeHmac(byte[] key, string value)
{
using var hmac = new HMACSHA256(key);
return hmac.ComputeHash(Encoding.UTF8.GetBytes(value));
}
private static string ConvertToHex(byte[] bytes) =>
Convert.ToHexString(bytes).ToLowerInvariant();
}
@@ -78,6 +78,13 @@ public sealed class LiveRoomsController : ControllerBase
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.UpdateSettingsAsync(id, request, cancellationToken));
[HttpPut("{id:guid}/metadata")]
public async Task<ActionResult<LiveRoomDto>> UpdateMetadata(
Guid id,
[FromBody] UpdateLiveRoomMetadataRequest request,
CancellationToken cancellationToken) =>
Ok(await _liveRoomService.UpdateMetadataAsync(id, request, cancellationToken));
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
@@ -12,10 +13,14 @@ public sealed class RecordSessionsController : ControllerBase
private static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(2);
private static readonly JsonSerializerOptions StreamJsonOptions = new(JsonSerializerDefaults.Web);
private readonly RecordSessionService _recordSessionService;
private readonly RecordUploadService _recordUploadService;
public RecordSessionsController(RecordSessionService recordSessionService)
public RecordSessionsController(
RecordSessionService recordSessionService,
RecordUploadService recordUploadService)
{
_recordSessionService = recordSessionService;
_recordUploadService = recordUploadService;
}
[HttpGet]
@@ -75,4 +80,8 @@ public sealed class RecordSessionsController : ControllerBase
[FromBody] DeleteRecordSessionsRequest request,
CancellationToken cancellationToken) =>
Ok(await _recordSessionService.DeleteAsync(request, cancellationToken));
[HttpPost("{id:guid}/upload")]
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
}
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
@@ -9,11 +10,16 @@ namespace LiveRecorder.WebApi.Controllers;
public sealed class RecordTasksController : ControllerBase
{
private readonly RecordService _recordService;
private readonly RecordUploadService _recordUploadService;
private readonly LinkGenerator _linkGenerator;
public RecordTasksController(RecordService recordService, LinkGenerator linkGenerator)
public RecordTasksController(
RecordService recordService,
RecordUploadService recordUploadService,
LinkGenerator linkGenerator)
{
_recordService = recordService;
_recordUploadService = recordUploadService;
_linkGenerator = linkGenerator;
}
@@ -65,4 +71,8 @@ public sealed class RecordTasksController : ControllerBase
[HttpPost("{id:guid}/transcode")]
public async Task<ActionResult<RecordTaskDetailDto>> StartManualTranscode(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordService.StartManualTranscodeAsync(id, cancellationToken));
[HttpPost("{id:guid}/upload")]
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
}
@@ -4,6 +4,8 @@ using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text;
using System.Text.Json;
namespace LiveRecorder.WebApi.Controllers;
@@ -41,6 +43,32 @@ public sealed class SettingsController : ControllerBase
CancellationToken cancellationToken) =>
Ok(await _systemSettingsService.UpdateAsync(request, cancellationToken));
[HttpGet("export")]
public async Task<FileContentResult> Export(CancellationToken cancellationToken)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
var payload = JsonSerializer.Serialize(settings, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
});
var fileName = $"live-recorder-settings-{DateTimeOffset.Now:yyyyMMddHHmmss}.json";
return File(
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(payload),
"application/json; charset=utf-8",
fileName);
}
[HttpPost("import")]
public async Task<ActionResult<SystemSettingsDto>> Import(
[FromBody] SystemSettingsDto request,
CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(request);
var updateRequest = JsonSerializer.Deserialize<UpdateSystemSettingsRequest>(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web))
?? throw new InvalidOperationException("Unable to deserialize imported settings.");
return Ok(await _systemSettingsService.UpdateAsync(updateRequest, cancellationToken));
}
[HttpPost("test-email")]
public async Task<IActionResult> SendTestEmail(
[FromBody] SendTestEmailRequest request,
+2
View File
@@ -147,6 +147,8 @@ builder.Services.AddScoped<SessionAnalyticsService>();
builder.Services.AddScoped<RecoveryService>();
builder.Services.AddScoped<RetentionCleanupService>();
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<PlatformHttpClientFactory>();
builder.Services.AddScoped<RecordUploadService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddSingleton<BilibiliWbiSigner>();