fix: retry segmented sessions and restore settings text
This commit is contained in:
+113
-108
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
@@ -285,8 +285,8 @@ const eventScriptEnvironmentExamples = [
|
||||
];
|
||||
|
||||
const eventScriptModeOptions = [
|
||||
{ label: "璺緞", value: "path" },
|
||||
{ label: "鑴氭湰鏂囨湰", value: "inline" }
|
||||
{ label: "路径", value: "path" },
|
||||
{ label: "脚本文本", value: "inline" }
|
||||
];
|
||||
|
||||
const uploadTargetOptions = [
|
||||
@@ -369,12 +369,12 @@ const canSendTestWebhook = computed(() => Boolean(form.webhookUrl.trim()));
|
||||
|
||||
const segmentedExamplePath = computed(() => {
|
||||
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
|
||||
return `Douyin/2026/04/15/涓绘挱鍚?221530_origin_涓绘挱鍚峗鐩存挱鏍囬_123456789_00001.${extension}`;
|
||||
return `Douyin/2026/04/15/主播名/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
const nestedSegmentedExamplePath = computed(() => {
|
||||
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
|
||||
return `Douyin/origin/2026/04/15/涓绘挱鍚?221530_origin_涓绘挱鍚峗鐩存挱鏍囬_123456789/221530_origin_涓绘挱鍚峗鐩存挱鏍囬_123456789_00001.${extension}`;
|
||||
return `Douyin/origin/2026/04/15/主播名/221530_origin_主播名_直播标题_123456789/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
@@ -400,11 +400,11 @@ const pwdForm = reactive({
|
||||
const pwdRules = {
|
||||
currentPassword: [{ required: true, message: "Please enter the current password", trigger: "blur" }],
|
||||
newPassword: [
|
||||
{ required: true, message: "璇疯緭鍏ユ柊瀵嗙爜", trigger: "blur" },
|
||||
{ required: true, message: "请输入新密码", trigger: "blur" },
|
||||
{ min: 6, message: "Password must be at least 6 characters", trigger: "blur" }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: "璇峰啀娆¤緭鍏ユ柊瀵嗙爜", trigger: "blur" },
|
||||
{ required: true, message: "请再次输入新密码", trigger: "blur" },
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (value !== pwdForm.newPassword) {
|
||||
@@ -470,7 +470,7 @@ async function sendTestEmail() {
|
||||
emailExceptionBodyTemplateHtml: form.emailExceptionBodyTemplateHtml
|
||||
});
|
||||
|
||||
ElMessage.success("娴嬭瘯閭欢宸插彂閫侊紝璇锋鏌ユ敹浠剁");
|
||||
ElMessage.success("测试邮件已发送,请检查收件箱");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "Failed to send test email."));
|
||||
} finally {
|
||||
@@ -488,7 +488,7 @@ async function testEventScript(eventType: ScriptEventType) {
|
||||
} catch (error) {
|
||||
const result: EventScriptTestResult = {
|
||||
success: false,
|
||||
message: getApiErrorMessage(error, "浜嬩欢鑴氭湰娴嬭瘯澶辫触")
|
||||
message: getApiErrorMessage(error, "事件脚本测试失败")
|
||||
};
|
||||
scriptTestResults[eventType] = result;
|
||||
ElMessage.error(result.message);
|
||||
@@ -512,7 +512,7 @@ async function testWebhook() {
|
||||
} catch (error) {
|
||||
const result: WebhookTestResult = {
|
||||
success: false,
|
||||
message: getApiErrorMessage(error, "Webhook 娴嬭瘯澶辫触")
|
||||
message: getApiErrorMessage(error, "Webhook 测试失败")
|
||||
};
|
||||
webhookTestResult.value = result;
|
||||
ElMessage.error(result.message);
|
||||
@@ -529,7 +529,7 @@ async function runRetentionCleanup() {
|
||||
await startRetentionCleanupTracking(data);
|
||||
ElMessage.success("Retention cleanup background task created.");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "淇濈暀娓呯悊鎵ц澶辫触"));
|
||||
ElMessage.error(getApiErrorMessage(error, "保留清理执行失败"));
|
||||
} finally {
|
||||
runningRetentionCleanup.value = false;
|
||||
}
|
||||
@@ -638,7 +638,7 @@ async function exportSettingsBackup() {
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
ElMessage.success("Settings backup exported.");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "绯荤粺璁剧疆瀵煎嚭澶辫触"));
|
||||
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
|
||||
} finally {
|
||||
exportingSettings.value = false;
|
||||
}
|
||||
@@ -665,7 +665,7 @@ async function importSettingsBackup(event: Event) {
|
||||
Object.assign(form, data);
|
||||
ElMessage.success("Settings imported from backup.");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "绯荤粺璁剧疆瀵煎叆澶辫触"));
|
||||
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
|
||||
} finally {
|
||||
importingSettings.value = false;
|
||||
}
|
||||
@@ -751,15 +751,16 @@ onBeforeUnmount(() => {
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">绯荤粺璁剧疆</h1>
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-subtitle">
|
||||
褰曞埗銆佸贰妫€銆佽剼鏈€乄ebhook銆侀偖浠跺拰淇濈暀娓呯悊閮介泦涓湪杩欓噷銆傝剼鏈祴璇曘€乄ebhook 娴嬭瘯鍜岄偖浠舵祴璇曢兘浼氱洿鎺ヤ娇鐢ㄥ綋鍓嶈〃鍗曞€硷紝涓嶈姹傚厛淇濆瓨銆? </p>
|
||||
录制、巡检、脚本、Webhook、邮件和保留清理都集中在这里。脚本测试、Webhook 测试和邮件测试都会直接使用当前表单值,不要求先保存。
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -775,15 +776,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="settings-grid" v-loading="loading">
|
||||
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
|
||||
<el-tab-pane label="褰曞埗" name="recording">
|
||||
<el-tab-pane label="录制" name="recording">
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">褰曞埗鍩虹</h3>
|
||||
<h3 class="section-title">录制基础</h3>
|
||||
<p class="section-subtitle">Default quality, output format, segmentation, ffmpeg templates, and network tolerance settings are managed here.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="ffmpeg 璺緞">
|
||||
<el-form-item label="ffmpeg 路径">
|
||||
<el-input v-model="form.ffmpegPath" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -805,7 +806,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="杈撳嚭鏍煎紡">
|
||||
<el-form-item label="输出格式">
|
||||
<el-select v-model="form.defaultOutputFormat">
|
||||
<el-option
|
||||
v-for="(label, value) in outputFormatLabelMap"
|
||||
@@ -817,7 +818,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="淇濆瓨妯″紡">
|
||||
<el-form-item label="保存模式">
|
||||
<el-select v-model="form.saveMode">
|
||||
<el-option
|
||||
v-for="(label, value) in saveModeLabelMap"
|
||||
@@ -829,7 +830,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="褰曞埗妯℃澘">
|
||||
<el-form-item label="录制模板">
|
||||
<el-select v-model="form.recordingTemplate">
|
||||
<el-option
|
||||
v-for="(label, value) in recordingTemplateLabelMap"
|
||||
@@ -841,7 +842,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍒嗘鏃堕暱锛堝垎閽燂級">
|
||||
<el-form-item label="分段时长(分钟)">
|
||||
<el-input-number v-model="form.segmentDurationMinutes" :min="1" :max="720" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -851,22 +852,22 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="MP4 杞爜瓒呮椂锛堝垎閽燂級">
|
||||
<el-form-item label="MP4 转码超时(分钟)">
|
||||
<el-input-number v-model="form.mp4FinalizeTimeoutMinutes" :min="1" :max="1440" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鏈€澶ч噸杩炲欢杩燂紙绉掞級">
|
||||
<el-form-item label="最大重连延迟(秒)">
|
||||
<el-input-number v-model="form.reconnectDelayMaxSeconds" :min="1" :max="300" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="璇诲啓瓒呮椂锛堟绉掞級">
|
||||
<el-form-item label="读写超时(毫秒)">
|
||||
<el-input-number v-model="form.readWriteTimeoutMilliseconds" :min="1000" :max="60000000" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍚敤 ffmpeg 鑷姩閲嶈繛">
|
||||
<el-form-item label="启用 ffmpeg 自动重连">
|
||||
<el-switch v-model="form.enableAutoReconnect" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -879,13 +880,13 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">瀛樺偍淇濇姢</h3>
|
||||
<h3 class="section-title">存储保护</h3>
|
||||
<p class="section-subtitle">Pause recording and MP4 finalization below the threshold, then resume once free space recovers.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍚敤瀛樺偍淇濇姢">
|
||||
<el-form-item label="启用存储保护">
|
||||
<el-switch v-model="form.enableStorageGuard" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -913,27 +914,28 @@ onBeforeUnmount(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="helper-panel">
|
||||
鎭㈠闃堝€煎缓璁珮浜庢殏鍋滈槇鍊硷紝閬垮厤纾佺洏绌洪棿鍦ㄤ复鐣屽€奸檮杩戝弽澶嶆姈鍔ㄣ€侻P4 杞爜浼氶澶栧崰鐢ㄤ腑闂?TS 鏂囦欢绌洪棿銆? </div>
|
||||
恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">淇濈暀娓呯悊</h3>
|
||||
<h3 class="section-title">保留清理</h3>
|
||||
<p class="section-subtitle">Clean up inactive sessions, tasks, results, and logs by retention age, with optional file deletion.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="鍚敤鑷姩娓呯悊">
|
||||
<el-form-item label="启用自动清理">
|
||||
<el-switch v-model="form.enableRetentionCleanup" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="淇濈暀澶╂暟">
|
||||
<el-form-item label="保留天数">
|
||||
<el-input-number v-model="form.retentionDays" :min="1" :max="3650" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="鍒犻櫎纾佺洏鏂囦欢">
|
||||
<el-form-item label="删除磁盘文件">
|
||||
<el-switch v-model="form.retentionDeleteFiles" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -995,13 +997,13 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">寮瑰箷褰曞埗</h3>
|
||||
<h3 class="section-title">弹幕录制</h3>
|
||||
<p class="section-subtitle">Control parallel danmaku XML recording, non-chat event capture, and retry / polling pacing.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍚敤寮瑰箷褰曞埗">
|
||||
<el-form-item label="启用弹幕录制">
|
||||
<el-switch v-model="form.enableDanmakuRecording" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1021,7 +1023,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鏈€澶ч噸璇曢€€閬匡紙绉掞級">
|
||||
<el-form-item label="最大重试退避(秒)">
|
||||
<el-input-number
|
||||
v-model="form.danmakuRetryDelayMaxSeconds"
|
||||
:min="1"
|
||||
@@ -1035,11 +1037,11 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">璺緞妯℃澘</h3>
|
||||
<h3 class="section-title">路径模板</h3>
|
||||
<p class="section-subtitle">Both directory and filename templates support variables. Segmented layouts are fully controlled by the templates themselves.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="鐩綍妯℃澘">
|
||||
<el-form-item label="目录模板">
|
||||
<el-input
|
||||
v-model="form.outputDirectoryTemplate"
|
||||
type="textarea"
|
||||
@@ -1059,9 +1061,9 @@ onBeforeUnmount(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="example-box">
|
||||
<div class="example-box__label">绀轰緥杈撳嚭</div>
|
||||
<div class="monospace example-box__value">榛樿鍒嗘锛歿{ segmentedExamplePath }}</div>
|
||||
<div class="monospace example-box__value">鐩綍妯℃澘鍚?{fileStem}锛歿{ nestedSegmentedExamplePath }}</div>
|
||||
<div class="example-box__label">示例输出</div>
|
||||
<div class="monospace example-box__value">默认分段:{{ segmentedExamplePath }}</div>
|
||||
<div class="monospace example-box__value">目录模板含 {fileStem}:{{ nestedSegmentedExamplePath }}</div>
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
@@ -1085,18 +1087,18 @@ onBeforeUnmount(() => {
|
||||
<el-tab-pane label="Polling and upload" name="polling">
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">鍚庡彴宸℃</h3>
|
||||
<h3 class="section-title">后台巡检</h3>
|
||||
<p class="section-subtitle">Control scheduled live-status checks and automatic recording starts when a room goes live.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍚敤鍚庡彴宸℃">
|
||||
<el-form-item label="启用后台巡检">
|
||||
<el-switch v-model="form.enableBackgroundPolling" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="妫€娴嬪埌寮€鎾悗鑷姩褰曞埗">
|
||||
<el-form-item label="检测到开播后自动录制">
|
||||
<el-switch v-model="form.autoStartRecordingOnLive" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1116,27 +1118,27 @@ onBeforeUnmount(() => {
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍚敤涓婁紶">
|
||||
<el-form-item label="启用上传">
|
||||
<el-switch v-model="form.enableFileUpload" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鑷姩涓婁紶">
|
||||
<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-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-form-item label="别名用于目录">
|
||||
<el-switch v-model="form.useAliasForStorage" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="涓婁紶鐩爣">
|
||||
<el-form-item label="上传目标">
|
||||
<el-select v-model="form.uploadTarget" :disabled="!form.enableFileUpload">
|
||||
<el-option
|
||||
v-for="option in uploadTargetOptions"
|
||||
@@ -1153,7 +1155,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="form.enableFileUpload && form.uploadTarget === 1" class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">WebDAV 鐩爣</h4>
|
||||
<h4 class="template-section__title">WebDAV 目标</h4>
|
||||
<p class="template-section__subtitle">Create remote directories from recording-relative paths and upload the video plus danmaku files.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1166,7 +1168,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍩虹璺緞">
|
||||
<el-form-item label="基础路径">
|
||||
<el-input v-model="form.webDavUpload.basePath" placeholder="/live-recorder" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1176,7 +1178,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="瀵嗙爜">
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.webDavUpload.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1187,7 +1189,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="form.enableFileUpload && form.uploadTarget === 2" class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">S3 鐩爣</h4>
|
||||
<h4 class="template-section__title">S3 目标</h4>
|
||||
<p class="template-section__subtitle">Supports custom endpoint, bucket, region, and prefix settings for object-storage compatible services.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1210,7 +1212,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍓嶇紑">
|
||||
<el-form-item label="前缀">
|
||||
<el-input v-model="form.s3Upload.prefix" placeholder="live-recorder/" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1234,11 +1236,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
鑷姩涓婁紶鍥哄畾澶勭悊鈥滆棰戞枃浠?+ 瀵瑰簲寮瑰箷 XML鈥濄€傚彧鏈変袱鑰呴兘涓婁紶鎴愬姛骞朵笖浣犳墦寮€鈥滀笂浼犲悗鍒犳湰鍦扳€濇椂锛岀郴缁熸墠浼氭竻鐞嗘湰鍦版枃浠躲€? </div>
|
||||
自动上传固定处理“视频文件 + 对应弹幕 XML”。只有两者都上传成功并且你打开“上传后删本地”时,系统才会清理本地文件。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">骞冲彴浠g悊</h3>
|
||||
<h3 class="section-title">平台代理</h3>
|
||||
<p class="section-subtitle">These proxies only affect platform-side status checks and stream requests, not email, webhook, or file uploads.</p>
|
||||
|
||||
<div class="event-script-grid">
|
||||
@@ -1250,7 +1253,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<el-switch v-model="form.douyinProxy.enabled" />
|
||||
</div>
|
||||
<el-form-item label="浠g悊鍦板潃">
|
||||
<el-form-item label="代理地址">
|
||||
<el-input v-model="form.douyinProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1263,7 +1266,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<el-switch v-model="form.bilibiliProxy.enabled" />
|
||||
</div>
|
||||
<el-form-item label="浠g悊鍦板潃">
|
||||
<el-form-item label="代理地址">
|
||||
<el-input v-model="form.bilibiliProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1276,7 +1279,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<el-switch v-model="form.huyaProxy.enabled" />
|
||||
</div>
|
||||
<el-form-item label="浠g悊鍦板潃">
|
||||
<el-form-item label="代理地址">
|
||||
<el-input v-model="form.huyaProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1284,17 +1287,18 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="浜嬩欢鑴氭湰" name="scripts">
|
||||
<el-tab-pane label="事件脚本" name="scripts">
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">浜嬩欢鑴氭湰</h3>
|
||||
<h3 class="section-title">事件脚本</h3>
|
||||
<p class="section-subtitle">
|
||||
寮€鎾€佷笅鎾€佸垎鐗囧畬鎴愭椂閮藉彲浠ユ墽琛岃剼鏈€傝剼鏈敮鎸佽矾寰勬ā寮忓拰鐩存帴濉啓鏂囨湰妯″紡锛涙祴璇曟寜閽細鐩存帴鐢ㄥ綋鍓嶈〃鍗曞€兼墽琛岋紝涓嶈姹傚厛淇濆瓨銆? </p>
|
||||
开播、下播、分片完成时都可以执行脚本。脚本支持路径模式和直接填写文本模式;测试按钮会直接用当前表单值执行,不要求先保存。
|
||||
</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="鍚敤浜嬩欢鑴氭湰">
|
||||
<el-form-item label="启用事件脚本">
|
||||
<el-switch v-model="form.enableEventScripts" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1331,14 +1335,14 @@ onBeforeUnmount(() => {
|
||||
:disabled="!canTestScript('live_started')"
|
||||
@click="testEventScript('live_started')"
|
||||
>
|
||||
娴嬭瘯鑴氭湰
|
||||
测试脚本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item v-if="form.liveStartedScriptMode === 'path'" label="鑴氭湰璺緞">
|
||||
<el-form-item v-if="form.liveStartedScriptMode === 'path'" label="脚本路径">
|
||||
<el-input v-model="form.liveStartedScriptPath" placeholder="/app/scripts/live-started.sh" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="鑴氭湰鏂囨湰">
|
||||
<el-form-item v-else label="脚本文本">
|
||||
<el-input
|
||||
v-model="form.liveStartedScriptContent"
|
||||
type="textarea"
|
||||
@@ -1356,7 +1360,7 @@ onBeforeUnmount(() => {
|
||||
{{ scriptTestResults.live_started?.detail }}
|
||||
</div>
|
||||
<div v-if="scriptTestResults.live_started?.customLogOutput" class="test-result__detail">
|
||||
鑷畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.live_started?.customLogOutput }}
|
||||
自定义日志输出:{{ scriptTestResults.live_started?.customLogOutput }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1364,7 +1368,7 @@ onBeforeUnmount(() => {
|
||||
<div class="event-script-section">
|
||||
<div class="event-script-section__header">
|
||||
<div>
|
||||
<h4 class="event-script-section__title">涓嬫挱</h4>
|
||||
<h4 class="event-script-section__title">下播</h4>
|
||||
<p class="event-script-section__subtitle">Triggered when the room changes from online back to offline.</p>
|
||||
</div>
|
||||
<div class="event-script-section__actions">
|
||||
@@ -1385,14 +1389,14 @@ onBeforeUnmount(() => {
|
||||
:disabled="!canTestScript('live_ended')"
|
||||
@click="testEventScript('live_ended')"
|
||||
>
|
||||
娴嬭瘯鑴氭湰
|
||||
测试脚本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item v-if="form.liveEndedScriptMode === 'path'" label="鑴氭湰璺緞">
|
||||
<el-form-item v-if="form.liveEndedScriptMode === 'path'" label="脚本路径">
|
||||
<el-input v-model="form.liveEndedScriptPath" placeholder="/app/scripts/live-ended.sh" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="鑴氭湰鏂囨湰">
|
||||
<el-form-item v-else label="脚本文本">
|
||||
<el-input
|
||||
v-model="form.liveEndedScriptContent"
|
||||
type="textarea"
|
||||
@@ -1410,7 +1414,7 @@ onBeforeUnmount(() => {
|
||||
{{ scriptTestResults.live_ended?.detail }}
|
||||
</div>
|
||||
<div v-if="scriptTestResults.live_ended?.customLogOutput" class="test-result__detail">
|
||||
鑷畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.live_ended?.customLogOutput }}
|
||||
自定义日志输出:{{ scriptTestResults.live_ended?.customLogOutput }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1418,7 +1422,7 @@ onBeforeUnmount(() => {
|
||||
<div class="event-script-section event-script-section--full">
|
||||
<div class="event-script-section__header">
|
||||
<div>
|
||||
<h4 class="event-script-section__title">鍒嗙墖瀹屾垚</h4>
|
||||
<h4 class="event-script-section__title">分片完成</h4>
|
||||
<p class="event-script-section__subtitle">Triggered after MP4 finalization completes and the segment status becomes completed.</p>
|
||||
</div>
|
||||
<div class="event-script-section__actions">
|
||||
@@ -1439,14 +1443,14 @@ onBeforeUnmount(() => {
|
||||
:disabled="!canTestScript('segment_completed')"
|
||||
@click="testEventScript('segment_completed')"
|
||||
>
|
||||
娴嬭瘯鑴氭湰
|
||||
测试脚本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item v-if="form.segmentCompletedScriptMode === 'path'" label="鑴氭湰璺緞">
|
||||
<el-form-item v-if="form.segmentCompletedScriptMode === 'path'" label="脚本路径">
|
||||
<el-input v-model="form.segmentCompletedScriptPath" placeholder="/app/scripts/segment-completed.sh" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="鑴氭湰鏂囨湰">
|
||||
<el-form-item v-else label="脚本文本">
|
||||
<el-input
|
||||
v-model="form.segmentCompletedScriptContent"
|
||||
type="textarea"
|
||||
@@ -1464,7 +1468,7 @@ onBeforeUnmount(() => {
|
||||
{{ scriptTestResults.segment_completed?.detail }}
|
||||
</div>
|
||||
<div v-if="scriptTestResults.segment_completed?.customLogOutput" class="test-result__detail">
|
||||
鑷畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.segment_completed?.customLogOutput }}
|
||||
自定义日志输出:{{ scriptTestResults.segment_completed?.customLogOutput }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1508,26 +1512,26 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="閫氱煡" name="notifications">
|
||||
<el-tab-pane label="通知" name="notifications">
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Webhook 閫氱煡</h3>
|
||||
<h3 class="section-title">Webhook 通知</h3>
|
||||
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Currently only live-started and exception events are sent.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍚敤 Webhook">
|
||||
<el-form-item label="启用 Webhook">
|
||||
<el-switch v-model="form.enableWebhookNotification" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="寮€鎾€氱煡">
|
||||
<el-form-item label="开播通知">
|
||||
<el-switch v-model="form.notifyWebhookOnLiveStarted" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="寮傚父閫氱煡">
|
||||
<el-form-item label="异常通知">
|
||||
<el-switch v-model="form.notifyWebhookOnException" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1544,7 +1548,7 @@ onBeforeUnmount(() => {
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24">
|
||||
<el-form-item label="鑷畾涔夎姹傚ご">
|
||||
<el-form-item label="自定义请求头">
|
||||
<el-input
|
||||
v-model="form.webhookHeaders"
|
||||
type="textarea"
|
||||
@@ -1554,7 +1558,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="鑷畾涔?JSON Body 妯℃澘">
|
||||
<el-form-item label="自定义 JSON Body 模板">
|
||||
<el-input
|
||||
v-model="form.webhookBodyTemplate"
|
||||
type="textarea"
|
||||
@@ -1566,7 +1570,7 @@ onBeforeUnmount(() => {
|
||||
</el-row>
|
||||
</el-form>
|
||||
<div class="template-help">
|
||||
<div class="template-help__label">Webhook 鍙橀噺</div>
|
||||
<div class="template-help__label">Webhook 变量</div>
|
||||
<div class="token-list">
|
||||
<span v-for="token in webhookTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
|
||||
</div>
|
||||
@@ -1574,7 +1578,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">The test sends a sample live_started payload using the current URL, headers, and timeout values from this form.</div>
|
||||
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">娴嬭瘯 Webhook</el-button>
|
||||
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">测试 Webhook</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="webhookTestResult" class="test-result" :class="webhookTestResult.success ? 'test-result--success' : 'test-result--warning'">
|
||||
@@ -1584,18 +1588,18 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">閭欢閫氱煡</h3>
|
||||
<h3 class="section-title">邮件通知</h3>
|
||||
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Tests use the current unsaved form values directly.</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍚敤閭欢閫氱煡">
|
||||
<el-form-item label="启用邮件通知">
|
||||
<el-switch v-model="form.enableEmailNotification" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鍚敤 SSL">
|
||||
<el-form-item label="启用 SSL">
|
||||
<el-switch v-model="form.emailUseSsl" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1605,7 +1609,7 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="寮傚父鎻愰啋">
|
||||
<el-form-item label="异常提醒">
|
||||
<el-switch v-model="form.notifyOnException" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1632,12 +1636,12 @@ onBeforeUnmount(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP 瀵嗙爜">
|
||||
<el-form-item label="SMTP 密码">
|
||||
<el-input v-model="form.emailPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="鍙戜欢鍦板潃">
|
||||
<el-form-item label="发件地址">
|
||||
<el-input v-model="form.emailFromAddress" placeholder="noreply@example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1648,7 +1652,7 @@ onBeforeUnmount(() => {
|
||||
v-model="form.emailToAddresses"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="澶氫釜鍦板潃鍙敤閫楀彿銆佸垎鍙锋垨鎹㈣鍒嗛殧"
|
||||
placeholder="多个地址可用逗号、分号或换行分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1662,11 +1666,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="涓婚妯℃澘">
|
||||
<el-form-item label="主题模板">
|
||||
<el-input v-model="form.emailLiveStartedSubjectTemplate" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="HTML 姝f枃妯℃澘">
|
||||
<el-form-item label="HTML 正文模板">
|
||||
<el-input v-model="form.emailLiveStartedBodyTemplateHtml" type="textarea" :rows="10" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1676,16 +1680,16 @@ onBeforeUnmount(() => {
|
||||
<div class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">寮傚父鎻愰啋妯℃澘</h4>
|
||||
<h4 class="template-section__title">异常提醒模板</h4>
|
||||
<p class="template-section__subtitle">Exception emails inject source, summary, detail, and task context values into the HTML body.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="涓婚妯℃澘">
|
||||
<el-form-item label="主题模板">
|
||||
<el-input v-model="form.emailExceptionSubjectTemplate" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="HTML 姝f枃妯℃澘">
|
||||
<el-form-item label="HTML 正文模板">
|
||||
<el-input v-model="form.emailExceptionBodyTemplateHtml" type="textarea" :rows="12" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1701,11 +1705,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
閭欢妯℃澘閲岀殑 <code v-pre>{{detectedAtUtc}}</code> 鍜?<code v-pre>{{occurredAtUtc}}</code> 瀛楁鍚嶄繚鎸佷笉鍙橈紝浣嗗疄闄呮覆鏌撳€煎凡缁忕粺涓€鏀规垚鍖椾含鏃堕棿锛圲TC+8锛夈€? </div>
|
||||
邮件模板里的 <code v-pre>{{detectedAtUtc}}</code> 和 <code v-pre>{{occurredAtUtc}}</code> 字段名保持不变,但实际渲染值已经统一改成北京时间(UTC+8)。
|
||||
</div>
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="helper-text">Sending a test email does not save settings. The email renders both the live-started and exception template examples.</div>
|
||||
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">娴嬭瘯閭欢</el-button>
|
||||
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -1723,7 +1728,7 @@ onBeforeUnmount(() => {
|
||||
label-position="top"
|
||||
style="max-width: 480px;"
|
||||
>
|
||||
<el-form-item label="褰撳墠瀵嗙爜" prop="currentPassword">
|
||||
<el-form-item label="当前密码" prop="currentPassword">
|
||||
<el-input v-model="pwdForm.currentPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="New password" prop="newPassword">
|
||||
@@ -1732,7 +1737,7 @@ onBeforeUnmount(() => {
|
||||
<el-form-item label="Confirm new password" prop="confirmPassword">
|
||||
<el-input v-model="pwdForm.confirmPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="changingPassword" @click="changePassword">淇敼瀵嗙爜</el-button>
|
||||
<el-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
@@ -1754,7 +1759,7 @@ onBeforeUnmount(() => {
|
||||
v-model="form.douyinCookie"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="鍙矘璐?ttwid銆乵sToken 绛?Cookie"
|
||||
placeholder="可粘贴 ttwid、msToken 等 Cookie"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -1766,10 +1771,10 @@ onBeforeUnmount(() => {
|
||||
<div class="settings-savebar" :style="savebarStyle">
|
||||
<div class="settings-savebar__content">
|
||||
<div class="settings-savebar__copy">
|
||||
<div class="settings-savebar__title">褰撳墠淇敼涓嶄細鑷姩淇濆瓨</div>
|
||||
<div class="settings-savebar__title">当前修改不会自动保存</div>
|
||||
<div class="settings-savebar__subtitle">You can save the current settings from anywhere on this page.</div>
|
||||
</div>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">淇濆瓨璁剧疆</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed partial class FfmpegService
|
||||
{
|
||||
private const int MaxInSessionRetryAttempts = 3;
|
||||
private static readonly TimeSpan RuntimeSourceFailureWindow = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RuntimeOfflineVerificationStopTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RuntimeOfflineVerificationKillTimeout = TimeSpan.FromSeconds(8);
|
||||
@@ -472,6 +473,11 @@ public sealed partial class FfmpegService
|
||||
return;
|
||||
}
|
||||
|
||||
if (await TryRecoverUnexpectedExitAsync(runtime, activeDanmakuSummary))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await FinalizeExitedSessionAsync(runtime, process, activeDanmakuSummary);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -522,6 +528,11 @@ public sealed partial class FfmpegService
|
||||
return false;
|
||||
}
|
||||
|
||||
if (runtime.RetryAttemptCount >= MaxInSessionRetryAttempts)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var observedAt = DateTimeOffset.UtcNow;
|
||||
if (runtime.StartupFailureKind == StartupFailureKind.InputOptionCompatibility)
|
||||
{
|
||||
@@ -689,6 +700,182 @@ public sealed partial class FfmpegService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryRecoverUnexpectedExitAsync(
|
||||
SessionProcessRuntime runtime,
|
||||
SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary)
|
||||
{
|
||||
if (!runtime.HasOpenedFirstSegment ||
|
||||
runtime.StopRequested ||
|
||||
runtime.CompletionRequested ||
|
||||
runtime.SaveMode != RecordSaveMode.Segmented ||
|
||||
runtime.RetryAttemptCount >= MaxInSessionRetryAttempts)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
|
||||
var liveRoomStatusService = scope.ServiceProvider.GetRequiredService<LiveRoomStatusService>();
|
||||
var eventScriptService = scope.ServiceProvider.GetRequiredService<IEventScriptService>();
|
||||
var recordUploadService = scope.ServiceProvider.GetRequiredService<RecordUploadService>();
|
||||
|
||||
var session = await dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordTasks)
|
||||
.ThenInclude(item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId);
|
||||
|
||||
if (session?.LiveRoom is null || !IsActiveSessionStatus(session.Status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var currentTask = session.RecordTasks
|
||||
.OrderBy(item => item.SegmentIndex)
|
||||
.ThenBy(item => item.CreatedAt)
|
||||
.FirstOrDefault(item => item.Id == runtime.CurrentTaskId);
|
||||
|
||||
if (currentTask is null || !IsActiveTaskStatus(currentTask.Status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var observedAt = DateTimeOffset.UtcNow;
|
||||
var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform);
|
||||
var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId);
|
||||
await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (!liveStatus.IsLive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var retryAttempt = runtime.RetryAttemptCount + 1;
|
||||
var refreshedStream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality);
|
||||
var retryStream = SelectRetryStreamForCurrentSession(refreshedStream, runtime);
|
||||
var nextSegmentIndex = Math.Max(currentTask.SegmentIndex + 1, session.ActiveSegmentIndex + 1);
|
||||
var outputPathPattern = session.OutputPathPattern ?? runtime.OutputPathPattern;
|
||||
var retryOutputPath = NormalizeAbsolutePath(
|
||||
ResolveSegmentOutputPath(outputPathPattern, session.SaveMode, nextSegmentIndex));
|
||||
|
||||
var retryTask = new RecordTask(
|
||||
session.LiveRoomId,
|
||||
session.Id,
|
||||
nextSegmentIndex,
|
||||
session.PreferredQuality,
|
||||
session.OutputFormat,
|
||||
observedAt);
|
||||
retryTask.MarkStarting(retryStream.SelectedUrl, retryOutputPath, observedAt);
|
||||
await dbContext.RecordTasks.AddAsync(retryTask);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await StartInternalAsync(
|
||||
session,
|
||||
retryTask,
|
||||
retryStream,
|
||||
runtime.RecordingSettings,
|
||||
runtime.InputOptionProfile,
|
||||
hasRetriedWithCompatibilityProfile: false,
|
||||
hasRetriedWithRefreshedStream: false,
|
||||
retryAttempt);
|
||||
}
|
||||
catch
|
||||
{
|
||||
dbContext.RecordTasks.Remove(retryTask);
|
||||
await dbContext.SaveChangesAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var restartedAt = DateTimeOffset.UtcNow;
|
||||
var previousTaskId = currentTask.Id;
|
||||
var previousDurationSeconds = currentTask.StartedAt.HasValue
|
||||
? Math.Max(0, (restartedAt - currentTask.StartedAt.Value).TotalSeconds)
|
||||
: (double?)null;
|
||||
var previousEffectiveOutputPath = currentTask.OutputFilePath ??
|
||||
NormalizeAbsolutePath(ResolveSegmentOutputPath(outputPathPattern, session.SaveMode, currentTask.SegmentIndex));
|
||||
var previousRecorderSegmentPaths = runtime.CaptureCurrentRecorderSegments();
|
||||
|
||||
if (session.OutputFormat == RecordOutputFormat.Mp4)
|
||||
{
|
||||
currentTask.MarkProcessing("Recorder exited unexpectedly. Segment finalization was queued before retry.", restartedAt);
|
||||
SetPostProcessState(
|
||||
session.Id,
|
||||
previousTaskId,
|
||||
"Queued",
|
||||
0,
|
||||
$"Waiting for {Path.GetFileName(GetRecorderOutputPath(previousEffectiveOutputPath, session.OutputFormat, session.SaveMode))} to finish writing");
|
||||
}
|
||||
else
|
||||
{
|
||||
currentTask.MarkCompleted(restartedAt, previousDurationSeconds);
|
||||
}
|
||||
|
||||
currentTask.DetachProcess(restartedAt);
|
||||
session.MarkRunning(restartedAt);
|
||||
session.ActivateSegment(nextSegmentIndex, restartedAt);
|
||||
retryTask.MarkRunning(restartedAt);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (session.OutputFormat == RecordOutputFormat.Mp4)
|
||||
{
|
||||
_ = FinalizeCompletedSegmentAsync(
|
||||
session.Id,
|
||||
previousTaskId,
|
||||
previousDurationSeconds,
|
||||
restartedAt,
|
||||
activeDanmakuSummary,
|
||||
previousRecorderSegmentPaths);
|
||||
}
|
||||
else
|
||||
{
|
||||
await UpsertRecordResultAsync(
|
||||
currentTask,
|
||||
dbContext,
|
||||
previousEffectiveOutputPath,
|
||||
CalculateFileSize(previousEffectiveOutputPath),
|
||||
currentTask.DurationSeconds,
|
||||
activeDanmakuSummary?.FilePath,
|
||||
activeDanmakuSummary?.MessageCount ?? 0,
|
||||
restartedAt);
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
ClearPostProcessState(currentTask.Id);
|
||||
var previousResult = await LoadRecordResultAsync(dbContext, currentTask.Id);
|
||||
await eventScriptService.RunSegmentCompletedAsync(
|
||||
session.LiveRoom,
|
||||
session,
|
||||
currentTask,
|
||||
previousResult,
|
||||
previousEffectiveOutputPath,
|
||||
restartedAt);
|
||||
await recordUploadService.TryAutoUploadTaskAsync(currentTask.Id, CancellationToken.None);
|
||||
}
|
||||
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"FFmpeg",
|
||||
$"ffmpeg exited unexpectedly. Retrying within the current session ({retryAttempt}/{MaxInSessionRetryAttempts}).",
|
||||
runtime.GetRecentOutputSummary(),
|
||||
session.LiveRoomId,
|
||||
session.Id,
|
||||
retryTask.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "In-session ffmpeg retry failed for session {RecordSessionId}", runtime.RecordSessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryRetryWithAlternateProtocolAsync(
|
||||
SessionProcessRuntime runtime,
|
||||
RecordSession session,
|
||||
@@ -790,6 +977,38 @@ public sealed partial class FfmpegService
|
||||
}
|
||||
}
|
||||
|
||||
private static StreamUrlResult SelectRetryStreamForCurrentSession(
|
||||
StreamUrlResult refreshedStream,
|
||||
SessionProcessRuntime runtime)
|
||||
{
|
||||
var matchedOption = refreshedStream.AvailableQualities
|
||||
.Where(item => item.Protocol.Equals(runtime.SelectedProtocol, StringComparison.OrdinalIgnoreCase) &&
|
||||
item.QualityKey.Equals(runtime.SelectedQuality, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(item => item.Rank)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (matchedOption is null)
|
||||
{
|
||||
matchedOption = refreshedStream.AvailableQualities
|
||||
.Where(item => item.Protocol.Equals(runtime.SelectedProtocol, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(item => item.Rank)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (matchedOption is null)
|
||||
{
|
||||
return refreshedStream;
|
||||
}
|
||||
|
||||
return new StreamUrlResult(
|
||||
matchedOption.QualityKey,
|
||||
matchedOption.Protocol,
|
||||
matchedOption.Url,
|
||||
refreshedStream.InputHeaders,
|
||||
refreshedStream.AvailableQualities,
|
||||
refreshedStream.SelectedVideoCodec);
|
||||
}
|
||||
|
||||
private async Task FinalizeExitedSessionAsync(
|
||||
SessionProcessRuntime runtime,
|
||||
Process process,
|
||||
@@ -1327,11 +1546,15 @@ public sealed partial class FfmpegService
|
||||
|
||||
private static bool IsRetryableStartupFailureLine(string line) =>
|
||||
line.Contains("Error reading HTTP response", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("overlong headers", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("unexpected EOF", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("Connection reset", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("I/O error", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("Invalid data found when processing input", StringComparison.OrdinalIgnoreCase) ||
|
||||
(line.Contains("Invalid argument", StringComparison.OrdinalIgnoreCase) &&
|
||||
(line.Contains("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("https://", StringComparison.OrdinalIgnoreCase))) ||
|
||||
line.Contains("Server returned 4", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("Server returned 5", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.Contains("HTTP error 4", StringComparison.OrdinalIgnoreCase) ||
|
||||
|
||||
@@ -645,6 +645,7 @@ public sealed partial class FfmpegService
|
||||
string outputFilePath,
|
||||
RecordOutputFormat outputFormat,
|
||||
RecordSaveMode saveMode,
|
||||
int startingSegmentIndex,
|
||||
RecordingTemplateType recordingTemplate,
|
||||
bool enableReconnect,
|
||||
int reconnectDelayMaxSeconds,
|
||||
@@ -708,7 +709,7 @@ public sealed partial class FfmpegService
|
||||
arguments.AddRange(
|
||||
[
|
||||
"-f", "segment",
|
||||
"-segment_start_number", "1",
|
||||
"-segment_start_number", Math.Max(1, startingSegmentIndex).ToString(),
|
||||
"-segment_time", Math.Max(60, segmentDurationMinutes * 60).ToString(),
|
||||
"-break_non_keyframes", "0",
|
||||
"-reset_timestamps", "1",
|
||||
|
||||
@@ -159,6 +159,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
recorderOutputPath,
|
||||
recordSession.OutputFormat,
|
||||
recordSession.SaveMode,
|
||||
Math.Max(1, initialTask.SegmentIndex),
|
||||
recordingSettings.RecordingTemplate,
|
||||
recordingSettings.EnableAutoReconnect,
|
||||
recordingSettings.ReconnectDelayMaxSeconds,
|
||||
|
||||
Reference in New Issue
Block a user