feat: add reliable OpenList segment uploads
This commit is contained in:
@@ -94,6 +94,18 @@ function resolveToneByNumber(value: number, context: BadgeContext): Tone {
|
|||||||
return "red";
|
return "red";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value === 3) {
|
||||||
|
return "blue";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 4) {
|
||||||
|
return "yellow";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 5) {
|
||||||
|
return "orange";
|
||||||
|
}
|
||||||
|
|
||||||
return "gray";
|
return "gray";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-1
@@ -257,6 +257,10 @@ export interface RecordArtifactUploadItemResult {
|
|||||||
remoteVideoPath?: string;
|
remoteVideoPath?: string;
|
||||||
remoteDanmakuPath?: string;
|
remoteDanmakuPath?: string;
|
||||||
deletedLocalFilesAfterUpload: boolean;
|
deletedLocalFilesAfterUpload: boolean;
|
||||||
|
uploadStatus?: number;
|
||||||
|
progressPercent?: number;
|
||||||
|
attemptCount: number;
|
||||||
|
nextAttemptAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordArtifactUploadBatchResult {
|
export interface RecordArtifactUploadBatchResult {
|
||||||
@@ -286,6 +290,11 @@ export interface UploadTaskItem {
|
|||||||
uploadErrorMessage?: string;
|
uploadErrorMessage?: string;
|
||||||
deletedLocalFilesAfterUpload: boolean;
|
deletedLocalFilesAfterUpload: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
uploadProgressPercent?: number;
|
||||||
|
uploadAttemptCount: number;
|
||||||
|
nextUploadAttemptAt?: string;
|
||||||
|
currentUploadArtifact?: string;
|
||||||
|
externalUploadTaskId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadTaskListResponse {
|
export interface UploadTaskListResponse {
|
||||||
@@ -294,6 +303,9 @@ export interface UploadTaskListResponse {
|
|||||||
notUploadedCount: number;
|
notUploadedCount: number;
|
||||||
succeededCount: number;
|
succeededCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
queuedCount: number;
|
||||||
|
uploadingCount: number;
|
||||||
|
waitingRetryCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManualSegmentCompletedTriggerResult {
|
export interface ManualSegmentCompletedTriggerResult {
|
||||||
@@ -480,6 +492,7 @@ export interface SystemSettings {
|
|||||||
platformRequestSettings: Record<string, PlatformRequestSettings>;
|
platformRequestSettings: Record<string, PlatformRequestSettings>;
|
||||||
webDavUpload: WebDavUploadSettings;
|
webDavUpload: WebDavUploadSettings;
|
||||||
s3Upload: S3UploadSettings;
|
s3Upload: S3UploadSettings;
|
||||||
|
openListUpload: OpenListUploadSettings;
|
||||||
enableEventScripts: boolean;
|
enableEventScripts: boolean;
|
||||||
enableLiveStartedScript: boolean;
|
enableLiveStartedScript: boolean;
|
||||||
liveStartedScriptMode: string;
|
liveStartedScriptMode: string;
|
||||||
@@ -599,6 +612,32 @@ export interface S3UploadSettings {
|
|||||||
forcePathStyle: boolean;
|
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 {
|
export interface EventScriptTestResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -813,7 +852,9 @@ export const uploadStatusLabelMap: Record<number, string> = {
|
|||||||
0: "未上传",
|
0: "未上传",
|
||||||
1: "已上传",
|
1: "已上传",
|
||||||
2: "上传失败",
|
2: "上传失败",
|
||||||
3: "上传中"
|
3: "上传中",
|
||||||
|
4: "排队中",
|
||||||
|
5: "等待重试"
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DanmakuEvent {
|
export interface DanmakuEvent {
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ async function uploadSessionArtifacts() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${props.id}/upload`);
|
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);
|
ElMessage[data.failedCount === 0 ? "success" : "warning"](message);
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -516,8 +516,9 @@ async function uploadSession(session: RecordSession) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${session.id}/upload`);
|
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"](
|
ElMessage[data.failedCount === 0 ? "success" : "warning"](
|
||||||
`会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}。`
|
`${queued ? "会话上传已加入队列" : "会话上传完成"}:已受理 ${data.successCount},失败 ${data.failedCount}。`
|
||||||
);
|
);
|
||||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import type {
|
|||||||
CleanupOperation,
|
CleanupOperation,
|
||||||
CleanupVideoFileCondition,
|
CleanupVideoFileCondition,
|
||||||
EventScriptTestResult,
|
EventScriptTestResult,
|
||||||
|
OpenListConnectionTestResult,
|
||||||
|
OpenListDirectoryItem,
|
||||||
|
OpenListDirectoryListResult,
|
||||||
PlatformProxySettings,
|
PlatformProxySettings,
|
||||||
PlatformRequestSettings,
|
PlatformRequestSettings,
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
@@ -47,6 +50,13 @@ const saving = ref(false);
|
|||||||
const changingPassword = ref(false);
|
const changingPassword = ref(false);
|
||||||
const testingEmail = ref(false);
|
const testingEmail = ref(false);
|
||||||
const testingWebhook = 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 runningRetentionCleanup = ref(false);
|
||||||
const exportingSettings = ref(false);
|
const exportingSettings = ref(false);
|
||||||
const importingSettings = ref(false);
|
const importingSettings = ref(false);
|
||||||
@@ -156,6 +166,14 @@ const form = reactive<SettingsFormModel>({
|
|||||||
prefix: "",
|
prefix: "",
|
||||||
forcePathStyle: false
|
forcePathStyle: false
|
||||||
},
|
},
|
||||||
|
openListUpload: {
|
||||||
|
baseUrl: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
basePath: "",
|
||||||
|
sourcePath: "",
|
||||||
|
destinationPath: ""
|
||||||
|
},
|
||||||
enableEventScripts: false,
|
enableEventScripts: false,
|
||||||
enableLiveStartedScript: false,
|
enableLiveStartedScript: false,
|
||||||
liveStartedScriptMode: "path",
|
liveStartedScriptMode: "path",
|
||||||
@@ -386,7 +404,8 @@ const eventScriptModeOptions = [
|
|||||||
const uploadTargetOptions = [
|
const uploadTargetOptions = [
|
||||||
{ label: "Do not upload", value: 0 },
|
{ label: "Do not upload", value: 0 },
|
||||||
{ label: "WebDAV", value: 1 },
|
{ label: "WebDAV", value: 1 },
|
||||||
{ label: "S3", value: 2 }
|
{ label: "S3", value: 2 },
|
||||||
|
{ label: "OpenList", value: 3 }
|
||||||
];
|
];
|
||||||
|
|
||||||
const retentionVideoFileOptions = [
|
const retentionVideoFileOptions = [
|
||||||
@@ -471,6 +490,117 @@ const nestedSegmentedExamplePath = computed(() => {
|
|||||||
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}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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() {
|
async function loadSettings() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
loadError.value = "";
|
loadError.value = "";
|
||||||
@@ -1326,9 +1456,9 @@ watch(
|
|||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||||
<h3 class="section-title">Upload and archive</h3>
|
<h3 class="section-title">上传与归档</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>
|
<p class="section-subtitle">分片完成后可自动加入上传队列;视频和对应弹幕 XML 均校验成功后,才会按设置清理本地文件。</p>
|
||||||
|
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
@@ -1338,12 +1468,12 @@ watch(
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item label="鑷姩涓婁紶">
|
<el-form-item label="自动上传">
|
||||||
<el-switch v-model="form.enableAutoUpload" :disabled="!form.enableFileUpload" />
|
<el-switch v-model="form.enableAutoUpload" :disabled="!form.enableFileUpload" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item label="涓婁紶鍚庡垹鏈湴">
|
<el-form-item label="上传后删本地">
|
||||||
<el-switch v-model="form.deleteLocalFilesAfterUpload" :disabled="!form.enableFileUpload" />
|
<el-switch v-model="form.deleteLocalFilesAfterUpload" :disabled="!form.enableFileUpload" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -1427,7 +1557,7 @@ watch(
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="鍓嶇紑">
|
<el-form-item label="前缀">
|
||||||
<el-input v-model="form.s3Upload.prefix" placeholder="live-recorder/" />
|
<el-input v-model="form.s3Upload.prefix" placeholder="live-recorder/" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -1450,11 +1580,119 @@ watch(
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</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">
|
<div class="helper-panel">
|
||||||
自动上传固定处理“视频文件 + 对应弹幕 XML”。只有两者都上传成功并且你打开“上传后删本地”时,系统才会清理本地文件。
|
自动上传固定处理“视频文件 + 对应弹幕 XML”。只有两者都上传成功并且你打开“上传后删本地”时,系统才会清理本地文件。
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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">
|
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||||
<h3 class="section-title">平台代理</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>
|
<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;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.settings-overview-grid {
|
.settings-overview-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -2643,6 +2951,16 @@ watch(
|
|||||||
padding: 16px 16px 2px;
|
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 {
|
.settings-savebar__content {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
|
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
|
||||||
@@ -21,18 +21,25 @@ const totalCount = ref(0);
|
|||||||
const notUploadedCount = ref(0);
|
const notUploadedCount = ref(0);
|
||||||
const succeededCount = ref(0);
|
const succeededCount = ref(0);
|
||||||
const failedCount = 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 uploadStatusFilter = ref<number | null>(null);
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const uploadingTaskId = ref<string | null>(null);
|
const uploadingTaskId = ref<string | null>(null);
|
||||||
const retryingFailed = ref(false);
|
const retryingFailed = ref(false);
|
||||||
const uploadingAllPending = ref(false);
|
const uploadingAllPending = ref(false);
|
||||||
|
let refreshTimer: number | null = null;
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ label: "全部", value: null as number | null },
|
{ label: "全部", value: null as number | null },
|
||||||
{ label: "待上传", value: 0 },
|
{ label: "待上传", value: 0 },
|
||||||
{ label: "已上传", value: 1 },
|
{ 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)));
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize)));
|
||||||
@@ -56,6 +63,9 @@ async function loadUploadStatus() {
|
|||||||
notUploadedCount.value = data.notUploadedCount;
|
notUploadedCount.value = data.notUploadedCount;
|
||||||
succeededCount.value = data.succeededCount;
|
succeededCount.value = data.succeededCount;
|
||||||
failedCount.value = data.failedCount;
|
failedCount.value = data.failedCount;
|
||||||
|
queuedCount.value = data.queuedCount;
|
||||||
|
uploadingCount.value = data.uploadingCount;
|
||||||
|
waitingRetryCount.value = data.waitingRetryCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = getApiErrorMessage(error, "上传任务列表加载失败,请稍后重试。");
|
loadError.value = getApiErrorMessage(error, "上传任务列表加载失败,请稍后重试。");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -113,7 +123,7 @@ async function retryAllFailed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ElMessage[successCount > 0 ? "success" : "warning"](
|
ElMessage[successCount > 0 ? "success" : "warning"](
|
||||||
`重试完成:成功 ${successCount},失败 ${failCount}。`
|
`重试请求已处理:已受理 ${successCount},失败 ${failCount}。`
|
||||||
);
|
);
|
||||||
await loadUploadStatus();
|
await loadUploadStatus();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -147,7 +157,7 @@ async function uploadAllPending() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ElMessage[successCount > 0 ? "success" : "warning"](
|
ElMessage[successCount > 0 ? "success" : "warning"](
|
||||||
`批量上传完成:成功 ${successCount},失败 ${failCount}。`
|
`批量上传请求已处理:已受理 ${successCount},失败 ${failCount}。`
|
||||||
);
|
);
|
||||||
await loadUploadStatus();
|
await loadUploadStatus();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -183,7 +193,26 @@ function formatFileSize(bytes?: number) {
|
|||||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -210,6 +239,8 @@ onMounted(loadUploadStatus);
|
|||||||
<MetricCard label="待上传" :value="notUploadedCount" description="尚未上传的分片" :icon="UploadFilled" />
|
<MetricCard label="待上传" :value="notUploadedCount" description="尚未上传的分片" :icon="UploadFilled" />
|
||||||
<MetricCard label="已上传" :value="succeededCount" description="上传成功的分片" :icon="CircleCheck" />
|
<MetricCard label="已上传" :value="succeededCount" description="上传成功的分片" :icon="CircleCheck" />
|
||||||
<MetricCard label="上传失败" :value="failedCount" description="上传失败的分片" :icon="CircleClose" />
|
<MetricCard label="上传失败" :value="failedCount" description="上传失败的分片" :icon="CircleClose" />
|
||||||
|
<MetricCard label="队列处理中" :value="queuedCount + uploadingCount" description="排队或正在由 OpenList 复制" :icon="UploadFilled" />
|
||||||
|
<MetricCard label="等待重试" :value="waitingRetryCount" description="按退避策略等待下一次尝试" :icon="Bell" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="surface-card upload-card" shadow="never">
|
<el-card class="surface-card upload-card" shadow="never">
|
||||||
@@ -305,6 +336,25 @@ onMounted(loadUploadStatus);
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="上传方式" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.lastUploadProvider || "-" }}
|
{{ row.lastUploadProvider || "-" }}
|
||||||
@@ -334,7 +384,7 @@ onMounted(loadUploadStatus);
|
|||||||
<div class="task-actions-cell">
|
<div class="task-actions-cell">
|
||||||
<el-button size="small" @click="openDetail(row)">详情</el-button>
|
<el-button size="small" @click="openDetail(row)">详情</el-button>
|
||||||
<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"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="uploadingTaskId === row.recordTaskId"
|
:loading="uploadingTaskId === row.recordTaskId"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# =========================
|
# Deprecated compatibility script. Prefer the built-in persistent OpenList upload
|
||||||
# 基础配置
|
# queue configured in the Web admin. No endpoint or credentials are stored here.
|
||||||
# =========================
|
|
||||||
|
|
||||||
OPENLIST_BASE_URL="http://192.168.6.145:5244"
|
OPENLIST_BASE_URL="${OPENLIST_BASE_URL:-}"
|
||||||
OPENLIST_USERNAME="${OPENLIST_USERNAME:-admin}"
|
OPENLIST_USERNAME="${OPENLIST_USERNAME:-}"
|
||||||
OPENLIST_PASSWORD="${OPENLIST_PASSWORD:-768788}"
|
OPENLIST_PASSWORD="${OPENLIST_PASSWORD:-}"
|
||||||
|
OPENLIST_SOURCE_ROOT="${OPENLIST_SOURCE_ROOT:-}"
|
||||||
|
OPENLIST_DESTINATION_ROOT="${OPENLIST_DESTINATION_ROOT:-}"
|
||||||
|
|
||||||
segment_path="$LIVE_RECORDER_SEGMENT_FILE_PATH"
|
segment_path="$LIVE_RECORDER_SEGMENT_FILE_PATH"
|
||||||
danmaku_path="$LIVE_RECORDER_DANMAKU_FILE_PATH"
|
danmaku_path="$LIVE_RECORDER_DANMAKU_FILE_PATH"
|
||||||
@@ -22,6 +23,16 @@ log() {
|
|||||||
# 参数检查
|
# 参数检查
|
||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
|
if [ -z "$OPENLIST_BASE_URL" ] || [ -z "$OPENLIST_USERNAME" ] || [ -z "$OPENLIST_PASSWORD" ]; then
|
||||||
|
echo "错误:请通过环境变量配置 OPENLIST_BASE_URL、OPENLIST_USERNAME 和 OPENLIST_PASSWORD" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$OPENLIST_SOURCE_ROOT" ] || [ -z "$OPENLIST_DESTINATION_ROOT" ]; then
|
||||||
|
echo "错误:请通过环境变量配置 OPENLIST_SOURCE_ROOT 和 OPENLIST_DESTINATION_ROOT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$segment_path" ]; then
|
if [ -z "$segment_path" ]; then
|
||||||
log "错误:LIVE_RECORDER_SEGMENT_FILE_PATH 为空"
|
log "错误:LIVE_RECORDER_SEGMENT_FILE_PATH 为空"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -65,8 +76,8 @@ log "日期: $record_date"
|
|||||||
log "视频文件名: $video_filename"
|
log "视频文件名: $video_filename"
|
||||||
log "弹幕文件名: $danmaku_filename"
|
log "弹幕文件名: $danmaku_filename"
|
||||||
|
|
||||||
remote_dir="/yidongpan/records/$anchor_name/$record_date"
|
remote_dir="${OPENLIST_DESTINATION_ROOT%/}/$platform/$anchor_name/$record_date"
|
||||||
src_dir="/local/home/nanxun/live_recorder/records/$platform/$anchor_name/$record_date"
|
src_dir="${OPENLIST_SOURCE_ROOT%/}/$platform/$anchor_name/$record_date"
|
||||||
|
|
||||||
log "OpenList 源目录: $src_dir"
|
log "OpenList 源目录: $src_dir"
|
||||||
log "OpenList 目标目录: $remote_dir"
|
log "OpenList 目标目录: $remote_dir"
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ public sealed class RecordArtifactUploadItemResultDto
|
|||||||
public string? RemoteDanmakuPath { get; init; }
|
public string? RemoteDanmakuPath { get; init; }
|
||||||
|
|
||||||
public bool DeletedLocalFilesAfterUpload { get; init; }
|
public bool DeletedLocalFilesAfterUpload { get; init; }
|
||||||
|
|
||||||
|
public RecordArtifactUploadStatus? UploadStatus { get; init; }
|
||||||
|
|
||||||
|
public double? ProgressPercent { get; init; }
|
||||||
|
|
||||||
|
public int AttemptCount { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? NextAttemptAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RecordArtifactUploadBatchResultDto
|
public sealed class RecordArtifactUploadBatchResultDto
|
||||||
@@ -211,6 +219,16 @@ public sealed class UploadTaskItemDto
|
|||||||
public bool DeletedLocalFilesAfterUpload { get; init; }
|
public bool DeletedLocalFilesAfterUpload { get; init; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
public double? UploadProgressPercent { get; init; }
|
||||||
|
|
||||||
|
public int UploadAttemptCount { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? NextUploadAttemptAt { get; init; }
|
||||||
|
|
||||||
|
public string? CurrentUploadArtifact { get; init; }
|
||||||
|
|
||||||
|
public string? ExternalUploadTaskId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UploadTaskListResponse
|
public sealed class UploadTaskListResponse
|
||||||
@@ -224,4 +242,10 @@ public sealed class UploadTaskListResponse
|
|||||||
public int SucceededCount { get; init; }
|
public int SucceededCount { get; init; }
|
||||||
|
|
||||||
public int FailedCount { get; init; }
|
public int FailedCount { get; init; }
|
||||||
|
|
||||||
|
public int QueuedCount { get; init; }
|
||||||
|
|
||||||
|
public int UploadingCount { get; init; }
|
||||||
|
|
||||||
|
public int WaitingRetryCount { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,49 @@ public sealed class OpenListUploadSettingsDto
|
|||||||
public string Password { get; set; } = string.Empty;
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string BasePath { get; set; } = string.Empty;
|
public string BasePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string SourcePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string DestinationPath { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OpenListConnectionRequest
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpenListDirectoryRequest : OpenListConnectionRequest
|
||||||
|
{
|
||||||
|
public string Path { get; set; } = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpenListDirectoryItemDto
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
public required string Path { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpenListDirectoryListDto
|
||||||
|
{
|
||||||
|
public required string Path { get; init; }
|
||||||
|
|
||||||
|
public bool CanWrite { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<OpenListDirectoryItemDto> Directories { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpenListConnectionTestDto
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public string? Version { get; init; }
|
||||||
|
|
||||||
|
public required string Message { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SystemSettingsDto
|
public sealed class SystemSettingsDto
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
private const string OpenListUsernameKey = "upload.openlist.username";
|
private const string OpenListUsernameKey = "upload.openlist.username";
|
||||||
private const string OpenListPasswordKey = "upload.openlist.password";
|
private const string OpenListPasswordKey = "upload.openlist.password";
|
||||||
private const string OpenListBasePathKey = "upload.openlist.base_path";
|
private const string OpenListBasePathKey = "upload.openlist.base_path";
|
||||||
|
private const string OpenListSourcePathKey = "upload.openlist.source_path";
|
||||||
|
private const string OpenListDestinationPathKey = "upload.openlist.destination_path";
|
||||||
private const string DouyinProxyEnabledKey = "platform_proxy.douyin.enabled";
|
private const string DouyinProxyEnabledKey = "platform_proxy.douyin.enabled";
|
||||||
private const string DouyinProxyUrlKey = "platform_proxy.douyin.url";
|
private const string DouyinProxyUrlKey = "platform_proxy.douyin.url";
|
||||||
private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled";
|
private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled";
|
||||||
@@ -189,7 +191,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
BaseUrl = GetValue(lookup, OpenListBaseUrlKey, string.Empty),
|
BaseUrl = GetValue(lookup, OpenListBaseUrlKey, string.Empty),
|
||||||
Username = GetValue(lookup, OpenListUsernameKey, string.Empty),
|
Username = GetValue(lookup, OpenListUsernameKey, string.Empty),
|
||||||
Password = GetValue(lookup, OpenListPasswordKey, string.Empty),
|
Password = GetValue(lookup, OpenListPasswordKey, string.Empty),
|
||||||
BasePath = GetValue(lookup, OpenListBasePathKey, string.Empty)
|
BasePath = GetValue(lookup, OpenListBasePathKey, string.Empty),
|
||||||
|
SourcePath = GetValue(lookup, OpenListSourcePathKey, string.Empty),
|
||||||
|
DestinationPath = GetValue(
|
||||||
|
lookup,
|
||||||
|
OpenListDestinationPathKey,
|
||||||
|
GetValue(lookup, OpenListBasePathKey, string.Empty))
|
||||||
},
|
},
|
||||||
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
|
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
|
||||||
EnableLiveStartedScript = GetEventScriptEnabled(
|
EnableLiveStartedScript = GetEventScriptEnabled(
|
||||||
@@ -363,7 +370,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
|||||||
await UpsertAsync(OpenListBaseUrlKey, openListUpload.BaseUrl.Trim(), now, cancellationToken);
|
await UpsertAsync(OpenListBaseUrlKey, openListUpload.BaseUrl.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(OpenListUsernameKey, openListUpload.Username.Trim(), now, cancellationToken);
|
await UpsertAsync(OpenListUsernameKey, openListUpload.Username.Trim(), now, cancellationToken);
|
||||||
await UpsertAsync(OpenListPasswordKey, openListUpload.Password, now, cancellationToken);
|
await UpsertAsync(OpenListPasswordKey, openListUpload.Password, now, cancellationToken);
|
||||||
await UpsertAsync(OpenListBasePathKey, openListUpload.BasePath.Trim(), now, cancellationToken);
|
var openListDestinationPath = string.IsNullOrWhiteSpace(openListUpload.DestinationPath)
|
||||||
|
? openListUpload.BasePath.Trim()
|
||||||
|
: openListUpload.DestinationPath.Trim();
|
||||||
|
await UpsertAsync(OpenListBasePathKey, openListDestinationPath, now, cancellationToken);
|
||||||
|
await UpsertAsync(OpenListSourcePathKey, openListUpload.SourcePath.Trim(), now, cancellationToken);
|
||||||
|
await UpsertAsync(OpenListDestinationPathKey, openListDestinationPath, now, cancellationToken);
|
||||||
foreach (var platformDefinition in LivePlatformCatalog.All)
|
foreach (var platformDefinition in LivePlatformCatalog.All)
|
||||||
{
|
{
|
||||||
var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type);
|
var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type);
|
||||||
|
|||||||
@@ -94,6 +94,36 @@ public class RecordResult
|
|||||||
DeletedLocalFilesAfterUpload = false;
|
DeletedLocalFilesAfterUpload = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void MarkUploadQueued(string provider, DateTimeOffset requestedAt)
|
||||||
|
{
|
||||||
|
UploadStatus = RecordArtifactUploadStatus.Queued;
|
||||||
|
LastUploadProvider = NormalizeNullable(provider);
|
||||||
|
LastUploadedAt = requestedAt;
|
||||||
|
UploadErrorMessage = null;
|
||||||
|
DeletedLocalFilesAfterUpload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkUploadWaitingRetry(string provider, string? errorMessage, DateTimeOffset updatedAt)
|
||||||
|
{
|
||||||
|
UploadStatus = RecordArtifactUploadStatus.WaitingRetry;
|
||||||
|
LastUploadProvider = NormalizeNullable(provider);
|
||||||
|
LastUploadedAt = updatedAt;
|
||||||
|
UploadErrorMessage = NormalizeNullable(errorMessage);
|
||||||
|
DeletedLocalFilesAfterUpload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkRemoteVideoUploaded(string remoteVideoPath, DateTimeOffset uploadedAt)
|
||||||
|
{
|
||||||
|
RemoteVideoPath = NormalizeNullable(remoteVideoPath);
|
||||||
|
LastUploadedAt = uploadedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkRemoteDanmakuUploaded(string remoteDanmakuPath, DateTimeOffset uploadedAt)
|
||||||
|
{
|
||||||
|
RemoteDanmakuPath = NormalizeNullable(remoteDanmakuPath);
|
||||||
|
LastUploadedAt = uploadedAt;
|
||||||
|
}
|
||||||
|
|
||||||
public void MarkUploadSucceeded(
|
public void MarkUploadSucceeded(
|
||||||
string provider,
|
string provider,
|
||||||
string? remoteVideoPath,
|
string? remoteVideoPath,
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ public class RecordTask
|
|||||||
|
|
||||||
public RecordResult? Result { get; private set; }
|
public RecordResult? Result { get; private set; }
|
||||||
|
|
||||||
|
public RecordUploadJob? UploadJob { get; private set; }
|
||||||
|
|
||||||
public void AssignToSession(Guid recordSessionId, int segmentIndex, DateTimeOffset updatedAt)
|
public void AssignToSession(Guid recordSessionId, int segmentIndex, DateTimeOffset updatedAt)
|
||||||
{
|
{
|
||||||
RecordSessionId = recordSessionId;
|
RecordSessionId = recordSessionId;
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class RecordUploadJob
|
||||||
|
{
|
||||||
|
private RecordUploadJob()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public RecordUploadJob(
|
||||||
|
Guid recordTaskId,
|
||||||
|
string providerEndpoint,
|
||||||
|
string sourceVideoPath,
|
||||||
|
string targetVideoPath,
|
||||||
|
long videoSizeBytes,
|
||||||
|
string? sourceDanmakuPath,
|
||||||
|
string? targetDanmakuPath,
|
||||||
|
long? danmakuSizeBytes,
|
||||||
|
bool deleteLocalFilesAfterUpload,
|
||||||
|
DateTimeOffset requestedAt)
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid();
|
||||||
|
RecordTaskId = recordTaskId;
|
||||||
|
ProviderEndpoint = NormalizeRequired(providerEndpoint);
|
||||||
|
SourceVideoPath = NormalizeRequired(sourceVideoPath);
|
||||||
|
TargetVideoPath = NormalizeRequired(targetVideoPath);
|
||||||
|
VideoSizeBytes = Math.Max(0, videoSizeBytes);
|
||||||
|
SourceDanmakuPath = NormalizeNullable(sourceDanmakuPath);
|
||||||
|
TargetDanmakuPath = NormalizeNullable(targetDanmakuPath);
|
||||||
|
DanmakuSizeBytes = danmakuSizeBytes.HasValue ? Math.Max(0, danmakuSizeBytes.Value) : null;
|
||||||
|
DeleteLocalFilesAfterUpload = deleteLocalFilesAfterUpload;
|
||||||
|
Status = RecordArtifactUploadStatus.Queued;
|
||||||
|
CurrentArtifact = RecordUploadArtifactStage.Video;
|
||||||
|
RequestedAt = requestedAt;
|
||||||
|
UpdatedAt = requestedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
public Guid RecordTaskId { get; private set; }
|
||||||
|
|
||||||
|
public RecordTask? RecordTask { get; private set; }
|
||||||
|
|
||||||
|
public string ProviderEndpoint { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public string SourceVideoPath { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public string TargetVideoPath { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public long VideoSizeBytes { get; private set; }
|
||||||
|
|
||||||
|
public string? SourceDanmakuPath { get; private set; }
|
||||||
|
|
||||||
|
public string? TargetDanmakuPath { get; private set; }
|
||||||
|
|
||||||
|
public long? DanmakuSizeBytes { get; private set; }
|
||||||
|
|
||||||
|
public bool DeleteLocalFilesAfterUpload { get; private set; }
|
||||||
|
|
||||||
|
public RecordArtifactUploadStatus Status { get; private set; }
|
||||||
|
|
||||||
|
public RecordUploadArtifactStage CurrentArtifact { get; private set; }
|
||||||
|
|
||||||
|
public int AttemptCount { get; private set; }
|
||||||
|
|
||||||
|
public double ProgressPercent { get; private set; }
|
||||||
|
|
||||||
|
public string? ExternalTaskId { get; private set; }
|
||||||
|
|
||||||
|
public string? ExternalTaskType { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? ExternalTaskStartedAt { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? NextAttemptAt { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? VerificationStartedAt { get; private set; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset RequestedAt { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? StartedAt { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAt { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? CompletedAt { get; private set; }
|
||||||
|
|
||||||
|
public void RefreshRequest(
|
||||||
|
string providerEndpoint,
|
||||||
|
string sourceVideoPath,
|
||||||
|
string targetVideoPath,
|
||||||
|
long videoSizeBytes,
|
||||||
|
string? sourceDanmakuPath,
|
||||||
|
string? targetDanmakuPath,
|
||||||
|
long? danmakuSizeBytes,
|
||||||
|
bool deleteLocalFilesAfterUpload,
|
||||||
|
DateTimeOffset requestedAt)
|
||||||
|
{
|
||||||
|
ProviderEndpoint = NormalizeRequired(providerEndpoint);
|
||||||
|
SourceVideoPath = NormalizeRequired(sourceVideoPath);
|
||||||
|
TargetVideoPath = NormalizeRequired(targetVideoPath);
|
||||||
|
VideoSizeBytes = Math.Max(0, videoSizeBytes);
|
||||||
|
SourceDanmakuPath = NormalizeNullable(sourceDanmakuPath);
|
||||||
|
TargetDanmakuPath = NormalizeNullable(targetDanmakuPath);
|
||||||
|
DanmakuSizeBytes = danmakuSizeBytes.HasValue ? Math.Max(0, danmakuSizeBytes.Value) : null;
|
||||||
|
DeleteLocalFilesAfterUpload = deleteLocalFilesAfterUpload;
|
||||||
|
Status = RecordArtifactUploadStatus.Queued;
|
||||||
|
CurrentArtifact = RecordUploadArtifactStage.Video;
|
||||||
|
AttemptCount = 0;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
ExternalTaskId = null;
|
||||||
|
ExternalTaskType = null;
|
||||||
|
ExternalTaskStartedAt = null;
|
||||||
|
NextAttemptAt = null;
|
||||||
|
VerificationStartedAt = null;
|
||||||
|
ErrorMessage = null;
|
||||||
|
RequestedAt = requestedAt;
|
||||||
|
StartedAt = null;
|
||||||
|
CompletedAt = null;
|
||||||
|
UpdatedAt = requestedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkProcessing(DateTimeOffset updatedAt)
|
||||||
|
{
|
||||||
|
Status = RecordArtifactUploadStatus.Uploading;
|
||||||
|
StartedAt ??= updatedAt;
|
||||||
|
NextAttemptAt = null;
|
||||||
|
ErrorMessage = null;
|
||||||
|
UpdatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginAttempt(DateTimeOffset updatedAt)
|
||||||
|
{
|
||||||
|
MarkProcessing(updatedAt);
|
||||||
|
AttemptCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackExternalTask(string taskId, string taskType, double progressPercent, DateTimeOffset updatedAt)
|
||||||
|
{
|
||||||
|
ExternalTaskId = NormalizeRequired(taskId);
|
||||||
|
ExternalTaskType = NormalizeRequired(taskType);
|
||||||
|
ExternalTaskStartedAt ??= updatedAt;
|
||||||
|
SetProgress(progressPercent, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetProgress(double progressPercent, DateTimeOffset updatedAt)
|
||||||
|
{
|
||||||
|
ProgressPercent = Math.Clamp(progressPercent, 0, 100);
|
||||||
|
UpdatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartVerification(DateTimeOffset updatedAt)
|
||||||
|
{
|
||||||
|
VerificationStartedAt ??= updatedAt;
|
||||||
|
UpdatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CompleteCurrentArtifact(DateTimeOffset updatedAt)
|
||||||
|
{
|
||||||
|
ExternalTaskId = null;
|
||||||
|
ExternalTaskType = null;
|
||||||
|
ExternalTaskStartedAt = null;
|
||||||
|
VerificationStartedAt = null;
|
||||||
|
ErrorMessage = null;
|
||||||
|
|
||||||
|
if (CurrentArtifact == RecordUploadArtifactStage.Video && !string.IsNullOrWhiteSpace(SourceDanmakuPath))
|
||||||
|
{
|
||||||
|
CurrentArtifact = RecordUploadArtifactStage.Danmaku;
|
||||||
|
ProgressPercent = CalculateCompletedVideoProgress();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CurrentArtifact = RecordUploadArtifactStage.Completed;
|
||||||
|
ProgressPercent = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ScheduleRetry(string? errorMessage, DateTimeOffset nextAttemptAt, DateTimeOffset updatedAt, bool clearExternalTask)
|
||||||
|
{
|
||||||
|
Status = RecordArtifactUploadStatus.WaitingRetry;
|
||||||
|
ErrorMessage = NormalizeNullable(errorMessage);
|
||||||
|
NextAttemptAt = nextAttemptAt;
|
||||||
|
if (clearExternalTask)
|
||||||
|
{
|
||||||
|
ExternalTaskId = null;
|
||||||
|
ExternalTaskType = null;
|
||||||
|
ExternalTaskStartedAt = null;
|
||||||
|
VerificationStartedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkSucceeded(DateTimeOffset completedAt)
|
||||||
|
{
|
||||||
|
Status = RecordArtifactUploadStatus.Succeeded;
|
||||||
|
CurrentArtifact = RecordUploadArtifactStage.Completed;
|
||||||
|
ProgressPercent = 100;
|
||||||
|
ExternalTaskId = null;
|
||||||
|
ExternalTaskType = null;
|
||||||
|
ExternalTaskStartedAt = null;
|
||||||
|
NextAttemptAt = null;
|
||||||
|
VerificationStartedAt = null;
|
||||||
|
ErrorMessage = null;
|
||||||
|
CompletedAt = completedAt;
|
||||||
|
UpdatedAt = completedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkFailed(string? errorMessage, DateTimeOffset completedAt)
|
||||||
|
{
|
||||||
|
Status = RecordArtifactUploadStatus.Failed;
|
||||||
|
ExternalTaskId = null;
|
||||||
|
ExternalTaskType = null;
|
||||||
|
ExternalTaskStartedAt = null;
|
||||||
|
NextAttemptAt = null;
|
||||||
|
VerificationStartedAt = null;
|
||||||
|
ErrorMessage = NormalizeNullable(errorMessage);
|
||||||
|
CompletedAt = completedAt;
|
||||||
|
UpdatedAt = completedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetCurrentSourcePath() => CurrentArtifact switch
|
||||||
|
{
|
||||||
|
RecordUploadArtifactStage.Video => SourceVideoPath,
|
||||||
|
RecordUploadArtifactStage.Danmaku => SourceDanmakuPath
|
||||||
|
?? throw new InvalidOperationException("Danmaku source path is not configured."),
|
||||||
|
_ => throw new InvalidOperationException("The upload job has no remaining artifact.")
|
||||||
|
};
|
||||||
|
|
||||||
|
public string GetCurrentTargetPath() => CurrentArtifact switch
|
||||||
|
{
|
||||||
|
RecordUploadArtifactStage.Video => TargetVideoPath,
|
||||||
|
RecordUploadArtifactStage.Danmaku => TargetDanmakuPath
|
||||||
|
?? throw new InvalidOperationException("Danmaku target path is not configured."),
|
||||||
|
_ => throw new InvalidOperationException("The upload job has no remaining artifact.")
|
||||||
|
};
|
||||||
|
|
||||||
|
public long GetCurrentSizeBytes() => CurrentArtifact switch
|
||||||
|
{
|
||||||
|
RecordUploadArtifactStage.Video => VideoSizeBytes,
|
||||||
|
RecordUploadArtifactStage.Danmaku => DanmakuSizeBytes ?? 0,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
public double CalculateOverallProgress(double currentArtifactProgress)
|
||||||
|
{
|
||||||
|
var danmakuSize = SourceDanmakuPath is null ? 0 : Math.Max(0, DanmakuSizeBytes ?? 0);
|
||||||
|
var totalSize = Math.Max(1, VideoSizeBytes + danmakuSize);
|
||||||
|
var completedSize = CurrentArtifact switch
|
||||||
|
{
|
||||||
|
RecordUploadArtifactStage.Video => 0,
|
||||||
|
RecordUploadArtifactStage.Danmaku => VideoSizeBytes,
|
||||||
|
_ => totalSize
|
||||||
|
};
|
||||||
|
var currentSize = CurrentArtifact switch
|
||||||
|
{
|
||||||
|
RecordUploadArtifactStage.Video => VideoSizeBytes,
|
||||||
|
RecordUploadArtifactStage.Danmaku => danmakuSize,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return Math.Clamp((completedSize + currentSize * Math.Clamp(currentArtifactProgress, 0, 100) / 100d) * 100d / totalSize, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double CalculateCompletedVideoProgress()
|
||||||
|
{
|
||||||
|
var totalSize = Math.Max(1, VideoSizeBytes + Math.Max(0, DanmakuSizeBytes ?? 0));
|
||||||
|
return Math.Clamp(VideoSizeBytes * 100d / totalSize, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRequired(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A non-empty value is required.", nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeNullable(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
@@ -5,5 +5,7 @@ public enum RecordArtifactUploadStatus
|
|||||||
NotUploaded = 0,
|
NotUploaded = 0,
|
||||||
Succeeded = 1,
|
Succeeded = 1,
|
||||||
Failed = 2,
|
Failed = 2,
|
||||||
Uploading = 3
|
Uploading = 3,
|
||||||
|
Queued = 4,
|
||||||
|
WaitingRetry = 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LiveRecorder.Domain.Enums;
|
||||||
|
|
||||||
|
public enum RecordUploadArtifactStage
|
||||||
|
{
|
||||||
|
Video = 0,
|
||||||
|
Danmaku = 1,
|
||||||
|
Completed = 2
|
||||||
|
}
|
||||||
@@ -74,6 +74,12 @@ public sealed class DatabaseInitializer
|
|||||||
["upload.s3.secret_key"] = string.Empty,
|
["upload.s3.secret_key"] = string.Empty,
|
||||||
["upload.s3.prefix"] = string.Empty,
|
["upload.s3.prefix"] = string.Empty,
|
||||||
["upload.s3.force_path_style"] = "False",
|
["upload.s3.force_path_style"] = "False",
|
||||||
|
["upload.openlist.base_url"] = string.Empty,
|
||||||
|
["upload.openlist.username"] = string.Empty,
|
||||||
|
["upload.openlist.password"] = string.Empty,
|
||||||
|
["upload.openlist.base_path"] = string.Empty,
|
||||||
|
["upload.openlist.source_path"] = string.Empty,
|
||||||
|
["upload.openlist.destination_path"] = string.Empty,
|
||||||
["platform_proxy.douyin.enabled"] = "False",
|
["platform_proxy.douyin.enabled"] = "False",
|
||||||
["platform_proxy.douyin.url"] = string.Empty,
|
["platform_proxy.douyin.url"] = string.Empty,
|
||||||
["platform_proxy.bilibili.enabled"] = "False",
|
["platform_proxy.bilibili.enabled"] = "False",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
|||||||
|
|
||||||
public DbSet<RecordResult> RecordResults => Set<RecordResult>();
|
public DbSet<RecordResult> RecordResults => Set<RecordResult>();
|
||||||
|
|
||||||
|
public DbSet<RecordUploadJob> RecordUploadJobs => Set<RecordUploadJob>();
|
||||||
|
|
||||||
public DbSet<SystemLogEntry> SystemLogEntries => Set<SystemLogEntry>();
|
public DbSet<SystemLogEntry> SystemLogEntries => Set<SystemLogEntry>();
|
||||||
|
|
||||||
public DbSet<CleanupOperation> CleanupOperations => Set<CleanupOperation>();
|
public DbSet<CleanupOperation> CleanupOperations => Set<CleanupOperation>();
|
||||||
@@ -119,6 +121,28 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RecordUploadJob>(builder =>
|
||||||
|
{
|
||||||
|
builder.ToTable("RecordUploadJobs");
|
||||||
|
builder.HasKey(static x => x.Id);
|
||||||
|
builder.Property(static x => x.Status).HasConversion<int>();
|
||||||
|
builder.Property(static x => x.CurrentArtifact).HasConversion<int>();
|
||||||
|
builder.Property(static x => x.ProviderEndpoint).HasMaxLength(2048);
|
||||||
|
builder.Property(static x => x.SourceVideoPath).HasMaxLength(2048);
|
||||||
|
builder.Property(static x => x.TargetVideoPath).HasMaxLength(2048);
|
||||||
|
builder.Property(static x => x.SourceDanmakuPath).HasMaxLength(2048);
|
||||||
|
builder.Property(static x => x.TargetDanmakuPath).HasMaxLength(2048);
|
||||||
|
builder.Property(static x => x.ExternalTaskId).HasMaxLength(128);
|
||||||
|
builder.Property(static x => x.ExternalTaskType).HasMaxLength(32);
|
||||||
|
builder.Property(static x => x.ErrorMessage).HasMaxLength(4096);
|
||||||
|
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
|
||||||
|
builder.HasIndex(static x => new { x.Status, x.NextAttemptAt, x.RequestedAt });
|
||||||
|
builder.HasOne(static x => x.RecordTask)
|
||||||
|
.WithOne(static x => x.UploadJob)
|
||||||
|
.HasForeignKey<RecordUploadJob>(static x => x.RecordTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SystemLogEntry>(builder =>
|
modelBuilder.Entity<SystemLogEntry>(builder =>
|
||||||
{
|
{
|
||||||
builder.ToTable("SystemLogEntries");
|
builder.ToTable("SystemLogEntries");
|
||||||
|
|||||||
+757
@@ -0,0 +1,757 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using LiveRecorder.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LiveRecorderDbContext))]
|
||||||
|
[Migration("20260801100250_AddOpenListUploadJobs")]
|
||||||
|
partial class AddOpenListUploadJobs
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.AppSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("AppSettings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.CleanupOperation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("DeleteFiles")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("DeletedDanmakuFileCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DeletedFileCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DeletedLogCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DeletedResultCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DeletedSessionCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DeletedTaskCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FiltersJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ProcessedSessionCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TotalSessionCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("WarningsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Status", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("CleanupOperations", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Alias")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("AnchorId")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("AnchorName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("AvailabilityStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarUrl")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("CoverUrl")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool?>("DanmakuIncludeNonChatEventsOverride")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int?>("DanmakuMinPollIntervalMillisecondsOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("DanmakuRetryDelayMaxSecondsOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool?>("EnableAutoReconnectOverride")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool?>("EnableDanmakuRecordingOverride")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HasSentLiveNotificationForCurrentSession")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsPriority")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastAutoStartDecisionAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LastAutoStartDecisionCode")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("LastAutoStartDecisionDetail")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("LastAutoStartDecisionSummary")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastCheckedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastStartRecordingTriggeredAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<int?>("OutputFormatOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("PollingIntervalSecondsOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("PreferredQualityOverride")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int?>("ReadWriteTimeoutMillisecondsOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("ReconnectDelayMaxSecondsOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("RecordingTemplateOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Remark")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("RoomId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int?>("SaveModeOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SegmentDurationMinutesOverride")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("SourceUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Platform", "RoomId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("LiveRooms", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DanmakuFilePath")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("DanmakuMessageCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("DeletedLocalFilesAfterUpload")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<double?>("DurationSeconds")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("FilePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<long?>("FileSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("FinalStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("LastUploadProvider")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastUploadedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecordTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RemoteDanmakuPath")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("RemoteVideoPath")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("UploadErrorMessage")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("UploadStatus")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RecordTaskId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("RecordResults", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("ActiveSegmentIndex")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("EndedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid>("LiveRoomId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("OutputFormat")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("OutputPathPattern")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("PreferredQuality")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int?>("RecorderProcessId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SaveMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SegmentCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("StreamUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LiveRoomId");
|
||||||
|
|
||||||
|
b.ToTable("RecordSessions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<double?>("DurationSeconds")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("EndedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid>("LiveRoomId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("OutputFilePath")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("OutputFormat")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("PreferredQuality")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecordSessionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int?>("RecorderProcessId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SegmentIndex")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("StreamUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LiveRoomId");
|
||||||
|
|
||||||
|
b.HasIndex("RecordSessionId", "SegmentIndex");
|
||||||
|
|
||||||
|
b.ToTable("RecordTasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordUploadJob", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AttemptCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentArtifact")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<long?>("DanmakuSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<bool>("DeleteLocalFilesAfterUpload")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalTaskId")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExternalTaskStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalTaskType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("NextAttemptAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<double>("ProgressPercent")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderEndpoint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecordTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("RequestedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("SourceDanmakuPath")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceVideoPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("TargetDanmakuPath")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetVideoPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("VerificationStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("VideoSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RecordTaskId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status", "NextAttemptAt", "RequestedAt");
|
||||||
|
|
||||||
|
b.ToTable("RecordUploadJobs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Level")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("LiveRoomId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RecordSessionId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RecordTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("RecordSessionId");
|
||||||
|
|
||||||
|
b.ToTable("SystemLogEntries", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("UserAccounts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserAccountId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserAccountId");
|
||||||
|
|
||||||
|
b.ToTable("UserSessions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
|
||||||
|
.WithOne("Result")
|
||||||
|
.HasForeignKey("LiveRecorder.Domain.Entities.RecordResult", "RecordTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("RecordTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LiveRoomId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("LiveRoom");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
|
||||||
|
.WithMany("RecordTasks")
|
||||||
|
.HasForeignKey("LiveRoomId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("LiveRecorder.Domain.Entities.RecordSession", "RecordSession")
|
||||||
|
.WithMany("RecordTasks")
|
||||||
|
.HasForeignKey("RecordSessionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("LiveRoom");
|
||||||
|
|
||||||
|
b.Navigation("RecordSession");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordUploadJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
|
||||||
|
.WithOne("UploadJob")
|
||||||
|
.HasForeignKey("LiveRecorder.Domain.Entities.RecordUploadJob", "RecordTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("RecordTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("UserAccount");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RecordTasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RecordTasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Result");
|
||||||
|
|
||||||
|
b.Navigation("UploadJob");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOpenListUploadJobs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RecordUploadJobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
RecordTaskId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProviderEndpoint = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
SourceVideoPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
TargetVideoPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
VideoSizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
SourceDanmakuPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||||
|
TargetDanmakuPath = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||||
|
DanmakuSizeBytes = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
DeleteLocalFilesAfterUpload = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CurrentArtifact = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
AttemptCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ProgressPercent = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
ExternalTaskId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
ExternalTaskType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
ExternalTaskStartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
NextAttemptAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
VerificationStartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
RequestedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_RecordUploadJobs", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RecordUploadJobs_RecordTasks_RecordTaskId",
|
||||||
|
column: x => x.RecordTaskId,
|
||||||
|
principalTable: "RecordTasks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RecordUploadJobs_RecordTaskId",
|
||||||
|
table: "RecordUploadJobs",
|
||||||
|
column: "RecordTaskId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RecordUploadJobs_Status_NextAttemptAt_RequestedAt",
|
||||||
|
table: "RecordUploadJobs",
|
||||||
|
columns: new[] { "Status", "NextAttemptAt", "RequestedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RecordUploadJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -462,6 +462,102 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("RecordTasks", (string)null);
|
b.ToTable("RecordTasks", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordUploadJob", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AttemptCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentArtifact")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<long?>("DanmakuSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<bool>("DeleteLocalFilesAfterUpload")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalTaskId")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExternalTaskStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalTaskType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("NextAttemptAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<double>("ProgressPercent")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderEndpoint")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecordTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("RequestedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("SourceDanmakuPath")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceVideoPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("StartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("TargetDanmakuPath")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetVideoPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("VerificationStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("VideoSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RecordTaskId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status", "NextAttemptAt", "RequestedAt");
|
||||||
|
|
||||||
|
b.ToTable("RecordUploadJobs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -614,6 +710,17 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("RecordSession");
|
b.Navigation("RecordSession");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordUploadJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
|
||||||
|
.WithOne("UploadJob")
|
||||||
|
.HasForeignKey("LiveRecorder.Domain.Entities.RecordUploadJob", "RecordTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("RecordTask");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
|
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
|
||||||
@@ -638,6 +745,8 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
|||||||
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
|
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Result");
|
b.Navigation("Result");
|
||||||
|
|
||||||
|
b.Navigation("UploadJob");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
|||||||
var query = _dbContext.RecordResults
|
var query = _dbContext.RecordResults
|
||||||
.Include(item => item.RecordTask!)
|
.Include(item => item.RecordTask!)
|
||||||
.ThenInclude(task => task.LiveRoom)
|
.ThenInclude(task => task.LiveRoom)
|
||||||
|
.Include(item => item.RecordTask!)
|
||||||
|
.ThenInclude(task => task.UploadJob)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (uploadStatusFilter.HasValue)
|
if (uploadStatusFilter.HasValue)
|
||||||
|
|||||||
@@ -0,0 +1,588 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LiveRecorder.Application.Models.Settings;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
|
public interface IOpenListClient
|
||||||
|
{
|
||||||
|
Task<OpenListConnectionTestDto> TestConnectionAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<OpenListDirectoryListDto> ListDirectoriesAsync(
|
||||||
|
OpenListDirectoryRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task EnsureDirectoryAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<OpenListObjectInfo?> TryGetObjectAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<OpenListCopyResult> CopyFileAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string sourcePath,
|
||||||
|
string targetPath,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<OpenListTaskInfo?> TryGetCopyTaskAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string taskId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record OpenListObjectInfo(
|
||||||
|
string Name,
|
||||||
|
long Size,
|
||||||
|
bool IsDirectory,
|
||||||
|
IReadOnlyDictionary<string, string> Hashes);
|
||||||
|
|
||||||
|
public sealed record OpenListCopyResult(IReadOnlyList<string> TaskIds);
|
||||||
|
|
||||||
|
public sealed record OpenListTaskInfo(
|
||||||
|
string Id,
|
||||||
|
int State,
|
||||||
|
double Progress,
|
||||||
|
string Status,
|
||||||
|
string? Error);
|
||||||
|
|
||||||
|
public sealed class OpenListClient : IOpenListClient
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ConcurrentDictionary<string, TokenCacheEntry> _tokens = new(StringComparer.Ordinal);
|
||||||
|
private readonly SemaphoreSlim _loginGate = new(1, 1);
|
||||||
|
|
||||||
|
public OpenListClient(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenListConnectionTestDto> TestConnectionAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var baseUrl = NormalizeBaseUrl(connection.BaseUrl);
|
||||||
|
await GetTokenAsync(connection, forceRefresh: true, cancellationToken);
|
||||||
|
|
||||||
|
using var client = _httpClientFactory.CreateClient("openlist");
|
||||||
|
using var response = await client.GetAsync($"{baseUrl}/api/public/settings", cancellationToken);
|
||||||
|
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
|
||||||
|
EnsureSuccess(envelope, "OpenList connection test");
|
||||||
|
|
||||||
|
string? version = null;
|
||||||
|
if (envelope.Data is { ValueKind: JsonValueKind.Object } data &&
|
||||||
|
data.TryGetProperty("version", out var versionElement))
|
||||||
|
{
|
||||||
|
version = versionElement.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenListConnectionTestDto
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Version = version,
|
||||||
|
Message = string.IsNullOrWhiteSpace(version)
|
||||||
|
? "OpenList 连接和登录成功。"
|
||||||
|
: $"OpenList 连接和登录成功:{version}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenListDirectoryListDto> ListDirectoriesAsync(
|
||||||
|
OpenListDirectoryRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var path = NormalizePath(request.Path);
|
||||||
|
var envelope = await SendAuthorizedAsync(
|
||||||
|
request,
|
||||||
|
() => CreateJsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/fs/list",
|
||||||
|
new { path, password = string.Empty, refresh = false, page = 1, per_page = 0 }),
|
||||||
|
cancellationToken);
|
||||||
|
EnsureSuccess(envelope, $"OpenList list '{path}'");
|
||||||
|
|
||||||
|
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OpenList list response did not contain directory data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var canWrite = data.TryGetProperty("write", out var writeElement) && writeElement.ValueKind == JsonValueKind.True;
|
||||||
|
var directories = new List<OpenListDirectoryItemDto>();
|
||||||
|
if (data.TryGetProperty("content", out var contentElement) && contentElement.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in contentElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!item.TryGetProperty("is_dir", out var isDirectoryElement) || !isDirectoryElement.GetBoolean())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = item.TryGetProperty("name", out var nameElement)
|
||||||
|
? nameElement.GetString()
|
||||||
|
: null;
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
directories.Add(new OpenListDirectoryItemDto
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Path = CombinePath(path, name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenListDirectoryListDto
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
CanWrite = canWrite,
|
||||||
|
Directories = directories.OrderBy(static item => item.Name, StringComparer.OrdinalIgnoreCase).ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureDirectoryAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
path = NormalizePath(path);
|
||||||
|
if (path == "/")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var accumulated = string.Empty;
|
||||||
|
foreach (var segment in SplitPath(path))
|
||||||
|
{
|
||||||
|
accumulated = CombinePath(accumulated, segment);
|
||||||
|
var existing = await TryGetObjectAsync(connection, accumulated, cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
if (!existing.IsDirectory)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OpenList path '{accumulated}' exists but is not a directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPath = accumulated;
|
||||||
|
var envelope = await SendAuthorizedAsync(
|
||||||
|
connection,
|
||||||
|
() => CreateJsonRequest(HttpMethod.Post, "/api/fs/mkdir", new { path = currentPath }),
|
||||||
|
cancellationToken);
|
||||||
|
if (envelope.Code == 200)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsAny(envelope.Message, "exist", "already"))
|
||||||
|
{
|
||||||
|
var racedObject = await TryGetObjectAsync(connection, currentPath, cancellationToken);
|
||||||
|
if (racedObject?.IsDirectory == true)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureSuccess(envelope, $"OpenList mkdir '{currentPath}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenListObjectInfo?> TryGetObjectAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
path = NormalizePath(path);
|
||||||
|
var result = await TryGetObjectCoreAsync(connection, path, cancellationToken);
|
||||||
|
if (result is not null || path == "/")
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud drivers can complete a server-side copy without invalidating
|
||||||
|
// OpenList's directory cache. Refreshing the parent also makes files
|
||||||
|
// written directly into a local mount visible before they are copied.
|
||||||
|
await RefreshDirectoryAsync(connection, GetDirectoryName(path), cancellationToken);
|
||||||
|
return await TryGetObjectCoreAsync(connection, path, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OpenListObjectInfo?> TryGetObjectCoreAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var envelope = await SendAuthorizedAsync(
|
||||||
|
connection,
|
||||||
|
() => CreateJsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/fs/get",
|
||||||
|
new { path, password = string.Empty }),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (envelope.Code != 200)
|
||||||
|
{
|
||||||
|
if (envelope.Code == 404 || ContainsAny(envelope.Message, "not found", "object not found", "no such file"))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureSuccess(envelope, $"OpenList get '{path}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = data.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? string.Empty : string.Empty;
|
||||||
|
var size = data.TryGetProperty("size", out var sizeElement) && sizeElement.TryGetInt64(out var parsedSize)
|
||||||
|
? parsedSize
|
||||||
|
: 0;
|
||||||
|
var isDirectory = data.TryGetProperty("is_dir", out var isDirectoryElement) && isDirectoryElement.GetBoolean();
|
||||||
|
var hashes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (data.TryGetProperty("hash_info", out var hashElement) && hashElement.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var property in hashElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
var value = property.Value.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
hashes[property.Name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenListObjectInfo(name, size, isDirectory, hashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshDirectoryAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var envelope = await SendAuthorizedAsync(
|
||||||
|
connection,
|
||||||
|
() => CreateJsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/fs/list",
|
||||||
|
new { path, password = string.Empty, refresh = true, page = 1, per_page = 0 }),
|
||||||
|
cancellationToken);
|
||||||
|
EnsureSuccess(envelope, $"OpenList refresh '{path}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenListCopyResult> CopyFileAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string sourcePath,
|
||||||
|
string targetPath,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
sourcePath = NormalizePath(sourcePath);
|
||||||
|
targetPath = NormalizePath(targetPath);
|
||||||
|
var sourceDirectory = GetDirectoryName(sourcePath);
|
||||||
|
var targetDirectory = GetDirectoryName(targetPath);
|
||||||
|
var sourceName = GetFileName(sourcePath);
|
||||||
|
var targetName = GetFileName(targetPath);
|
||||||
|
if (!string.Equals(sourceName, targetName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OpenList server-side copy requires source and target file names to match.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope = await SendAuthorizedAsync(
|
||||||
|
connection,
|
||||||
|
() => CreateJsonRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/fs/copy",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
src_dir = sourceDirectory,
|
||||||
|
dst_dir = targetDirectory,
|
||||||
|
names = new[] { sourceName },
|
||||||
|
overwrite = false,
|
||||||
|
skip_existing = false,
|
||||||
|
merge = false
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
EnsureSuccess(envelope, $"OpenList copy '{sourcePath}' to '{targetPath}'");
|
||||||
|
|
||||||
|
var taskIds = new List<string>();
|
||||||
|
if (envelope.Data is { ValueKind: JsonValueKind.Object } data &&
|
||||||
|
data.TryGetProperty("tasks", out var tasksElement) &&
|
||||||
|
tasksElement.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var taskElement in tasksElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!taskElement.TryGetProperty("id", out var idElement))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskId = idElement.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(taskId))
|
||||||
|
{
|
||||||
|
taskIds.Add(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenListCopyResult(taskIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenListTaskInfo?> TryGetCopyTaskAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string taskId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(taskId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var encodedTaskId = Uri.EscapeDataString(taskId.Trim());
|
||||||
|
var envelope = await SendAuthorizedAsync(
|
||||||
|
connection,
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Post, $"/api/task/copy/info?tid={encodedTaskId}"),
|
||||||
|
cancellationToken);
|
||||||
|
if (envelope.Code != 200)
|
||||||
|
{
|
||||||
|
if (envelope.Code == 404 || ContainsAny(envelope.Message, "task not found", "not found"))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureSuccess(envelope, $"OpenList copy task '{taskId}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = data.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? taskId : taskId;
|
||||||
|
var state = data.TryGetProperty("state", out var stateElement) && stateElement.TryGetInt32(out var parsedState)
|
||||||
|
? parsedState
|
||||||
|
: -1;
|
||||||
|
var progress = data.TryGetProperty("progress", out var progressElement) && progressElement.TryGetDouble(out var parsedProgress)
|
||||||
|
? parsedProgress
|
||||||
|
: 0;
|
||||||
|
var status = data.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? string.Empty : string.Empty;
|
||||||
|
var error = data.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
|
||||||
|
return new OpenListTaskInfo(id, state, progress, status, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeBaseUrl(string baseUrl)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) ||
|
||||||
|
uri.Scheme is not ("http" or "https"))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OpenList 地址必须是有效的 HTTP 或 HTTPS URL。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = uri.AbsolutePath.TrimEnd('/');
|
||||||
|
var davIndex = path.IndexOf("/dav", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (davIndex >= 0 && (davIndex + 4 == path.Length || path[davIndex + 4] == '/'))
|
||||||
|
{
|
||||||
|
path = path[..davIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UriBuilder(uri)
|
||||||
|
{
|
||||||
|
Path = path,
|
||||||
|
Query = string.Empty,
|
||||||
|
Fragment = string.Empty
|
||||||
|
}.Uri.ToString().TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizePath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || path.Trim() == "/")
|
||||||
|
{
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = path
|
||||||
|
.Replace('\\', '/')
|
||||||
|
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (segments.Any(static segment => segment is "." or ".."))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OpenList 路径不能包含 '.' 或 '..' 段。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/" + string.Join('/', segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CombinePath(params string?[] parts)
|
||||||
|
{
|
||||||
|
var segments = parts
|
||||||
|
.Where(static part => !string.IsNullOrWhiteSpace(part))
|
||||||
|
.SelectMany(static part => part!.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
.ToArray();
|
||||||
|
return NormalizePath("/" + string.Join('/', segments));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApiEnvelope> SendAuthorizedAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
Func<HttpRequestMessage> requestFactory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < 2; attempt++)
|
||||||
|
{
|
||||||
|
var forceRefresh = attempt > 0;
|
||||||
|
var token = await GetTokenAsync(connection, forceRefresh, cancellationToken);
|
||||||
|
var baseUrl = NormalizeBaseUrl(connection.BaseUrl);
|
||||||
|
using var client = _httpClientFactory.CreateClient("openlist");
|
||||||
|
using var request = requestFactory();
|
||||||
|
request.RequestUri = new Uri(baseUrl + request.RequestUri, UriKind.Absolute);
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", token);
|
||||||
|
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
|
||||||
|
if (response.StatusCode != HttpStatusCode.Unauthorized && envelope.Code != 401)
|
||||||
|
{
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
InvalidateToken(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("OpenList 登录状态无效,请检查账号或密码。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetTokenAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
bool forceRefresh,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cacheKey = BuildCacheKey(connection);
|
||||||
|
if (!forceRefresh && _tokens.TryGetValue(cacheKey, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return cached.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _loginGate.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!forceRefresh && _tokens.TryGetValue(cacheKey, out cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return cached.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUrl = NormalizeBaseUrl(connection.BaseUrl);
|
||||||
|
using var client = _httpClientFactory.CreateClient("openlist");
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"{baseUrl}/api/auth/login",
|
||||||
|
new { username = connection.Username?.Trim() ?? string.Empty, password = connection.Password ?? string.Empty },
|
||||||
|
JsonOptions,
|
||||||
|
cancellationToken);
|
||||||
|
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
|
||||||
|
EnsureSuccess(envelope, "OpenList login");
|
||||||
|
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data ||
|
||||||
|
!data.TryGetProperty("token", out var tokenElement) ||
|
||||||
|
string.IsNullOrWhiteSpace(tokenElement.GetString()))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OpenList 登录响应未包含 token。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = tokenElement.GetString()!;
|
||||||
|
_tokens[cacheKey] = new TokenCacheEntry(token, DateTimeOffset.UtcNow.AddMinutes(20));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loginGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InvalidateToken(OpenListConnectionRequest connection) =>
|
||||||
|
_tokens.TryRemove(BuildCacheKey(connection), out _);
|
||||||
|
|
||||||
|
private static string BuildCacheKey(OpenListConnectionRequest connection)
|
||||||
|
{
|
||||||
|
var raw = $"{NormalizeBaseUrl(connection.BaseUrl)}\n{connection.Username}\n{connection.Password}";
|
||||||
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage CreateJsonRequest(HttpMethod method, string path, object payload) =>
|
||||||
|
new(method, path)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload, options: JsonOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<ApiEnvelope> ReadEnvelopeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return new ApiEnvelope((int)response.StatusCode, response.ReasonPhrase ?? "Empty response", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(body);
|
||||||
|
var root = document.RootElement;
|
||||||
|
var code = root.TryGetProperty("code", out var codeElement) && codeElement.TryGetInt32(out var parsedCode)
|
||||||
|
? parsedCode
|
||||||
|
: (int)response.StatusCode;
|
||||||
|
var message = root.TryGetProperty("message", out var messageElement)
|
||||||
|
? messageElement.GetString() ?? body
|
||||||
|
: body;
|
||||||
|
JsonElement? data = root.TryGetProperty("data", out var dataElement)
|
||||||
|
? dataElement.Clone()
|
||||||
|
: null;
|
||||||
|
return new ApiEnvelope(code, message, data);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OpenList 返回了无效 JSON(HTTP {(int)response.StatusCode}):{body}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSuccess(ApiEnvelope envelope, string operation)
|
||||||
|
{
|
||||||
|
if (envelope.Code != 200)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{operation} failed with code {envelope.Code}: {envelope.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(string? value, params string[] candidates) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value) &&
|
||||||
|
candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static string[] SplitPath(string path) =>
|
||||||
|
NormalizePath(path).Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
private static string GetDirectoryName(string path)
|
||||||
|
{
|
||||||
|
var normalized = NormalizePath(path);
|
||||||
|
var index = normalized.LastIndexOf('/');
|
||||||
|
return index <= 0 ? "/" : normalized[..index];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFileName(string path)
|
||||||
|
{
|
||||||
|
var normalized = NormalizePath(path);
|
||||||
|
var index = normalized.LastIndexOf('/');
|
||||||
|
var name = normalized[(index + 1)..];
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OpenList path '{path}' does not contain a file name.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record TokenCacheEntry(string Token, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
private sealed record ApiEnvelope(int Code, string Message, JsonElement? Data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,784 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
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;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class OpenListUploadQueueService
|
||||||
|
{
|
||||||
|
private const int MaxAttempts = 6;
|
||||||
|
private static readonly TimeSpan VerificationTimeout = TimeSpan.FromMinutes(2);
|
||||||
|
private static readonly TimeSpan ExternalTaskTimeout = TimeSpan.FromHours(24);
|
||||||
|
private static readonly TimeSpan[] RetryDelays =
|
||||||
|
[
|
||||||
|
TimeSpan.FromMinutes(1),
|
||||||
|
TimeSpan.FromMinutes(5),
|
||||||
|
TimeSpan.FromMinutes(15),
|
||||||
|
TimeSpan.FromHours(1),
|
||||||
|
TimeSpan.FromHours(6)
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly LiveRecorderDbContext _dbContext;
|
||||||
|
private readonly ISystemSettingsService _settingsService;
|
||||||
|
private readonly IOpenListClient _openListClient;
|
||||||
|
private readonly ISystemLogService _systemLogService;
|
||||||
|
|
||||||
|
public OpenListUploadQueueService(
|
||||||
|
LiveRecorderDbContext dbContext,
|
||||||
|
ISystemSettingsService settingsService,
|
||||||
|
IOpenListClient openListClient,
|
||||||
|
ISystemLogService systemLogService)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_openListClient = openListClient;
|
||||||
|
_systemLogService = systemLogService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecordArtifactUploadItemResultDto?> TryEnqueueAutomaticAsync(
|
||||||
|
Guid recordTaskId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetAsync(cancellationToken);
|
||||||
|
if (!settings.EnableFileUpload ||
|
||||||
|
!settings.EnableAutoUpload ||
|
||||||
|
settings.UploadTarget != UploadTargetType.OpenList)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await EnqueueInternalAsync(recordTaskId, settings, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecordArtifactUploadItemResultDto> EnqueueAsync(
|
||||||
|
Guid recordTaskId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetAsync(cancellationToken);
|
||||||
|
if (!settings.EnableFileUpload || settings.UploadTarget != UploadTargetType.OpenList)
|
||||||
|
{
|
||||||
|
return Failure(recordTaskId, "OpenList 上传未启用。", "openlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await EnqueueInternalAsync(recordTaskId, settings, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecordArtifactUploadBatchResultDto> EnqueueSessionAsync(
|
||||||
|
Guid recordSessionId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var taskIds = await _dbContext.RecordTasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item => item.RecordSessionId == recordSessionId)
|
||||||
|
.OrderBy(static item => item.SegmentIndex)
|
||||||
|
.ThenBy(static item => item.CreatedAt)
|
||||||
|
.Select(static item => item.Id)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (taskIds.Length == 0)
|
||||||
|
{
|
||||||
|
return new RecordArtifactUploadBatchResultDto
|
||||||
|
{
|
||||||
|
RequestedCount = 0,
|
||||||
|
SuccessCount = 0,
|
||||||
|
FailedCount = 1,
|
||||||
|
Items = [Failure(Guid.Empty, "录制会话不存在或没有可上传分片。", "openlist")]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = new List<RecordArtifactUploadItemResultDto>(taskIds.Length);
|
||||||
|
foreach (var taskId in taskIds)
|
||||||
|
{
|
||||||
|
items.Add(await EnqueueAsync(taskId, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RecordArtifactUploadBatchResultDto
|
||||||
|
{
|
||||||
|
RequestedCount = taskIds.Length,
|
||||||
|
SuccessCount = items.Count(static item => item.Success),
|
||||||
|
FailedCount = items.Count(static item => !item.Success),
|
||||||
|
Items = items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProcessNextAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var jobs = _dbContext.RecordUploadJobs
|
||||||
|
.Include(static item => item.RecordTask)
|
||||||
|
.ThenInclude(static item => item!.Result)
|
||||||
|
.Include(static item => item.RecordTask)
|
||||||
|
.ThenInclude(static item => item!.LiveRoom);
|
||||||
|
|
||||||
|
var job = await jobs
|
||||||
|
.Where(item =>
|
||||||
|
item.Status == RecordArtifactUploadStatus.Uploading ||
|
||||||
|
item.Status == RecordArtifactUploadStatus.Queued)
|
||||||
|
.OrderByDescending(static item => item.Status == RecordArtifactUploadStatus.Uploading)
|
||||||
|
.ThenBy(static item => item.RequestedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (job is null)
|
||||||
|
{
|
||||||
|
var waitingJobs = await jobs
|
||||||
|
.Where(static item => item.Status == RecordArtifactUploadStatus.WaitingRetry)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
job = waitingJobs
|
||||||
|
.Where(item => !item.NextAttemptAt.HasValue || item.NextAttemptAt <= now)
|
||||||
|
.OrderBy(item => item.NextAttemptAt ?? DateTimeOffset.MinValue)
|
||||||
|
.ThenBy(static item => item.RequestedAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job?.RecordTask?.Result is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = job.RecordTask.Result;
|
||||||
|
if (job.Status != RecordArtifactUploadStatus.Uploading)
|
||||||
|
{
|
||||||
|
job.BeginAttempt(now);
|
||||||
|
result.MarkUploadStarted("openlist", now);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = await _settingsService.GetAsync(cancellationToken);
|
||||||
|
var connection = new OpenListConnectionRequest
|
||||||
|
{
|
||||||
|
BaseUrl = job.ProviderEndpoint,
|
||||||
|
Username = settings.OpenListUpload.Username,
|
||||||
|
Password = settings.OpenListUpload.Password
|
||||||
|
};
|
||||||
|
await ProcessJobStepAsync(job, result, connection, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OpenListUploadConflictException ex)
|
||||||
|
{
|
||||||
|
await MarkFailedAsync(job, result, ex.Message, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await ScheduleRetryOrFailAsync(job, result, ex.Message, clearExternalTask: false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<RecordArtifactUploadItemResultDto> EnqueueInternalAsync(
|
||||||
|
Guid recordTaskId,
|
||||||
|
SystemSettingsDto settings,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ValidateSettings(settings.OpenListUpload);
|
||||||
|
var recordTask = await _dbContext.RecordTasks
|
||||||
|
.Include(static item => item.Result)
|
||||||
|
.Include(static item => item.UploadJob)
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
|
||||||
|
if (recordTask?.Result is null)
|
||||||
|
{
|
||||||
|
return Failure(recordTaskId, "录制结果尚未生成,不能上传。", "openlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = recordTask.Result;
|
||||||
|
var localVideoPath = NormalizeAbsolutePath(result.FilePath);
|
||||||
|
if (string.IsNullOrWhiteSpace(localVideoPath) || !File.Exists(localVideoPath))
|
||||||
|
{
|
||||||
|
if (result.UploadStatus == RecordArtifactUploadStatus.Succeeded)
|
||||||
|
{
|
||||||
|
return SuccessFromExisting(recordTaskId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Failure(recordTaskId, "本地视频文件不存在,不能加入上传队列。", "openlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputRoot = Path.GetFullPath(settings.OutputRoot, AppContext.BaseDirectory);
|
||||||
|
var videoRelativePath = GetSafeRelativePath(outputRoot, localVideoPath);
|
||||||
|
var sourceVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, videoRelativePath);
|
||||||
|
var targetVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.DestinationPath, videoRelativePath);
|
||||||
|
|
||||||
|
string? sourceDanmakuPath = null;
|
||||||
|
string? targetDanmakuPath = null;
|
||||||
|
long? danmakuSizeBytes = null;
|
||||||
|
var localDanmakuPath = NormalizeNullableAbsolutePath(result.DanmakuFilePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(localDanmakuPath) && File.Exists(localDanmakuPath))
|
||||||
|
{
|
||||||
|
var danmakuRelativePath = GetSafeRelativePath(outputRoot, localDanmakuPath);
|
||||||
|
sourceDanmakuPath = OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, danmakuRelativePath);
|
||||||
|
targetDanmakuPath = OpenListClient.CombinePath(settings.OpenListUpload.DestinationPath, danmakuRelativePath);
|
||||||
|
danmakuSizeBytes = new FileInfo(localDanmakuPath).Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(sourceVideoPath, targetVideoPath, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Failure(recordTaskId, "OpenList 源路径和目标路径不能相同。", "openlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var endpoint = OpenListClient.NormalizeBaseUrl(settings.OpenListUpload.BaseUrl);
|
||||||
|
var videoSizeBytes = new FileInfo(localVideoPath).Length;
|
||||||
|
var job = recordTask.UploadJob;
|
||||||
|
if (job is null)
|
||||||
|
{
|
||||||
|
job = new RecordUploadJob(
|
||||||
|
recordTask.Id,
|
||||||
|
endpoint,
|
||||||
|
sourceVideoPath,
|
||||||
|
targetVideoPath,
|
||||||
|
videoSizeBytes,
|
||||||
|
sourceDanmakuPath,
|
||||||
|
targetDanmakuPath,
|
||||||
|
danmakuSizeBytes,
|
||||||
|
settings.DeleteLocalFilesAfterUpload,
|
||||||
|
now);
|
||||||
|
await _dbContext.RecordUploadJobs.AddAsync(job, cancellationToken);
|
||||||
|
}
|
||||||
|
else if (job.Status == RecordArtifactUploadStatus.Succeeded)
|
||||||
|
{
|
||||||
|
return SuccessFromExisting(recordTaskId, result);
|
||||||
|
}
|
||||||
|
else if (job.Status is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.Uploading or RecordArtifactUploadStatus.WaitingRetry)
|
||||||
|
{
|
||||||
|
return QueuedResult(recordTaskId, result, job, "该分片已在 OpenList 上传队列中。");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
job.RefreshRequest(
|
||||||
|
endpoint,
|
||||||
|
sourceVideoPath,
|
||||||
|
targetVideoPath,
|
||||||
|
videoSizeBytes,
|
||||||
|
sourceDanmakuPath,
|
||||||
|
targetDanmakuPath,
|
||||||
|
danmakuSizeBytes,
|
||||||
|
settings.DeleteLocalFilesAfterUpload,
|
||||||
|
now);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.MarkUploadQueued("openlist", now);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
SystemLogLevel.Info,
|
||||||
|
"Upload",
|
||||||
|
"OpenList upload job queued.",
|
||||||
|
$"video={targetVideoPath}; danmaku={targetDanmakuPath ?? "none"}",
|
||||||
|
liveRoomId: recordTask.LiveRoomId,
|
||||||
|
recordSessionId: recordTask.RecordSessionId,
|
||||||
|
recordTaskId: recordTask.Id,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return QueuedResult(recordTaskId, result, job, "已加入 OpenList 上传队列。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessJobStepAsync(
|
||||||
|
RecordUploadJob job,
|
||||||
|
RecordResult result,
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (job.CurrentArtifact == RecordUploadArtifactStage.Completed)
|
||||||
|
{
|
||||||
|
await CompleteJobAsync(job, result, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var targetPath = job.GetCurrentTargetPath();
|
||||||
|
var expectedSize = job.GetCurrentSizeBytes();
|
||||||
|
var localPath = GetCurrentLocalPath(job, result);
|
||||||
|
|
||||||
|
if (job.VerificationStartedAt.HasValue)
|
||||||
|
{
|
||||||
|
var verification = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
|
||||||
|
if (verification == TargetVerification.Match)
|
||||||
|
{
|
||||||
|
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verification == TargetVerification.Conflict)
|
||||||
|
{
|
||||||
|
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - job.VerificationStartedAt.Value < VerificationTimeout)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ScheduleRetryOrFailAsync(job, result, $"OpenList 任务结束后两分钟内仍未发现目标文件 '{targetPath}'。", true, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.ExternalTaskId))
|
||||||
|
{
|
||||||
|
if (job.ExternalTaskStartedAt.HasValue && now - job.ExternalTaskStartedAt.Value > ExternalTaskTimeout)
|
||||||
|
{
|
||||||
|
await ScheduleRetryOrFailAsync(job, result, $"OpenList 复制任务运行超过 24 小时:{job.ExternalTaskId}", true, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var task = await _openListClient.TryGetCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
|
||||||
|
if (task is null)
|
||||||
|
{
|
||||||
|
var missingTaskVerification = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
|
||||||
|
if (missingTaskVerification == TargetVerification.Match)
|
||||||
|
{
|
||||||
|
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
|
||||||
|
}
|
||||||
|
else if (missingTaskVerification == TargetVerification.Conflict)
|
||||||
|
{
|
||||||
|
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ScheduleRetryOrFailAsync(job, result, $"OpenList 复制任务不存在:{job.ExternalTaskId}", true, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.SetProgress(job.CalculateOverallProgress(task.Progress), now);
|
||||||
|
if (task.State == 2)
|
||||||
|
{
|
||||||
|
job.StartVerification(now);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
await ProcessJobStepAsync(job, result, connection, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.State is 4 or 7)
|
||||||
|
{
|
||||||
|
var error = string.IsNullOrWhiteSpace(task.Error)
|
||||||
|
? $"OpenList 复制任务以状态 {task.State} 结束。"
|
||||||
|
: task.Error;
|
||||||
|
await ScheduleRetryOrFailAsync(job, result, error, true, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingTarget = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken);
|
||||||
|
if (existingTarget == TargetVerification.Match)
|
||||||
|
{
|
||||||
|
await CompleteCurrentArtifactAsync(job, result, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTarget == TargetVerification.Conflict)
|
||||||
|
{
|
||||||
|
throw new OpenListUploadConflictException($"目标文件 '{targetPath}' 已存在但内容不一致。");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.MarkUploadStarted("openlist", now);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sourcePath = job.GetCurrentSourcePath();
|
||||||
|
var sourceObject = await _openListClient.TryGetObjectAsync(connection, sourcePath, cancellationToken);
|
||||||
|
if (sourceObject is null || sourceObject.IsDirectory)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OpenList 源文件不存在:{sourcePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceObject.Size != expectedSize)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OpenList 源文件大小不一致:期望 {expectedSize},实际 {sourceObject.Size},路径 {sourcePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _openListClient.EnsureDirectoryAsync(connection, GetDirectoryName(targetPath), cancellationToken);
|
||||||
|
var copyResult = await _openListClient.CopyFileAsync(connection, sourcePath, targetPath, cancellationToken);
|
||||||
|
if (copyResult.TaskIds.Count == 0)
|
||||||
|
{
|
||||||
|
job.StartVerification(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
job.TrackExternalTask(copyResult.TaskIds[0], "copy", job.ProgressPercent, DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteCurrentArtifactAsync(
|
||||||
|
RecordUploadJob job,
|
||||||
|
RecordResult result,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (job.CurrentArtifact == RecordUploadArtifactStage.Video)
|
||||||
|
{
|
||||||
|
result.MarkRemoteVideoUploaded(job.TargetVideoPath, now);
|
||||||
|
}
|
||||||
|
else if (job.CurrentArtifact == RecordUploadArtifactStage.Danmaku && job.TargetDanmakuPath is not null)
|
||||||
|
{
|
||||||
|
result.MarkRemoteDanmakuUploaded(job.TargetDanmakuPath, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.CompleteCurrentArtifact(now);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
if (job.CurrentArtifact == RecordUploadArtifactStage.Completed)
|
||||||
|
{
|
||||||
|
await CompleteJobAsync(job, result, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteJobAsync(
|
||||||
|
RecordUploadJob job,
|
||||||
|
RecordResult result,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var deletedLocalFiles = false;
|
||||||
|
string? cleanupWarning = null;
|
||||||
|
if (job.DeleteLocalFilesAfterUpload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
deletedLocalFiles = DeleteLocalArtifacts(job, result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
cleanupWarning = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job.MarkSucceeded(now);
|
||||||
|
result.MarkUploadSucceeded(
|
||||||
|
"openlist",
|
||||||
|
job.TargetVideoPath,
|
||||||
|
job.TargetDanmakuPath,
|
||||||
|
deletedLocalFiles,
|
||||||
|
now);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
cleanupWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
|
||||||
|
"Upload",
|
||||||
|
"OpenList artifact upload completed.",
|
||||||
|
$"video={job.TargetVideoPath}; danmaku={job.TargetDanmakuPath ?? "none"}; deletedLocalFiles={deletedLocalFiles}" +
|
||||||
|
(cleanupWarning is null ? string.Empty : $"; cleanupWarning={cleanupWarning}"),
|
||||||
|
liveRoomId: job.RecordTask?.LiveRoomId,
|
||||||
|
recordSessionId: job.RecordTask?.RecordSessionId,
|
||||||
|
recordTaskId: job.RecordTaskId,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScheduleRetryOrFailAsync(
|
||||||
|
RecordUploadJob job,
|
||||||
|
RecordResult result,
|
||||||
|
string error,
|
||||||
|
bool clearExternalTask,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (job.AttemptCount >= MaxAttempts)
|
||||||
|
{
|
||||||
|
await MarkFailedAsync(job, result, error, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var delayIndex = Math.Clamp(Math.Max(1, job.AttemptCount) - 1, 0, RetryDelays.Length - 1);
|
||||||
|
var nextAttemptAt = now.Add(RetryDelays[delayIndex]);
|
||||||
|
job.ScheduleRetry(error, nextAttemptAt, now, clearExternalTask);
|
||||||
|
result.MarkUploadWaitingRetry("openlist", error, now);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
SystemLogLevel.Warning,
|
||||||
|
"Upload",
|
||||||
|
"OpenList upload will retry.",
|
||||||
|
$"attempt={job.AttemptCount}/{MaxAttempts}; nextAttemptAt={nextAttemptAt:O}; error={error}",
|
||||||
|
liveRoomId: job.RecordTask?.LiveRoomId,
|
||||||
|
recordSessionId: job.RecordTask?.RecordSessionId,
|
||||||
|
recordTaskId: job.RecordTaskId,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkFailedAsync(
|
||||||
|
RecordUploadJob job,
|
||||||
|
RecordResult result,
|
||||||
|
string error,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
job.MarkFailed(error, now);
|
||||||
|
result.MarkUploadFailed("openlist", error, now);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _systemLogService.WriteAsync(
|
||||||
|
SystemLogLevel.Error,
|
||||||
|
"Upload",
|
||||||
|
"OpenList upload failed permanently.",
|
||||||
|
error,
|
||||||
|
liveRoomId: job.RecordTask?.LiveRoomId,
|
||||||
|
recordSessionId: job.RecordTask?.RecordSessionId,
|
||||||
|
recordTaskId: job.RecordTaskId,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TargetVerification> VerifyTargetAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string targetPath,
|
||||||
|
string localPath,
|
||||||
|
long expectedSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var remote = await _openListClient.TryGetObjectAsync(connection, targetPath, cancellationToken);
|
||||||
|
if (remote is null)
|
||||||
|
{
|
||||||
|
return TargetVerification.Missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remote.IsDirectory || remote.Size != expectedSize)
|
||||||
|
{
|
||||||
|
return TargetVerification.Conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
var comparableHash = remote.Hashes
|
||||||
|
.FirstOrDefault(pair => pair.Key.Equals("sha256", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
pair.Key.Equals("sha1", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
pair.Key.Equals("md5", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (string.IsNullOrWhiteSpace(comparableHash.Key) || string.IsNullOrWhiteSpace(comparableHash.Value))
|
||||||
|
{
|
||||||
|
return TargetVerification.Match;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localHash = await ComputeFileHashAsync(localPath, comparableHash.Key, cancellationToken);
|
||||||
|
return string.Equals(localHash, comparableHash.Value, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? TargetVerification.Match
|
||||||
|
: TargetVerification.Conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ComputeFileHashAsync(
|
||||||
|
string localPath,
|
||||||
|
string hashName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HashAlgorithm algorithm = hashName.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"md5" => MD5.Create(),
|
||||||
|
"sha1" => SHA1.Create(),
|
||||||
|
"sha256" => SHA256.Create(),
|
||||||
|
_ => throw new InvalidOperationException($"不支持的哈希类型:{hashName}")
|
||||||
|
};
|
||||||
|
await using var stream = new FileStream(
|
||||||
|
localPath,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.Read,
|
||||||
|
FileShare.Read,
|
||||||
|
bufferSize: 1024 * 1024,
|
||||||
|
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
var hash = await algorithm.ComputeHashAsync(stream, cancellationToken);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCurrentLocalPath(RecordUploadJob job, RecordResult result) =>
|
||||||
|
job.CurrentArtifact switch
|
||||||
|
{
|
||||||
|
RecordUploadArtifactStage.Video => NormalizeAbsolutePath(result.FilePath),
|
||||||
|
RecordUploadArtifactStage.Danmaku => NormalizeNullableAbsolutePath(result.DanmakuFilePath)
|
||||||
|
?? throw new InvalidOperationException("本地弹幕文件路径不存在。"),
|
||||||
|
_ => throw new InvalidOperationException("上传作业没有待处理产物。")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool DeleteLocalArtifacts(RecordUploadJob job, RecordResult result)
|
||||||
|
{
|
||||||
|
var paths = new List<string> { NormalizeAbsolutePath(result.FilePath) };
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.SourceDanmakuPath))
|
||||||
|
{
|
||||||
|
var danmakuPath = NormalizeNullableAbsolutePath(result.DanmakuFilePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(danmakuPath))
|
||||||
|
{
|
||||||
|
paths.Add(danmakuPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var artifactPaths = paths.Where(static path => !string.IsNullOrWhiteSpace(path)).ToArray();
|
||||||
|
|
||||||
|
foreach (var path in artifactPaths)
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifactPaths.Length > 0 && artifactPaths.All(static path => !File.Exists(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateSettings(OpenListUploadSettingsDto settings)
|
||||||
|
{
|
||||||
|
_ = OpenListClient.NormalizeBaseUrl(settings.BaseUrl);
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.Username) || string.IsNullOrWhiteSpace(settings.Password))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OpenList 用户名或密码未配置。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.SourcePath) || string.IsNullOrWhiteSpace(settings.DestinationPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("请选择 OpenList 源挂载根目录和目标归档根目录。");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = OpenListClient.NormalizePath(settings.SourcePath);
|
||||||
|
_ = OpenListClient.NormalizePath(settings.DestinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSafeRelativePath(string outputRoot, string absolutePath)
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(outputRoot, absolutePath);
|
||||||
|
if (relativePath == ".." ||
|
||||||
|
relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
|
||||||
|
Path.IsPathRooted(relativePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"录制文件不在输出根目录内:{absolutePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativePath.Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeAbsolutePath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.IsPathRooted(path)
|
||||||
|
? Path.GetFullPath(path)
|
||||||
|
: Path.GetFullPath(path, AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeNullableAbsolutePath(string? path) =>
|
||||||
|
string.IsNullOrWhiteSpace(path) ? null : NormalizeAbsolutePath(path);
|
||||||
|
|
||||||
|
private static string GetDirectoryName(string path)
|
||||||
|
{
|
||||||
|
var normalized = OpenListClient.NormalizePath(path);
|
||||||
|
var index = normalized.LastIndexOf('/');
|
||||||
|
return index <= 0 ? "/" : normalized[..index];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecordArtifactUploadItemResultDto Failure(Guid recordTaskId, string message, string provider) => new()
|
||||||
|
{
|
||||||
|
RecordTaskId = recordTaskId,
|
||||||
|
Success = false,
|
||||||
|
Message = message,
|
||||||
|
Provider = provider
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RecordArtifactUploadItemResultDto SuccessFromExisting(Guid recordTaskId, RecordResult result) => new()
|
||||||
|
{
|
||||||
|
RecordTaskId = recordTaskId,
|
||||||
|
Success = true,
|
||||||
|
Message = "该分片已上传。",
|
||||||
|
Provider = result.LastUploadProvider,
|
||||||
|
RemoteVideoPath = result.RemoteVideoPath,
|
||||||
|
RemoteDanmakuPath = result.RemoteDanmakuPath,
|
||||||
|
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
|
||||||
|
UploadStatus = result.UploadStatus,
|
||||||
|
ProgressPercent = 100
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RecordArtifactUploadItemResultDto QueuedResult(
|
||||||
|
Guid recordTaskId,
|
||||||
|
RecordResult result,
|
||||||
|
RecordUploadJob job,
|
||||||
|
string message) => new()
|
||||||
|
{
|
||||||
|
RecordTaskId = recordTaskId,
|
||||||
|
Success = true,
|
||||||
|
Message = message,
|
||||||
|
Provider = "openlist",
|
||||||
|
RemoteVideoPath = result.RemoteVideoPath,
|
||||||
|
RemoteDanmakuPath = result.RemoteDanmakuPath,
|
||||||
|
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
|
||||||
|
UploadStatus = job.Status,
|
||||||
|
ProgressPercent = job.ProgressPercent,
|
||||||
|
AttemptCount = job.AttemptCount,
|
||||||
|
NextAttemptAt = job.NextAttemptAt
|
||||||
|
};
|
||||||
|
|
||||||
|
private enum TargetVerification
|
||||||
|
{
|
||||||
|
Missing,
|
||||||
|
Match,
|
||||||
|
Conflict
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OpenListUploadConflictException : Exception
|
||||||
|
{
|
||||||
|
public OpenListUploadConflictException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OpenListUploadBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(2);
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<OpenListUploadBackgroundService> _logger;
|
||||||
|
|
||||||
|
public OpenListUploadBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<OpenListUploadBackgroundService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var queue = scope.ServiceProvider.GetRequiredService<OpenListUploadQueueService>();
|
||||||
|
var processed = await queue.ProcessNextAsync(stoppingToken);
|
||||||
|
if (!processed)
|
||||||
|
{
|
||||||
|
await Task.Delay(IdleDelay, stoppingToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Task.Delay(IdleDelay, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "OpenList upload background worker failed");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,15 +18,18 @@ public sealed class RecordUploadService
|
|||||||
private readonly LiveRecorderDbContext _dbContext;
|
private readonly LiveRecorderDbContext _dbContext;
|
||||||
private readonly ISystemSettingsService _systemSettingsService;
|
private readonly ISystemSettingsService _systemSettingsService;
|
||||||
private readonly ISystemLogService _systemLogService;
|
private readonly ISystemLogService _systemLogService;
|
||||||
|
private readonly OpenListUploadQueueService _openListUploadQueue;
|
||||||
|
|
||||||
public RecordUploadService(
|
public RecordUploadService(
|
||||||
LiveRecorderDbContext dbContext,
|
LiveRecorderDbContext dbContext,
|
||||||
ISystemSettingsService systemSettingsService,
|
ISystemSettingsService systemSettingsService,
|
||||||
ISystemLogService systemLogService)
|
ISystemLogService systemLogService,
|
||||||
|
OpenListUploadQueueService openListUploadQueue)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_systemSettingsService = systemSettingsService;
|
_systemSettingsService = systemSettingsService;
|
||||||
_systemLogService = systemLogService;
|
_systemLogService = systemLogService;
|
||||||
|
_openListUploadQueue = openListUploadQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RecordArtifactUploadItemResultDto?> TryAutoUploadTaskAsync(
|
public async Task<RecordArtifactUploadItemResultDto?> TryAutoUploadTaskAsync(
|
||||||
@@ -39,6 +42,11 @@ public sealed class RecordUploadService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.UploadTarget == UploadTargetType.OpenList)
|
||||||
|
{
|
||||||
|
return await _openListUploadQueue.TryEnqueueAutomaticAsync(recordTaskId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: true, cancellationToken);
|
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +55,11 @@ public sealed class RecordUploadService
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
|
if (settings.UploadTarget == UploadTargetType.OpenList)
|
||||||
|
{
|
||||||
|
return await _openListUploadQueue.EnqueueAsync(recordTaskId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: false, cancellationToken);
|
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +68,11 @@ public sealed class RecordUploadService
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||||
|
if (settings.UploadTarget == UploadTargetType.OpenList)
|
||||||
|
{
|
||||||
|
return await _openListUploadQueue.EnqueueSessionAsync(recordSessionId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var session = await _dbContext.RecordSessions
|
var session = await _dbContext.RecordSessions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(item => item.RecordTasks)
|
.Include(item => item.RecordTasks)
|
||||||
|
|||||||
@@ -104,8 +104,14 @@ public sealed class RecordSessionsController : ControllerBase
|
|||||||
Ok(await _cleanupOperationCoordinator.EnqueueEmptyAsync(request, cancellationToken));
|
Ok(await _cleanupOperationCoordinator.EnqueueEmptyAsync(request, cancellationToken));
|
||||||
|
|
||||||
[HttpPost("{id:guid}/upload")]
|
[HttpPost("{id:guid}/upload")]
|
||||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken)
|
||||||
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
|
{
|
||||||
|
var result = await _recordUploadService.UploadSessionAsync(id, cancellationToken);
|
||||||
|
return result.Items.Any(static item =>
|
||||||
|
item.UploadStatus is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.WaitingRetry)
|
||||||
|
? Accepted(result)
|
||||||
|
: Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/danmaku")]
|
[HttpGet("{id:guid}/danmaku")]
|
||||||
public async Task<ActionResult<SessionDanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult<SessionDanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ public sealed class RecordTasksController : ControllerBase
|
|||||||
var notUploadedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.NotUploaded, cancellationToken);
|
var notUploadedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.NotUploaded, cancellationToken);
|
||||||
var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, cancellationToken);
|
var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, cancellationToken);
|
||||||
var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, cancellationToken);
|
var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, cancellationToken);
|
||||||
|
var queuedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Queued, cancellationToken);
|
||||||
|
var uploadingCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Uploading, cancellationToken);
|
||||||
|
var waitingRetryCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.WaitingRetry, cancellationToken);
|
||||||
|
|
||||||
return Ok(new UploadTaskListResponse
|
return Ok(new UploadTaskListResponse
|
||||||
{
|
{
|
||||||
@@ -63,7 +66,10 @@ public sealed class RecordTasksController : ControllerBase
|
|||||||
TotalCount = totalCount,
|
TotalCount = totalCount,
|
||||||
NotUploadedCount = notUploadedCount,
|
NotUploadedCount = notUploadedCount,
|
||||||
SucceededCount = succeededCount,
|
SucceededCount = succeededCount,
|
||||||
FailedCount = failedCount
|
FailedCount = failedCount,
|
||||||
|
QueuedCount = queuedCount,
|
||||||
|
UploadingCount = uploadingCount,
|
||||||
|
WaitingRetryCount = waitingRetryCount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +77,7 @@ public sealed class RecordTasksController : ControllerBase
|
|||||||
{
|
{
|
||||||
var (result, task) = pair;
|
var (result, task) = pair;
|
||||||
var liveRoom = task.LiveRoom;
|
var liveRoom = task.LiveRoom;
|
||||||
|
var uploadJob = task.UploadJob;
|
||||||
|
|
||||||
return new UploadTaskItemDto
|
return new UploadTaskItemDto
|
||||||
{
|
{
|
||||||
@@ -92,7 +99,12 @@ public sealed class RecordTasksController : ControllerBase
|
|||||||
LastUploadedAt = result.LastUploadedAt,
|
LastUploadedAt = result.LastUploadedAt,
|
||||||
UploadErrorMessage = result.UploadErrorMessage,
|
UploadErrorMessage = result.UploadErrorMessage,
|
||||||
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
|
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
|
||||||
CreatedAt = result.CreatedAt
|
CreatedAt = result.CreatedAt,
|
||||||
|
UploadProgressPercent = uploadJob?.ProgressPercent,
|
||||||
|
UploadAttemptCount = uploadJob?.AttemptCount ?? 0,
|
||||||
|
NextUploadAttemptAt = uploadJob?.NextAttemptAt,
|
||||||
|
CurrentUploadArtifact = uploadJob?.CurrentArtifact.ToString(),
|
||||||
|
ExternalUploadTaskId = uploadJob?.ExternalTaskId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +170,13 @@ public sealed class RecordTasksController : ControllerBase
|
|||||||
Ok(await _recordService.TriggerSegmentCompletedEventAsync(id, cancellationToken));
|
Ok(await _recordService.TriggerSegmentCompletedEventAsync(id, cancellationToken));
|
||||||
|
|
||||||
[HttpPost("{id:guid}/upload")]
|
[HttpPost("{id:guid}/upload")]
|
||||||
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken)
|
||||||
Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
|
{
|
||||||
|
var result = await _recordUploadService.UploadTaskAsync(id, cancellationToken);
|
||||||
|
return result.UploadStatus is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.WaitingRetry
|
||||||
|
? Accepted(result)
|
||||||
|
: Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/danmaku")]
|
[HttpGet("{id:guid}/danmaku")]
|
||||||
public async Task<ActionResult<DanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult<DanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -21,19 +21,22 @@ public sealed class SettingsController : ControllerBase
|
|||||||
private readonly IEventScriptService _eventScriptService;
|
private readonly IEventScriptService _eventScriptService;
|
||||||
private readonly IWebhookNotificationService _webhookNotificationService;
|
private readonly IWebhookNotificationService _webhookNotificationService;
|
||||||
private readonly RetentionCleanupService _retentionCleanupService;
|
private readonly RetentionCleanupService _retentionCleanupService;
|
||||||
|
private readonly IOpenListClient _openListClient;
|
||||||
|
|
||||||
public SettingsController(
|
public SettingsController(
|
||||||
ISystemSettingsService systemSettingsService,
|
ISystemSettingsService systemSettingsService,
|
||||||
IEmailNotificationService emailNotificationService,
|
IEmailNotificationService emailNotificationService,
|
||||||
IEventScriptService eventScriptService,
|
IEventScriptService eventScriptService,
|
||||||
IWebhookNotificationService webhookNotificationService,
|
IWebhookNotificationService webhookNotificationService,
|
||||||
RetentionCleanupService retentionCleanupService)
|
RetentionCleanupService retentionCleanupService,
|
||||||
|
IOpenListClient openListClient)
|
||||||
{
|
{
|
||||||
_systemSettingsService = systemSettingsService;
|
_systemSettingsService = systemSettingsService;
|
||||||
_emailNotificationService = emailNotificationService;
|
_emailNotificationService = emailNotificationService;
|
||||||
_eventScriptService = eventScriptService;
|
_eventScriptService = eventScriptService;
|
||||||
_webhookNotificationService = webhookNotificationService;
|
_webhookNotificationService = webhookNotificationService;
|
||||||
_retentionCleanupService = retentionCleanupService;
|
_retentionCleanupService = retentionCleanupService;
|
||||||
|
_openListClient = openListClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -102,6 +105,18 @@ public sealed class SettingsController : ControllerBase
|
|||||||
return Ok(operation);
|
return Ok(operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("openlist/test")]
|
||||||
|
public async Task<ActionResult<OpenListConnectionTestDto>> TestOpenList(
|
||||||
|
[FromBody] OpenListConnectionRequest request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _openListClient.TestConnectionAsync(request, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("openlist/directories")]
|
||||||
|
public async Task<ActionResult<OpenListDirectoryListDto>> ListOpenListDirectories(
|
||||||
|
[FromBody] OpenListDirectoryRequest request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _openListClient.ListDirectoriesAsync(request, cancellationToken));
|
||||||
|
|
||||||
private static void ApplyLegacyPlatformSettings(JsonElement root, UpdateSystemSettingsRequest request)
|
private static void ApplyLegacyPlatformSettings(JsonElement root, UpdateSystemSettingsRequest request)
|
||||||
{
|
{
|
||||||
if (root.ValueKind != JsonValueKind.Object)
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
|
|||||||
@@ -123,6 +123,22 @@ builder.Services.AddHttpClient("bilibili", client =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient("openlist", client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestVersion = HttpVersion.Version11;
|
||||||
|
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
|
||||||
|
})
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(static () => new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
|
||||||
|
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
|
||||||
|
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
|
||||||
|
MaxConnectionsPerServer = 4,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||||
|
UseCookies = false
|
||||||
|
});
|
||||||
|
|
||||||
var defaultConnection = builder.Configuration.GetConnectionString("DefaultConnection")
|
var defaultConnection = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required.");
|
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required.");
|
||||||
|
|
||||||
@@ -191,6 +207,8 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
|||||||
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||||
builder.Services.AddScoped<PlatformHttpRequestService>();
|
builder.Services.AddScoped<PlatformHttpRequestService>();
|
||||||
builder.Services.AddScoped<RecordUploadService>();
|
builder.Services.AddScoped<RecordUploadService>();
|
||||||
|
builder.Services.AddScoped<OpenListUploadQueueService>();
|
||||||
|
builder.Services.AddSingleton<IOpenListClient, OpenListClient>();
|
||||||
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
|
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
|
||||||
builder.Services.AddScoped<IVideoMetadataService, FfmpegVideoMetadataService>();
|
builder.Services.AddScoped<IVideoMetadataService, FfmpegVideoMetadataService>();
|
||||||
builder.Services.AddScoped<BandwidthStatisticsService>();
|
builder.Services.AddScoped<BandwidthStatisticsService>();
|
||||||
@@ -226,6 +244,7 @@ builder.Services.AddSingleton<ILiveRoomPollingSignal>(provider => provider.GetRe
|
|||||||
builder.Services.AddHostedService(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
|
builder.Services.AddHostedService(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
|
||||||
builder.Services.AddHostedService<CleanupOperationBackgroundService>();
|
builder.Services.AddHostedService<CleanupOperationBackgroundService>();
|
||||||
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
|
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
|
||||||
|
builder.Services.AddHostedService<OpenListUploadBackgroundService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -0,0 +1,615 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LiveRecorder.Application.Abstractions.Logging;
|
||||||
|
using LiveRecorder.Application.Abstractions.Settings;
|
||||||
|
using LiveRecorder.Application.Models.Logs;
|
||||||
|
using LiveRecorder.Application.Models.Settings;
|
||||||
|
using LiveRecorder.Domain.Entities;
|
||||||
|
using LiveRecorder.Domain.Enums;
|
||||||
|
using LiveRecorder.Infrastructure.Persistence;
|
||||||
|
using LiveRecorder.Infrastructure.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
|
||||||
|
namespace LiveRecorder.Tests;
|
||||||
|
|
||||||
|
public sealed class OpenListUploadTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://openlist.example.com/", "https://openlist.example.com")]
|
||||||
|
[InlineData("https://openlist.example.com/base/dav/archive/file", "https://openlist.example.com/base")]
|
||||||
|
[InlineData("https://openlist.example.com/dav", "https://openlist.example.com")]
|
||||||
|
[InlineData("https://openlist.example.com/davinci", "https://openlist.example.com/davinci")]
|
||||||
|
public void NormalizeBaseUrl_HandlesWebDavUrlsWithoutTruncatingOrdinarySegments(string input, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, OpenListClient.NormalizeBaseUrl(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenListPaths_PreserveUnicodeAndRejectTraversal()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
"/归档/Douyin/2026/08/01/主播名/分片.mp4",
|
||||||
|
OpenListClient.CombinePath("/归档/", "Douyin/2026/08/01/主播名/分片.mp4"));
|
||||||
|
Assert.Throws<InvalidOperationException>(() => OpenListClient.NormalizePath("/归档/../其他"));
|
||||||
|
Assert.Throws<InvalidOperationException>(() => OpenListClient.CombinePath("/归档", "../其他"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureDirectory_ReusesExistingLevelsAndCreatesOnlyMissingLevels()
|
||||||
|
{
|
||||||
|
var existingDirectories = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"/",
|
||||||
|
"/归档",
|
||||||
|
"/归档/Douyin"
|
||||||
|
};
|
||||||
|
var createdDirectories = new List<string>();
|
||||||
|
var handler = new StubHttpMessageHandler(async request =>
|
||||||
|
{
|
||||||
|
if (request.RequestUri!.AbsolutePath.EndsWith("/api/auth/login", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return JsonResponse("""{"code":200,"message":"success","data":{"token":"test-token"}}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await request.Content!.ReadAsStringAsync();
|
||||||
|
var path = JsonDocument.Parse(body).RootElement.GetProperty("path").GetString()!;
|
||||||
|
if (request.RequestUri.AbsolutePath.EndsWith("/api/fs/get", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return existingDirectories.Contains(path)
|
||||||
|
? JsonResponse("""{"code":200,"message":"success","data":{"name":"dir","size":0,"is_dir":true,"hash_info":{}}}""")
|
||||||
|
: JsonResponse("""{"code":500,"message":"object not found","data":null}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.RequestUri.AbsolutePath.EndsWith("/api/fs/list", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Assert.True(JsonDocument.Parse(body).RootElement.GetProperty("refresh").GetBoolean());
|
||||||
|
return JsonResponse("""{"code":200,"message":"success","data":{"content":[],"write":true}}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.EndsWith("/api/fs/mkdir", request.RequestUri.AbsolutePath, StringComparison.Ordinal);
|
||||||
|
createdDirectories.Add(path);
|
||||||
|
existingDirectories.Add(path);
|
||||||
|
return JsonResponse("""{"code":200,"message":"success","data":null}""");
|
||||||
|
});
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
await client.EnsureDirectoryAsync(Connection(), "/归档/Douyin/2026/08/01/主播名");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"/归档/Douyin/2026",
|
||||||
|
"/归档/Douyin/2026/08",
|
||||||
|
"/归档/Douyin/2026/08/01",
|
||||||
|
"/归档/Douyin/2026/08/01/主播名"
|
||||||
|
],
|
||||||
|
createdDirectories);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TryGetObject_RefreshesParentDirectoryAfterCacheMiss()
|
||||||
|
{
|
||||||
|
var getCount = 0;
|
||||||
|
var refreshedPath = string.Empty;
|
||||||
|
var handler = new StubHttpMessageHandler(async request =>
|
||||||
|
{
|
||||||
|
if (request.RequestUri!.AbsolutePath.EndsWith("/api/auth/login", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return JsonResponse("""{"code":200,"message":"success","data":{"token":"test-token"}}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement;
|
||||||
|
if (request.RequestUri.AbsolutePath.EndsWith("/api/fs/list", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
refreshedPath = body.GetProperty("path").GetString();
|
||||||
|
Assert.True(body.GetProperty("refresh").GetBoolean());
|
||||||
|
return JsonResponse("""{"code":200,"message":"success","data":{"content":[],"write":true}}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.EndsWith("/api/fs/get", request.RequestUri.AbsolutePath, StringComparison.Ordinal);
|
||||||
|
getCount++;
|
||||||
|
return getCount == 1
|
||||||
|
? JsonResponse("""{"code":500,"message":"object not found","data":null}""")
|
||||||
|
: JsonResponse("""{"code":200,"message":"success","data":{"name":"large.flv","size":2792401155,"is_dir":false,"hash_info":{}}}""");
|
||||||
|
});
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
var result = await client.TryGetObjectAsync(Connection(), "/archive/主播/large.flv");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(2_792_401_155, result.Size);
|
||||||
|
Assert.Equal(2, getCount);
|
||||||
|
Assert.Equal("/archive/主播", refreshedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CopyAndTaskPolling_UseOpenListV423Contract()
|
||||||
|
{
|
||||||
|
HttpMethod? copyMethod = null;
|
||||||
|
HttpMethod? taskMethod = null;
|
||||||
|
string? copyAuthorization = null;
|
||||||
|
JsonElement copyPayload = default;
|
||||||
|
var handler = new StubHttpMessageHandler(async request =>
|
||||||
|
{
|
||||||
|
var path = request.RequestUri!.AbsolutePath;
|
||||||
|
if (path.EndsWith("/api/auth/login", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return JsonResponse("""{"code":200,"message":"success","data":{"token":"test-token"}}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.EndsWith("/api/fs/copy", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
copyMethod = request.Method;
|
||||||
|
copyAuthorization = request.Headers.Authorization?.ToString();
|
||||||
|
copyPayload = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement.Clone();
|
||||||
|
return JsonResponse("""{"code":200,"message":"success","data":{"message":"created","tasks":[{"id":"copy-task-1","state":0,"progress":0}]}}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
taskMethod = request.Method;
|
||||||
|
Assert.EndsWith("/api/task/copy/info", path, StringComparison.Ordinal);
|
||||||
|
Assert.Equal("copy-task-1", GetQueryValue(request.RequestUri.Query, "tid"));
|
||||||
|
return JsonResponse("""{"code":200,"message":"success","data":{"id":"copy-task-1","state":2,"progress":100,"status":"done","error":""}}""");
|
||||||
|
});
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
var copy = await client.CopyFileAsync(
|
||||||
|
Connection(),
|
||||||
|
"/本地挂载/Douyin/主播/分片.mp4",
|
||||||
|
"/移动云盘/归档/Douyin/主播/分片.mp4");
|
||||||
|
var task = await client.TryGetCopyTaskAsync(Connection(), copy.TaskIds.Single());
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Post, copyMethod);
|
||||||
|
Assert.Equal(HttpMethod.Post, taskMethod);
|
||||||
|
Assert.Equal("test-token", copyAuthorization);
|
||||||
|
Assert.Equal("/本地挂载/Douyin/主播", copyPayload.GetProperty("src_dir").GetString());
|
||||||
|
Assert.Equal("/移动云盘/归档/Douyin/主播", copyPayload.GetProperty("dst_dir").GetString());
|
||||||
|
Assert.Equal("分片.mp4", copyPayload.GetProperty("names")[0].GetString());
|
||||||
|
Assert.False(copyPayload.GetProperty("overwrite").GetBoolean());
|
||||||
|
Assert.NotNull(task);
|
||||||
|
Assert.Equal(2, task.State);
|
||||||
|
Assert.Equal(100, task.Progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UploadJob_PersistsArtifactProgressAndRetryState()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.Parse("2026-08-01T10:00:00+08:00");
|
||||||
|
var job = new RecordUploadJob(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"https://openlist.example.com",
|
||||||
|
"/source/video.mp4",
|
||||||
|
"/archive/video.mp4",
|
||||||
|
100,
|
||||||
|
"/source/video.xml",
|
||||||
|
"/archive/video.xml",
|
||||||
|
20,
|
||||||
|
deleteLocalFilesAfterUpload: true,
|
||||||
|
now);
|
||||||
|
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Queued, job.Status);
|
||||||
|
job.BeginAttempt(now.AddSeconds(1));
|
||||||
|
job.TrackExternalTask("task-1", "copy", job.CalculateOverallProgress(50), now.AddSeconds(2));
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Uploading, job.Status);
|
||||||
|
Assert.Equal(1, job.AttemptCount);
|
||||||
|
Assert.Equal(41.67, job.ProgressPercent, precision: 2);
|
||||||
|
|
||||||
|
job.CompleteCurrentArtifact(now.AddSeconds(3));
|
||||||
|
Assert.Equal(RecordUploadArtifactStage.Danmaku, job.CurrentArtifact);
|
||||||
|
Assert.Equal(83.33, job.ProgressPercent, precision: 2);
|
||||||
|
|
||||||
|
var retryAt = now.AddMinutes(1);
|
||||||
|
job.ScheduleRetry("temporary failure", retryAt, now.AddSeconds(4), clearExternalTask: true);
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.WaitingRetry, job.Status);
|
||||||
|
Assert.Equal(retryAt, job.NextAttemptAt);
|
||||||
|
Assert.Null(job.ExternalTaskId);
|
||||||
|
|
||||||
|
job.BeginAttempt(retryAt);
|
||||||
|
job.CompleteCurrentArtifact(retryAt.AddSeconds(1));
|
||||||
|
job.MarkSucceeded(retryAt.AddSeconds(2));
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Succeeded, job.Status);
|
||||||
|
Assert.Equal(RecordUploadArtifactStage.Completed, job.CurrentArtifact);
|
||||||
|
Assert.Equal(100, job.ProgressPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UploadJob_RetainsExternalTaskAcrossTransientRetry()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.Parse("2026-08-01T10:00:00+08:00");
|
||||||
|
var job = new RecordUploadJob(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"https://openlist.example.com",
|
||||||
|
"/source/video.mp4",
|
||||||
|
"/archive/video.mp4",
|
||||||
|
100,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
deleteLocalFilesAfterUpload: false,
|
||||||
|
now);
|
||||||
|
|
||||||
|
job.BeginAttempt(now.AddSeconds(1));
|
||||||
|
job.TrackExternalTask("copy-task-1", "copy", 25, now.AddSeconds(2));
|
||||||
|
job.StartVerification(now.AddSeconds(3));
|
||||||
|
job.ScheduleRetry(
|
||||||
|
"temporary polling failure",
|
||||||
|
now.AddMinutes(1),
|
||||||
|
now.AddSeconds(4),
|
||||||
|
clearExternalTask: false);
|
||||||
|
job.BeginAttempt(now.AddMinutes(1));
|
||||||
|
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Uploading, job.Status);
|
||||||
|
Assert.Equal(2, job.AttemptCount);
|
||||||
|
Assert.Equal("copy-task-1", job.ExternalTaskId);
|
||||||
|
Assert.Equal("copy", job.ExternalTaskType);
|
||||||
|
Assert.Equal(now.AddSeconds(2), job.ExternalTaskStartedAt);
|
||||||
|
Assert.Equal(now.AddSeconds(3), job.VerificationStartedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Queue_MapsOutputRelativePathAndTreatsMatchingTargetAsIdempotentSuccess()
|
||||||
|
{
|
||||||
|
await using var fixture = await QueueFixture.CreateAsync();
|
||||||
|
var queued = await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||||
|
var job = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
|
||||||
|
|
||||||
|
Assert.True(queued.Success);
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Queued, queued.UploadStatus);
|
||||||
|
Assert.Equal("/source/Douyin/2026/08/01/主播/segment.mp4", job.SourceVideoPath);
|
||||||
|
Assert.Equal("/destination/Douyin/2026/08/01/主播/segment.mp4", job.TargetVideoPath);
|
||||||
|
|
||||||
|
fixture.OpenList.Objects[job.TargetVideoPath] = FileObject("segment.mp4", job.VideoSizeBytes);
|
||||||
|
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||||
|
|
||||||
|
fixture.Context.ChangeTracker.Clear();
|
||||||
|
var result = await fixture.Context.RecordResults.SingleAsync();
|
||||||
|
var completedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Succeeded, result.UploadStatus);
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Succeeded, completedJob.Status);
|
||||||
|
Assert.Equal(job.TargetVideoPath, result.RemoteVideoPath);
|
||||||
|
Assert.Empty(fixture.OpenList.CopyRequests);
|
||||||
|
|
||||||
|
var repeated = await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||||
|
Assert.True(repeated.Success);
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Succeeded, repeated.UploadStatus);
|
||||||
|
Assert.Equal(1, await fixture.Context.RecordUploadJobs.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Queue_RejectsSameNameConflictWithoutOverwriting()
|
||||||
|
{
|
||||||
|
await using var fixture = await QueueFixture.CreateAsync();
|
||||||
|
await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||||
|
var job = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
|
||||||
|
fixture.OpenList.Objects[job.TargetVideoPath] = FileObject("segment.mp4", job.VideoSizeBytes + 1);
|
||||||
|
|
||||||
|
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||||
|
|
||||||
|
fixture.Context.ChangeTracker.Clear();
|
||||||
|
var failedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
|
||||||
|
var result = await fixture.Context.RecordResults.SingleAsync();
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Failed, failedJob.Status);
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Failed, result.UploadStatus);
|
||||||
|
Assert.Contains("内容不一致", failedJob.ErrorMessage);
|
||||||
|
Assert.Empty(fixture.OpenList.CopyRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Queue_QueuesTwoGiBFileUsingServerSideCopy()
|
||||||
|
{
|
||||||
|
const long twoGiB = 2L * 1024 * 1024 * 1024;
|
||||||
|
await using var fixture = await QueueFixture.CreateAsync();
|
||||||
|
var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync();
|
||||||
|
await using (var stream = new FileStream(result.FilePath, FileMode.Open, FileAccess.Write, FileShare.Read))
|
||||||
|
{
|
||||||
|
stream.SetLength(twoGiB);
|
||||||
|
}
|
||||||
|
|
||||||
|
var queued = await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||||
|
var job = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
|
||||||
|
fixture.OpenList.Objects[job.SourceVideoPath] = FileObject("segment.mp4", twoGiB);
|
||||||
|
|
||||||
|
Assert.True(queued.Success);
|
||||||
|
Assert.Equal(twoGiB, job.VideoSizeBytes);
|
||||||
|
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||||
|
Assert.Equal([(job.SourceVideoPath, job.TargetVideoPath)], fixture.OpenList.CopyRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Queue_ResumesPersistedOpenListTaskAfterDbContextRestart()
|
||||||
|
{
|
||||||
|
await using var fixture = await QueueFixture.CreateAsync();
|
||||||
|
await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||||
|
var job = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
|
||||||
|
fixture.OpenList.Objects[job.SourceVideoPath] = FileObject("segment.mp4", job.VideoSizeBytes);
|
||||||
|
fixture.OpenList.NextCopyResult = new OpenListCopyResult(["copy-task-1"]);
|
||||||
|
|
||||||
|
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||||
|
fixture.Context.ChangeTracker.Clear();
|
||||||
|
var runningJob = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
|
||||||
|
Assert.Equal("copy-task-1", runningJob.ExternalTaskId);
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Uploading, runningJob.Status);
|
||||||
|
|
||||||
|
await fixture.ReopenContextAsync();
|
||||||
|
fixture.OpenList.Tasks["copy-task-1"] = new OpenListTaskInfo("copy-task-1", 2, 100, "done", null);
|
||||||
|
fixture.OpenList.Objects[job.TargetVideoPath] = FileObject("segment.mp4", job.VideoSizeBytes);
|
||||||
|
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||||
|
|
||||||
|
fixture.Context.ChangeTracker.Clear();
|
||||||
|
var completedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
|
||||||
|
var result = await fixture.Context.RecordResults.SingleAsync();
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Succeeded, completedJob.Status);
|
||||||
|
Assert.Equal(1, completedJob.AttemptCount);
|
||||||
|
Assert.Equal(RecordArtifactUploadStatus.Succeeded, result.UploadStatus);
|
||||||
|
Assert.Single(fixture.OpenList.CopyRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OpenListClient CreateClient(HttpMessageHandler handler) =>
|
||||||
|
new(new StubHttpClientFactory(handler));
|
||||||
|
|
||||||
|
private static OpenListConnectionRequest Connection() => new()
|
||||||
|
{
|
||||||
|
BaseUrl = "https://openlist.example.com",
|
||||||
|
Username = "tester",
|
||||||
|
Password = "secret"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static OpenListObjectInfo FileObject(string name, long size) =>
|
||||||
|
new(name, size, false, new Dictionary<string, string>());
|
||||||
|
|
||||||
|
private static HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? GetQueryValue(string query, string key)
|
||||||
|
{
|
||||||
|
foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var parts = pair.Split('=', 2);
|
||||||
|
if (Uri.UnescapeDataString(parts[0]) == key)
|
||||||
|
{
|
||||||
|
return parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly HttpMessageHandler _handler;
|
||||||
|
|
||||||
|
public StubHttpClientFactory(HttpMessageHandler handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> _send;
|
||||||
|
|
||||||
|
public StubHttpMessageHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send)
|
||||||
|
{
|
||||||
|
_send = send;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) => _send(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class QueueFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly DbContextOptions<LiveRecorderDbContext> _options;
|
||||||
|
private readonly FixedSettingsService _settingsService;
|
||||||
|
private readonly string _temporaryRoot;
|
||||||
|
|
||||||
|
private QueueFixture(
|
||||||
|
DbContextOptions<LiveRecorderDbContext> options,
|
||||||
|
LiveRecorderDbContext context,
|
||||||
|
FixedSettingsService settingsService,
|
||||||
|
FakeOpenListClient openList,
|
||||||
|
string temporaryRoot,
|
||||||
|
Guid recordTaskId)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
Context = context;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
OpenList = openList;
|
||||||
|
_temporaryRoot = temporaryRoot;
|
||||||
|
RecordTaskId = recordTaskId;
|
||||||
|
Queue = CreateQueue(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LiveRecorderDbContext Context { get; private set; }
|
||||||
|
|
||||||
|
public OpenListUploadQueueService Queue { get; private set; }
|
||||||
|
|
||||||
|
public FakeOpenListClient OpenList { get; }
|
||||||
|
|
||||||
|
public Guid RecordTaskId { get; }
|
||||||
|
|
||||||
|
public static async Task<QueueFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var temporaryRoot = Path.Combine(Path.GetTempPath(), $"live-recorder-openlist-{Guid.NewGuid():N}");
|
||||||
|
var recordDirectory = Path.Combine(temporaryRoot, "Douyin", "2026", "08", "01", "主播");
|
||||||
|
Directory.CreateDirectory(recordDirectory);
|
||||||
|
var videoPath = Path.Combine(recordDirectory, "segment.mp4");
|
||||||
|
await File.WriteAllBytesAsync(videoPath, Encoding.UTF8.GetBytes("video-content"));
|
||||||
|
|
||||||
|
var databaseRoot = new InMemoryDatabaseRoot();
|
||||||
|
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
|
||||||
|
.UseInMemoryDatabase($"openlist-{Guid.NewGuid():N}", databaseRoot)
|
||||||
|
.Options;
|
||||||
|
var context = new LiveRecorderDbContext(options);
|
||||||
|
await context.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var liveRoom = new LiveRoom(
|
||||||
|
LivePlatformType.Douyin,
|
||||||
|
"https://live.example/room",
|
||||||
|
"room-1",
|
||||||
|
"https://live.example/room",
|
||||||
|
now);
|
||||||
|
var session = new RecordSession(
|
||||||
|
liveRoom.Id,
|
||||||
|
"origin",
|
||||||
|
RecordOutputFormat.Mp4,
|
||||||
|
RecordSaveMode.Segmented,
|
||||||
|
now);
|
||||||
|
var task = new RecordTask(
|
||||||
|
liveRoom.Id,
|
||||||
|
session.Id,
|
||||||
|
1,
|
||||||
|
"origin",
|
||||||
|
RecordOutputFormat.Mp4,
|
||||||
|
now);
|
||||||
|
var result = new RecordResult(
|
||||||
|
task.Id,
|
||||||
|
videoPath,
|
||||||
|
new FileInfo(videoPath).Length,
|
||||||
|
60,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
RecordTaskStatus.Completed,
|
||||||
|
null,
|
||||||
|
now);
|
||||||
|
context.AddRange(liveRoom, session, task, result);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var settings = new SystemSettingsDto
|
||||||
|
{
|
||||||
|
OutputRoot = temporaryRoot,
|
||||||
|
EnableFileUpload = true,
|
||||||
|
EnableAutoUpload = true,
|
||||||
|
UploadTarget = UploadTargetType.OpenList,
|
||||||
|
OpenListUpload = new OpenListUploadSettingsDto
|
||||||
|
{
|
||||||
|
BaseUrl = "https://openlist.example.com",
|
||||||
|
Username = "tester",
|
||||||
|
Password = "secret",
|
||||||
|
SourcePath = "/source",
|
||||||
|
DestinationPath = "/destination"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var settingsService = new FixedSettingsService(settings);
|
||||||
|
var openList = new FakeOpenListClient();
|
||||||
|
return new QueueFixture(
|
||||||
|
options,
|
||||||
|
context,
|
||||||
|
settingsService,
|
||||||
|
openList,
|
||||||
|
temporaryRoot,
|
||||||
|
task.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReopenContextAsync()
|
||||||
|
{
|
||||||
|
await Context.DisposeAsync();
|
||||||
|
Context = new LiveRecorderDbContext(_options);
|
||||||
|
Queue = CreateQueue(Context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Context.DisposeAsync();
|
||||||
|
if (Directory.Exists(_temporaryRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(_temporaryRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OpenListUploadQueueService CreateQueue(LiveRecorderDbContext context) =>
|
||||||
|
new(context, _settingsService, OpenList, new NullSystemLogService());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedSettingsService : ISystemSettingsService
|
||||||
|
{
|
||||||
|
private readonly SystemSettingsDto _settings;
|
||||||
|
|
||||||
|
public FixedSettingsService(SystemSettingsDto settings)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SystemSettingsDto> GetAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(_settings);
|
||||||
|
|
||||||
|
public Task<SystemSettingsDto> UpdateAsync(
|
||||||
|
UpdateSystemSettingsRequest request,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullSystemLogService : ISystemLogService
|
||||||
|
{
|
||||||
|
public Task WriteAsync(
|
||||||
|
SystemLogLevel level,
|
||||||
|
string category,
|
||||||
|
string message,
|
||||||
|
string? detail = null,
|
||||||
|
Guid? liveRoomId = null,
|
||||||
|
Guid? recordSessionId = null,
|
||||||
|
Guid? recordTaskId = null,
|
||||||
|
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SystemLogDto>> ListAsync(
|
||||||
|
Guid? liveRoomId = null,
|
||||||
|
Guid? recordSessionId = null,
|
||||||
|
Guid? recordTaskId = null,
|
||||||
|
SystemLogLevel? level = null,
|
||||||
|
string? content = null,
|
||||||
|
int take = 200,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<IReadOnlyList<SystemLogDto>>([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeOpenListClient : IOpenListClient
|
||||||
|
{
|
||||||
|
public Dictionary<string, OpenListObjectInfo> Objects { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public Dictionary<string, OpenListTaskInfo> Tasks { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public List<(string Source, string Target)> CopyRequests { get; } = [];
|
||||||
|
|
||||||
|
public OpenListCopyResult NextCopyResult { get; set; } = new([]);
|
||||||
|
|
||||||
|
public Task<OpenListConnectionTestDto> TestConnectionAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<OpenListDirectoryListDto> ListDirectoriesAsync(
|
||||||
|
OpenListDirectoryRequest request,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task EnsureDirectoryAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<OpenListObjectInfo?> TryGetObjectAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(Objects.GetValueOrDefault(path));
|
||||||
|
|
||||||
|
public Task<OpenListCopyResult> CopyFileAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string sourcePath,
|
||||||
|
string targetPath,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
CopyRequests.Add((sourcePath, targetPath));
|
||||||
|
return Task.FromResult(NextCopyResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OpenListTaskInfo?> TryGetCopyTaskAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string taskId,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(Tasks.GetValueOrDefault(taskId));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user