feat: add reliable OpenList segment uploads

This commit is contained in:
2026-08-01 19:27:56 +08:00
parent 12ba62e2a0
commit 9091c1abc7
30 changed files with 3910 additions and 35 deletions
@@ -94,6 +94,18 @@ function resolveToneByNumber(value: number, context: BadgeContext): Tone {
return "red";
}
if (value === 3) {
return "blue";
}
if (value === 4) {
return "yellow";
}
if (value === 5) {
return "orange";
}
return "gray";
}
+42 -1
View File
@@ -257,6 +257,10 @@ export interface RecordArtifactUploadItemResult {
remoteVideoPath?: string;
remoteDanmakuPath?: string;
deletedLocalFilesAfterUpload: boolean;
uploadStatus?: number;
progressPercent?: number;
attemptCount: number;
nextAttemptAt?: string;
}
export interface RecordArtifactUploadBatchResult {
@@ -286,6 +290,11 @@ export interface UploadTaskItem {
uploadErrorMessage?: string;
deletedLocalFilesAfterUpload: boolean;
createdAt: string;
uploadProgressPercent?: number;
uploadAttemptCount: number;
nextUploadAttemptAt?: string;
currentUploadArtifact?: string;
externalUploadTaskId?: string;
}
export interface UploadTaskListResponse {
@@ -294,6 +303,9 @@ export interface UploadTaskListResponse {
notUploadedCount: number;
succeededCount: number;
failedCount: number;
queuedCount: number;
uploadingCount: number;
waitingRetryCount: number;
}
export interface ManualSegmentCompletedTriggerResult {
@@ -480,6 +492,7 @@ export interface SystemSettings {
platformRequestSettings: Record<string, PlatformRequestSettings>;
webDavUpload: WebDavUploadSettings;
s3Upload: S3UploadSettings;
openListUpload: OpenListUploadSettings;
enableEventScripts: boolean;
enableLiveStartedScript: boolean;
liveStartedScriptMode: string;
@@ -599,6 +612,32 @@ export interface S3UploadSettings {
forcePathStyle: boolean;
}
export interface OpenListUploadSettings {
baseUrl: string;
username: string;
password: string;
basePath: string;
sourcePath: string;
destinationPath: string;
}
export interface OpenListConnectionTestResult {
success: boolean;
version?: string;
message: string;
}
export interface OpenListDirectoryItem {
name: string;
path: string;
}
export interface OpenListDirectoryListResult {
path: string;
canWrite: boolean;
directories: OpenListDirectoryItem[];
}
export interface EventScriptTestResult {
success: boolean;
message: string;
@@ -813,7 +852,9 @@ export const uploadStatusLabelMap: Record<number, string> = {
0: "未上传",
1: "已上传",
2: "上传失败",
3: "上传中"
3: "上传中",
4: "排队中",
5: "等待重试"
};
export interface DanmakuEvent {
@@ -113,7 +113,8 @@ async function uploadSessionArtifacts() {
try {
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${props.id}/upload`);
const message = `会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}`;
const queued = data.items.some(item => item.uploadStatus === 4 || item.uploadStatus === 5);
const message = `${queued ? "会话上传已加入队列" : "会话上传完成"}:已受理 ${data.successCount},失败 ${data.failedCount}`;
ElMessage[data.failedCount === 0 ? "success" : "warning"](message);
await loadDetail();
} catch (error) {
+2 -1
View File
@@ -516,8 +516,9 @@ async function uploadSession(session: RecordSession) {
try {
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${session.id}/upload`);
const queued = data.items.some(item => item.uploadStatus === 4 || item.uploadStatus === 5);
ElMessage[data.failedCount === 0 ? "success" : "warning"](
`会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}`
`${queued ? "会话上传已加入队列" : "会话上传完成"}:已受理 ${data.successCount},失败 ${data.failedCount}`
);
await loadSessions({ resetPanels: false, resetSelection: false });
} catch (error) {
+325 -7
View File
@@ -6,6 +6,9 @@ import type {
CleanupOperation,
CleanupVideoFileCondition,
EventScriptTestResult,
OpenListConnectionTestResult,
OpenListDirectoryItem,
OpenListDirectoryListResult,
PlatformProxySettings,
PlatformRequestSettings,
SystemSettings,
@@ -47,6 +50,13 @@ const saving = ref(false);
const changingPassword = ref(false);
const testingEmail = ref(false);
const testingWebhook = ref(false);
const testingOpenList = ref(false);
const openListDirectoryLoading = ref(false);
const openListDirectoryDialogVisible = ref(false);
const openListDirectoryPickerTarget = ref<"source" | "destination">("source");
const openListCurrentDirectory = ref("/");
const openListCurrentDirectoryCanWrite = ref(false);
const openListDirectories = ref<OpenListDirectoryItem[]>([]);
const runningRetentionCleanup = ref(false);
const exportingSettings = ref(false);
const importingSettings = ref(false);
@@ -156,6 +166,14 @@ const form = reactive<SettingsFormModel>({
prefix: "",
forcePathStyle: false
},
openListUpload: {
baseUrl: "",
username: "",
password: "",
basePath: "",
sourcePath: "",
destinationPath: ""
},
enableEventScripts: false,
enableLiveStartedScript: false,
liveStartedScriptMode: "path",
@@ -386,7 +404,8 @@ const eventScriptModeOptions = [
const uploadTargetOptions = [
{ label: "Do not upload", value: 0 },
{ label: "WebDAV", value: 1 },
{ label: "S3", value: 2 }
{ label: "S3", value: 2 },
{ label: "OpenList", value: 3 }
];
const retentionVideoFileOptions = [
@@ -471,6 +490,117 @@ const nestedSegmentedExamplePath = computed(() => {
return `Douyin/origin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
});
function normalizeOpenListPath(path: string) {
const segments = path.replace(/\\/g, "/").split("/").filter(Boolean);
return segments.length === 0 ? "/" : `/${segments.join("/")}`;
}
function joinOpenListPath(root: string, relativePath: string) {
return normalizeOpenListPath(`${root}/${relativePath}`);
}
const openListSourcePreview = computed(() =>
form.openListUpload.sourcePath.trim()
? joinOpenListPath(form.openListUpload.sourcePath, segmentedExamplePath.value)
: "请先选择源挂载根目录"
);
const openListDestinationPreview = computed(() =>
form.openListUpload.destinationPath.trim()
? joinOpenListPath(form.openListUpload.destinationPath, segmentedExamplePath.value)
: "请先选择目标归档根目录"
);
const openListPickerTitle = computed(() =>
openListDirectoryPickerTarget.value === "source"
? "选择 OpenList 源挂载根目录"
: "选择 OpenList 目标归档根目录"
);
const openListParentDirectory = computed(() => {
const normalized = normalizeOpenListPath(openListCurrentDirectory.value);
if (normalized === "/") {
return "/";
}
const index = normalized.lastIndexOf("/");
return index <= 0 ? "/" : normalized.slice(0, index);
});
function buildOpenListConnectionPayload() {
return {
baseUrl: form.openListUpload.baseUrl,
username: form.openListUpload.username,
password: form.openListUpload.password
};
}
async function testOpenListConnection() {
testingOpenList.value = true;
try {
const { data } = await apiClient.post<OpenListConnectionTestResult>(
"/settings/openlist/test",
buildOpenListConnectionPayload()
);
ElMessage[data.success ? "success" : "warning"](data.message);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "OpenList 连接测试失败"));
} finally {
testingOpenList.value = false;
}
}
async function loadOpenListDirectories(path: string) {
openListDirectoryLoading.value = true;
try {
const { data } = await apiClient.post<OpenListDirectoryListResult>(
"/settings/openlist/directories",
{
...buildOpenListConnectionPayload(),
path: normalizeOpenListPath(path)
}
);
openListCurrentDirectory.value = data.path;
openListCurrentDirectoryCanWrite.value = data.canWrite;
openListDirectories.value = data.directories;
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "OpenList 目录加载失败"));
} finally {
openListDirectoryLoading.value = false;
}
}
async function openOpenListDirectoryPicker(target: "source" | "destination") {
openListDirectoryPickerTarget.value = target;
openListDirectoryDialogVisible.value = true;
const configuredPath = target === "source"
? form.openListUpload.sourcePath
: form.openListUpload.destinationPath;
await loadOpenListDirectories(configuredPath || "/");
}
async function enterOpenListDirectory(path: string) {
await loadOpenListDirectories(path);
}
function selectOpenListCurrentDirectory() {
if (openListDirectoryPickerTarget.value === "destination" && !openListCurrentDirectoryCanWrite.value) {
ElMessage.warning("该目录未声明写入权限,请选择可写的归档目录。");
return;
}
if (openListDirectoryPickerTarget.value === "source") {
form.openListUpload.sourcePath = openListCurrentDirectory.value;
} else {
form.openListUpload.destinationPath = openListCurrentDirectory.value;
form.openListUpload.basePath = openListCurrentDirectory.value;
}
openListDirectoryDialogVisible.value = false;
}
async function loadSettings() {
loading.value = true;
loadError.value = "";
@@ -1326,9 +1456,9 @@ watch(
</el-form>
</el-card>
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Upload and archive</h3>
<p class="section-subtitle">Upload video files and matching danmaku XML automatically or manually, then optionally delete local files after a successful upload.</p>
<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">
@@ -1338,12 +1468,12 @@ watch(
</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>
@@ -1427,7 +1557,7 @@ watch(
</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>
@@ -1450,11 +1580,119 @@ watch(
</el-form>
</div>
<div v-if="form.enableFileUpload && form.uploadTarget === 3" class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">OpenList 服务端复制</h4>
<p class="template-section__subtitle">
源目录应是 OpenList 可见的本地录制挂载目标目录是归档根目录系统由 OpenList 在服务端复制避免大文件经 Live Recorder 中转
</p>
</div>
<el-button :loading="testingOpenList" @click="testOpenListConnection">测试连接</el-button>
</div>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="OpenList 地址">
<el-input v-model="form.openListUpload.baseUrl" placeholder="https://openlist.example.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用户名">
<el-input v-model="form.openListUpload.username" autocomplete="username" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="密码">
<el-input
v-model="form.openListUpload.password"
type="password"
autocomplete="current-password"
show-password
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="源挂载根目录">
<el-input v-model="form.openListUpload.sourcePath" readonly placeholder="从 OpenList 现有目录中选择">
<template #append>
<el-button @click="openOpenListDirectoryPicker('source')">选择</el-button>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标归档根目录">
<el-input v-model="form.openListUpload.destinationPath" readonly placeholder="从 OpenList 现有目录中选择">
<template #append>
<el-button @click="openOpenListDirectoryPicker('destination')">选择</el-button>
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="openlist-path-preview">
<div>
<span>源文件示例</span>
<code>{{ openListSourcePreview }}</code>
</div>
<div>
<span>归档结果示例</span>
<code>{{ openListDestinationPreview }}</code>
</div>
</div>
<el-alert
type="info"
:closable="false"
show-icon
title="路径中已有平台、日期或主播目录时会直接复用;系统只逐级创建缺失目录。同名文件大小或可用哈希不一致时会标记冲突,绝不覆盖。"
/>
</div>
<div class="helper-panel">
自动上传固定处理视频文件 + 对应弹幕 XML只有两者都上传成功并且你打开上传后删本地系统才会清理本地文件
</div>
</el-card>
<el-dialog v-model="openListDirectoryDialogVisible" :title="openListPickerTitle" width="min(680px, 92vw)">
<div class="openlist-directory-toolbar">
<el-button
:disabled="openListCurrentDirectory === '/' || openListDirectoryLoading"
@click="enterOpenListDirectory(openListParentDirectory)"
>
返回上级
</el-button>
<code>{{ openListCurrentDirectory }}</code>
<el-tag :type="openListCurrentDirectoryCanWrite ? 'success' : 'info'">
{{ openListCurrentDirectoryCanWrite ? "可写" : "只读或未知" }}
</el-tag>
</div>
<div v-loading="openListDirectoryLoading" class="openlist-directory-list">
<el-empty v-if="!openListDirectoryLoading && openListDirectories.length === 0" description="当前目录没有子目录" />
<button
v-for="directory in openListDirectories"
:key="directory.path"
type="button"
class="openlist-directory-item"
@click="enterOpenListDirectory(directory.path)"
>
<span>📁</span>
<strong>{{ directory.name }}</strong>
<code>{{ directory.path }}</code>
</button>
</div>
<template #footer>
<el-button @click="openListDirectoryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="selectOpenListCurrentDirectory">选择当前目录</el-button>
</template>
</el-dialog>
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
<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>
@@ -2575,6 +2813,76 @@ watch(
line-height: 1.7;
}
.openlist-path-preview {
display: grid;
gap: 10px;
margin: 4px 0 16px;
padding: 14px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: var(--surface);
}
.openlist-path-preview > div {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.openlist-path-preview span {
color: var(--text-secondary);
font-size: 13px;
}
.openlist-path-preview code,
.openlist-directory-toolbar code,
.openlist-directory-item code {
overflow-wrap: anywhere;
color: var(--text-primary);
font-size: 12px;
}
.openlist-directory-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.openlist-directory-toolbar code {
flex: 1;
}
.openlist-directory-list {
min-height: 220px;
max-height: 52vh;
overflow-y: auto;
display: grid;
align-content: start;
gap: 8px;
}
.openlist-directory-item {
display: grid;
grid-template-columns: auto minmax(120px, auto) minmax(0, 1fr);
gap: 10px;
align-items: center;
width: 100%;
padding: 11px 12px;
border: 1px solid var(--border-subtle);
border-radius: 10px;
color: var(--text-primary);
background: var(--surface);
cursor: pointer;
text-align: left;
}
.openlist-directory-item:hover {
border-color: var(--primary);
background: var(--surface-raised);
}
@media (max-width: 960px) {
.settings-overview-grid {
grid-template-columns: 1fr;
@@ -2643,6 +2951,16 @@ watch(
padding: 16px 16px 2px;
}
.openlist-path-preview > div,
.openlist-directory-item {
grid-template-columns: 1fr;
}
.openlist-directory-toolbar {
align-items: flex-start;
flex-wrap: wrap;
}
.settings-savebar__content {
padding: 12px 14px;
border-radius: 12px;
+56 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
@@ -21,18 +21,25 @@ const totalCount = ref(0);
const notUploadedCount = ref(0);
const succeededCount = ref(0);
const failedCount = ref(0);
const queuedCount = ref(0);
const uploadingCount = ref(0);
const waitingRetryCount = ref(0);
const uploadStatusFilter = ref<number | null>(null);
const currentPage = ref(1);
const pageSize = 50;
const uploadingTaskId = ref<string | null>(null);
const retryingFailed = ref(false);
const uploadingAllPending = ref(false);
let refreshTimer: number | null = null;
const filterOptions = [
{ label: "全部", value: null as number | null },
{ label: "待上传", value: 0 },
{ label: "已上传", value: 1 },
{ label: "上传失败", value: 2 }
{ label: "上传失败", value: 2 },
{ label: "上传中", value: 3 },
{ label: "排队中", value: 4 },
{ label: "等待重试", value: 5 }
];
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize)));
@@ -56,6 +63,9 @@ async function loadUploadStatus() {
notUploadedCount.value = data.notUploadedCount;
succeededCount.value = data.succeededCount;
failedCount.value = data.failedCount;
queuedCount.value = data.queuedCount;
uploadingCount.value = data.uploadingCount;
waitingRetryCount.value = data.waitingRetryCount;
} catch (error) {
loadError.value = getApiErrorMessage(error, "上传任务列表加载失败,请稍后重试。");
} finally {
@@ -113,7 +123,7 @@ async function retryAllFailed() {
}
ElMessage[successCount > 0 ? "success" : "warning"](
`重试完成:成功 ${successCount},失败 ${failCount}`
`重试请求已处理:已受理 ${successCount},失败 ${failCount}`
);
await loadUploadStatus();
} finally {
@@ -147,7 +157,7 @@ async function uploadAllPending() {
}
ElMessage[successCount > 0 ? "success" : "warning"](
`批量上传完成:成功 ${successCount},失败 ${failCount}`
`批量上传请求已处理:已受理 ${successCount},失败 ${failCount}`
);
await loadUploadStatus();
} finally {
@@ -183,7 +193,26 @@ function formatFileSize(bytes?: number) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
onMounted(loadUploadStatus);
function formatProgress(value?: number) {
return typeof value === "number" && Number.isFinite(value)
? `${Math.max(0, Math.min(100, value)).toFixed(1)}%`
: "-";
}
onMounted(() => {
void loadUploadStatus();
refreshTimer = window.setInterval(() => {
if (!loading.value && !uploadingTaskId.value && !retryingFailed.value && !uploadingAllPending.value) {
void loadUploadStatus();
}
}, 5000);
});
onBeforeUnmount(() => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer);
}
});
</script>
<template>
@@ -210,6 +239,8 @@ onMounted(loadUploadStatus);
<MetricCard label="待上传" :value="notUploadedCount" description="尚未上传的分片" :icon="UploadFilled" />
<MetricCard label="已上传" :value="succeededCount" description="上传成功的分片" :icon="CircleCheck" />
<MetricCard label="上传失败" :value="failedCount" description="上传失败的分片" :icon="CircleClose" />
<MetricCard label="队列处理中" :value="queuedCount + uploadingCount" description="排队或正在由 OpenList 复制" :icon="UploadFilled" />
<MetricCard label="等待重试" :value="waitingRetryCount" description="按退避策略等待下一次尝试" :icon="Bell" />
</div>
<el-card class="surface-card upload-card" shadow="never">
@@ -305,6 +336,25 @@ onMounted(loadUploadStatus);
</template>
</el-table-column>
<el-table-column label="进度 / 重试" width="150">
<template #default="{ row }">
<div v-if="row.uploadStatus === 3 || row.uploadStatus === 4 || row.uploadStatus === 5">
<el-progress
:percentage="Math.round(row.uploadProgressPercent || 0)"
:stroke-width="6"
:show-text="false"
/>
<div class="cell-subtitle">
{{ formatProgress(row.uploadProgressPercent) }} · {{ row.uploadAttemptCount || 0 }}
</div>
<div v-if="row.nextUploadAttemptAt" class="cell-subtitle">
下次{{ formatDate(row.nextUploadAttemptAt) }}
</div>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="上传方式" width="100">
<template #default="{ row }">
{{ row.lastUploadProvider || "-" }}
@@ -334,7 +384,7 @@ onMounted(loadUploadStatus);
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.uploadStatus !== 1"
v-if="row.uploadStatus !== 1 && row.uploadStatus !== 3 && row.uploadStatus !== 4 && row.uploadStatus !== 5"
size="small"
type="primary"
:loading="uploadingTaskId === row.recordTaskId"