feat: improve recording recovery and upload workflow
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|apply_patch",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\" ] || node \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\" ] || node \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Design deep pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,7 @@ docker compose up -d
|
||||
```bash
|
||||
./scripts/build-fnos-package.sh
|
||||
./scripts/smoke-fnos-package.sh \
|
||||
artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \
|
||||
artifacts/fnos/liverecorder-1.2.19-x86_64.fpk \
|
||||
/path/to/nxsir-postgresql-15.1.1-x86_64.fpk
|
||||
```
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ PostgreSQL 共享服务由独立仓库构建和发布:<https://gitea.nxsir.cn/
|
||||
|
||||
```bash
|
||||
./scripts/smoke-fnos-package.sh \
|
||||
artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \
|
||||
artifacts/fnos/liverecorder-1.2.19-x86_64.fpk \
|
||||
/path/to/nxsir-postgresql-15.1.1-x86_64.fpk
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appname=liverecorder
|
||||
version=1.2.13
|
||||
version=1.2.19
|
||||
display_name=Live Recorder
|
||||
desc=原生直播录制系统,使用独立 PostgreSQL 共享服务,支持分片录制、弹幕采集与 OpenList 自动上传
|
||||
platform=x86
|
||||
|
||||
@@ -1,130 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBackendStatus } from "@/composables/useBackendStatus";
|
||||
import { useUiPreferences } from "@/composables/useUiPreferences";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import {
|
||||
Bell,
|
||||
Collection,
|
||||
DataAnalysis,
|
||||
Document,
|
||||
FolderOpened,
|
||||
Fold,
|
||||
House,
|
||||
Menu,
|
||||
Moon,
|
||||
RefreshRight,
|
||||
Setting,
|
||||
Sunny,
|
||||
Tickets,
|
||||
Upload,
|
||||
VideoCamera
|
||||
} from "@element-plus/icons-vue";
|
||||
import { Bell, Collection, DataAnalysis, Menu, Moon, Setting, Sunny, Tickets, VideoCamera } from "@element-plus/icons-vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const { isMobile } = useViewport();
|
||||
const { backendUnavailable, backendMessage, backendLastChangedAt } = useBackendStatus();
|
||||
const { resolvedTheme, sidebarCollapsed, cycleThemeMode, toggleSidebarCollapsed } = useUiPreferences();
|
||||
|
||||
const mobileNavVisible = ref(false);
|
||||
|
||||
const navigationGroups = [
|
||||
{
|
||||
key: "overview",
|
||||
title: "总览",
|
||||
key: "workspace",
|
||||
title: "工作区",
|
||||
items: [
|
||||
{ index: "/", label: "仪表盘", icon: DataAnalysis },
|
||||
{ index: "/", label: "运行中心", shortLabel: "运行", icon: DataAnalysis, names: ["dashboard"] },
|
||||
{ index: "/live-rooms", label: "直播与录制", shortLabel: "录制", icon: VideoCamera, names: ["live-rooms", "record-tasks", "record-task-detail", "record-session-detail", "recovery"] },
|
||||
{ index: "/transcode-tasks", label: "处理与归档", shortLabel: "归档", icon: Collection, names: ["transcode-tasks", "upload-tasks", "media-browser"] },
|
||||
{ index: "/daily-reviews", label: "分析与排障", shortLabel: "分析", icon: Tickets, names: ["daily-reviews", "logs"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "monitor",
|
||||
title: "监控录制",
|
||||
key: "manage",
|
||||
title: "管理",
|
||||
items: [
|
||||
{ index: "/live-rooms", label: "直播间", icon: House },
|
||||
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
|
||||
{ index: "/recovery", label: "恢复中心", icon: RefreshRight }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "media",
|
||||
title: "媒资归档",
|
||||
items: [
|
||||
{ index: "/transcode-tasks", label: "转码任务", icon: Collection },
|
||||
{ index: "/upload-tasks", label: "上传任务", icon: Upload },
|
||||
{ index: "/media-browser", label: "文件库", icon: FolderOpened }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "analysis",
|
||||
title: "分析",
|
||||
items: [
|
||||
{ index: "/daily-reviews", label: "回顾日报", icon: Document },
|
||||
{ index: "/logs", label: "系统日志", icon: Tickets }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
title: "系统",
|
||||
items: [
|
||||
{ index: "/settings", label: "系统设置", icon: Setting }
|
||||
{ index: "/settings", label: "系统设置", shortLabel: "设置", icon: Setting, names: ["settings"] }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const bottomNavigation = computed(() => navigationGroups.flatMap((group) => group.items));
|
||||
|
||||
const workspaceTabs = computed(() => {
|
||||
const name = String(route.name ?? "");
|
||||
if (["live-rooms", "record-tasks", "record-task-detail", "record-session-detail", "recovery"].includes(name)) {
|
||||
return [
|
||||
{ name: "live-rooms", label: "直播间" },
|
||||
{ name: "record-tasks", label: "录制会话" },
|
||||
{ name: "recovery", label: "恢复中心" }
|
||||
];
|
||||
}
|
||||
if (["transcode-tasks", "upload-tasks", "media-browser"].includes(name)) {
|
||||
return [
|
||||
{ name: "transcode-tasks", label: "转码队列" },
|
||||
{ name: "upload-tasks", label: "上传队列" },
|
||||
{ name: "media-browser", label: "文件库" }
|
||||
];
|
||||
}
|
||||
if (["daily-reviews", "logs"].includes(name)) {
|
||||
return [
|
||||
{ name: "daily-reviews", label: "回顾日报" },
|
||||
{ name: "logs", label: "系统日志" }
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const userDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员");
|
||||
const userAvatarText = computed(() => userDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
|
||||
|
||||
function isNavItemActive(index: string) {
|
||||
return route.path === index || route.path.startsWith(`${index}/`);
|
||||
function isNavItemActive(item: { names: string[] }) {
|
||||
return item.names.includes(String(route.name ?? ""));
|
||||
}
|
||||
function navigate(index: string) { void router.push(index); }
|
||||
|
||||
function isWorkspaceTabActive(name: string) {
|
||||
const current = String(route.name ?? "");
|
||||
if (name === "record-tasks") return ["record-tasks", "record-task-detail", "record-session-detail"].includes(current);
|
||||
return current === name;
|
||||
}
|
||||
|
||||
const pageBreadcrumb = computed(() => {
|
||||
const m: Record<string, string> = {
|
||||
dashboard: "总览 / 仪表盘",
|
||||
"live-rooms": "监控录制 / 直播间",
|
||||
"record-tasks": "监控录制 / 录制任务",
|
||||
"transcode-tasks": "媒资归档 / 转码任务",
|
||||
"upload-tasks": "媒资归档 / 上传任务",
|
||||
"media-browser": "媒资归档 / 文件库",
|
||||
"record-task-detail": "监控录制 / 录制任务 / 分片详情",
|
||||
"record-session-detail": "监控录制 / 录制任务 / 会话详情",
|
||||
"daily-reviews": "分析 / 回顾日报",
|
||||
logs: "分析 / 系统日志",
|
||||
recovery: "监控录制 / 恢复中心",
|
||||
dashboard: "工作台 / 运行中心",
|
||||
"live-rooms": "直播与录制 / 直播间",
|
||||
"record-tasks": "直播与录制 / 录制会话",
|
||||
"transcode-tasks": "处理与归档 / 转码队列",
|
||||
"upload-tasks": "处理与归档 / 上传队列",
|
||||
"media-browser": "处理与归档 / 文件库",
|
||||
"record-task-detail": "直播与录制 / 分片详情",
|
||||
"record-session-detail": "直播与录制 / 会话详情",
|
||||
"daily-reviews": "分析与排障 / 回顾日报",
|
||||
logs: "分析与排障 / 系统日志",
|
||||
recovery: "直播与录制 / 恢复中心",
|
||||
settings: "系统 / 系统设置"
|
||||
};
|
||||
return m[String(route.name)] || "控制台";
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
mobileNavVisible.value = false;
|
||||
await authStore.logout();
|
||||
await router.push({ name: "login" });
|
||||
}
|
||||
|
||||
function openMobileNav() { mobileNavVisible.value = true; }
|
||||
function closeMobileNav() { mobileNavVisible.value = false; }
|
||||
|
||||
watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<!-- mobile scrim -->
|
||||
<div
|
||||
class="scrim"
|
||||
:class="{ open: mobileNavVisible }"
|
||||
@click="closeMobileNav"
|
||||
/>
|
||||
|
||||
<!-- sidebar -->
|
||||
<aside class="app-sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileNavVisible }">
|
||||
<aside class="app-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="app-brand">
|
||||
<div class="app-brand__mark">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
@@ -151,7 +128,7 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
<button
|
||||
type="button"
|
||||
class="app-nav-icon-item"
|
||||
:class="{ 'is-active': isNavItemActive(item.index) }"
|
||||
:class="{ 'is-active': isNavItemActive(item) }"
|
||||
@click="navigate(item.index)"
|
||||
>
|
||||
<el-icon :size="18"><component :is="item.icon" /></el-icon>
|
||||
@@ -163,7 +140,7 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
v-for="item in group.items" :key="item.index"
|
||||
type="button"
|
||||
class="app-nav-item"
|
||||
:class="{ 'is-active': isNavItemActive(item.index) }"
|
||||
:class="{ 'is-active': isNavItemActive(item) }"
|
||||
@click="navigate(item.index)"
|
||||
>
|
||||
<el-icon :size="18"><component :is="item.icon" /></el-icon>
|
||||
@@ -186,9 +163,10 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
<!-- main -->
|
||||
<div class="app-main-col">
|
||||
<header class="app-topbar">
|
||||
<button class="app-topbar__menu-btn" @click="isMobile ? openMobileNav() : toggleSidebarCollapsed()">
|
||||
<button class="app-topbar__menu-btn" @click="toggleSidebarCollapsed">
|
||||
<el-icon :size="20"><Menu /></el-icon>
|
||||
</button>
|
||||
<div class="app-mobile-brand" aria-label="Live Recorder">LR</div>
|
||||
<div class="app-breadcrumb">{{ pageBreadcrumb }}</div>
|
||||
|
||||
<div class="app-topbar__spacer" />
|
||||
@@ -201,8 +179,18 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
</button>
|
||||
|
||||
<el-dropdown trigger="click">
|
||||
<button type="button" class="app-user-btn">
|
||||
<span class="app-user-btn__avatar">{{ userAvatarText }}</span>
|
||||
<button type="button" class="app-user-btn" aria-label="管理员菜单" title="管理员菜单">
|
||||
<svg
|
||||
class="app-user-btn__avatar"
|
||||
viewBox="0 0 36 36"
|
||||
width="36"
|
||||
height="36"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle class="app-user-btn__avatar-circle" cx="18" cy="18" r="17.5" />
|
||||
<text class="app-user-btn__avatar-text" x="18" y="18">{{ userAvatarText }}</text>
|
||||
</svg>
|
||||
<span class="app-user-btn__name desktop-only">{{ userDisplayName }}</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
@@ -215,14 +203,49 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<nav v-if="workspaceTabs.length" class="workspace-tabs" aria-label="工作区页面切换">
|
||||
<button
|
||||
v-for="tab in workspaceTabs"
|
||||
:key="tab.name"
|
||||
type="button"
|
||||
:class="{ 'is-active': isWorkspaceTabActive(tab.name) }"
|
||||
@click="router.push({ name: tab.name })"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<el-alert
|
||||
v-if="backendUnavailable && route.name !== 'record-tasks'"
|
||||
class="backend-alert"
|
||||
type="error" :closable="false" show-icon
|
||||
title="后端服务暂时不可用" :description="backendMessage"
|
||||
/>
|
||||
<router-view />
|
||||
<router-view v-slot="{ Component, route: viewRoute }">
|
||||
<transition name="page-swap" mode="out-in">
|
||||
<component
|
||||
:is="Component"
|
||||
:key="String(viewRoute.name ?? viewRoute.path)"
|
||||
class="app-route-view"
|
||||
/>
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
<nav class="app-bottom-nav" aria-label="移动端主导航">
|
||||
<button
|
||||
v-for="item in bottomNavigation"
|
||||
:key="item.index"
|
||||
type="button"
|
||||
:class="{ 'is-active': isNavItemActive(item) }"
|
||||
:aria-current="isNavItemActive(item) ? 'page' : undefined"
|
||||
@click="navigate(item.index)"
|
||||
>
|
||||
<span class="app-bottom-nav__icon" aria-hidden="true">
|
||||
<el-icon :size="19"><component :is="item.icon" /></el-icon>
|
||||
</span>
|
||||
<span class="app-bottom-nav__label">{{ item.shortLabel }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -230,9 +253,6 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
<style scoped>
|
||||
/* ==================== Shell ==================== */
|
||||
.app-shell { display: flex; width: 100%; height: 100dvh; min-height: 0; overflow: hidden; }
|
||||
.scrim { position: fixed; inset: 0; background: rgba(15,23,42,.5); z-index: 39; opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||||
.scrim.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* ==================== Sidebar ==================== */
|
||||
.app-sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; z-index: 30;
|
||||
@@ -242,45 +262,44 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; });
|
||||
}
|
||||
.app-sidebar.collapsed { width: var(--sidebar-w-collapsed); }
|
||||
|
||||
.app-brand { display: flex; align-items: center; gap: 11px; height: var(--topbar-h); padding: 0 18px; flex-shrink: 0; border-bottom: 1px solid var(--side-border); }
|
||||
.app-brand__mark { width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px; display: grid; place-items: center; color: #fff; background: var(--accent); box-shadow: 0 2px 8px rgba(26,86,219,.25); }
|
||||
html[data-theme="dark"] .app-brand__mark { background: #1a2438; box-shadow: 0 2px 8px rgba(0,0,0,.5); }
|
||||
.app-brand__name { font-weight: 800; font-size: 16px; letter-spacing: -.02em; color: var(--text-primary); white-space: nowrap; }
|
||||
html[data-theme="dark"] .app-brand__name { color: #eaf0fb; }
|
||||
.app-brand__sub { font-size: 11px; color: var(--side-title); white-space: nowrap; margin-top: 1px; }
|
||||
.app-brand { display: flex; align-items: center; gap: 11px; height: var(--topbar-h); padding: 0 17px; flex-shrink: 0; border-bottom: 1px solid var(--side-border); }
|
||||
.app-brand__mark { width: 34px; height: 34px; flex-shrink: 0; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--action); box-shadow: 0 8px 22px color-mix(in srgb, var(--action) 26%, transparent); }
|
||||
html[data-theme="dark"] .app-brand__mark { background: var(--action); box-shadow: 0 8px 22px rgba(0,0,0,.28); }
|
||||
.app-brand__name { font-weight: 780; font-size: 14px; letter-spacing: -.01em; color: var(--text-primary); white-space: nowrap; }
|
||||
.app-brand__sub { font-size: 10px; color: var(--side-title); white-space: nowrap; margin-top: 1px; }
|
||||
.collapsed .app-brand__copy { display: none; }
|
||||
|
||||
.app-nav { flex: 1; overflow-y: auto; padding: 14px 12px 8px; }
|
||||
.app-nav-group { margin-bottom: 16px; }
|
||||
.app-nav-group__title { padding: 6px 10px; font-size: 11px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: var(--side-title); }
|
||||
.app-nav { flex: 1; overflow-y: auto; padding: 14px 10px 8px; }
|
||||
.app-nav-group { margin-bottom: 10px; }
|
||||
.app-nav-group__title { padding: 8px 10px 7px; font-size: 10px; font-weight: 750; letter-spacing: .12em; color: var(--side-title); }
|
||||
.collapsed .app-nav-group__title { font-size: 0; padding: 6px 0; text-align: center; }
|
||||
.collapsed .app-nav-group__title::after { content: "•"; font-size: 13px; }
|
||||
|
||||
.app-nav-item {
|
||||
display: flex; align-items: center; gap: 11px; width: 100%;
|
||||
padding: 9px 11px; margin-bottom: 2px; border: 0; border-radius: 6px;
|
||||
background: transparent; color: var(--side-item); font-weight: 600; font-size: 14px;
|
||||
min-height: 43px; padding: 0 11px; margin-bottom: 4px; border: 0; border-radius: 9px;
|
||||
background: transparent; color: var(--side-item); font-weight: 650; font-size: 13px;
|
||||
text-align: left; cursor: pointer; transition: background .15s, color .15s;
|
||||
}
|
||||
.app-nav-item:hover { background: var(--side-item-hover-bg); color: var(--side-item-hover); }
|
||||
.app-nav-item.is-active { background: var(--side-active-bg); color: var(--side-active); }
|
||||
.app-nav-item.is-active { background: var(--side-active-bg); color: var(--side-active); box-shadow: inset 1px 0 var(--side-active); }
|
||||
|
||||
.app-nav-icon-list { display: grid; gap: 8px; }
|
||||
.app-nav-icon-item {
|
||||
display: grid; place-items: center; min-height: 46px; border: 0; border-radius: 6px;
|
||||
display: grid; place-items: center; min-height: 43px; border: 0; border-radius: 9px;
|
||||
background: transparent; color: var(--side-item); cursor: pointer;
|
||||
}
|
||||
.app-nav-icon-item:hover { background: var(--side-item-hover-bg); color: var(--side-item-hover); }
|
||||
.app-nav-icon-item.is-active { background: var(--side-active-bg); color: var(--side-active); }
|
||||
.app-nav-icon-item.is-active { background: var(--side-active-bg); color: var(--side-active); box-shadow: inset 1px 0 var(--side-active); }
|
||||
|
||||
.app-sidebar__foot { padding: 12px; border-top: 1px solid var(--side-border); }
|
||||
.app-health { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: rgba(22,163,74,.10); border: 1px solid rgba(22,163,74,.20); }
|
||||
.app-health { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 10px; border-radius: 10px; background: var(--success-soft); border: 1px solid color-mix(in srgb, var(--success) 22%, var(--side-border)); }
|
||||
html[data-theme="dark"] .app-health { background: rgba(52,211,153,.12); border-color: rgba(52,211,153,.22); }
|
||||
.app-health.is-danger { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
|
||||
.app-health__dot { width: 8px; height: 8px; border-radius: 99px; background: #16a34a; box-shadow: 0 0 0 3px rgba(22,163,74,.18); }
|
||||
html[data-theme="dark"] .app-health__dot { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,.22); }
|
||||
.app-health.is-danger .app-health__dot { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
|
||||
.app-health__txt { font-size: 12.5px; font-weight: 600; color: #15803d; }
|
||||
.app-health__txt { font-size: 11px; font-weight: 700; color: var(--success); }
|
||||
html[data-theme="dark"] .app-health__txt { color: #6ee7b7; }
|
||||
.app-health.is-danger .app-health__txt { color: var(--danger); }
|
||||
.collapsed .app-health__txt { display: none; }
|
||||
@@ -298,41 +317,78 @@ html[data-theme="dark"] .app-health__txt { color: #6ee7b7; }
|
||||
.app-topbar {
|
||||
position: relative; z-index: 20; flex: 0 0 var(--topbar-h);
|
||||
height: var(--topbar-h); display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 24px;
|
||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(15,23,42,.07);
|
||||
padding: 0 22px;
|
||||
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
html[data-theme="dark"] .app-topbar { border-bottom-color: rgba(255,255,255,.06); }
|
||||
.app-topbar__menu-btn { width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; }
|
||||
.app-topbar__menu-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.app-mobile-brand { display: none; width: 27px; height: 27px; place-items: center; border-radius: 8px; color: #fff; background: var(--action); font-size: 10px; font-weight: 800; }
|
||||
.app-breadcrumb { font-size: 13.5px; color: var(--text-muted); min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.app-topbar__spacer { flex: 1; }
|
||||
.app-topbar__icon-btn { width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; }
|
||||
.app-topbar__icon-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.app-user-btn { display: flex; align-items: center; gap: 9px; padding: 4px 10px 4px 4px; border: 1px solid var(--border-subtle); border-radius: 99px; background: var(--surface); cursor: pointer; }
|
||||
.app-user-btn:hover { background: var(--surface-hover); }
|
||||
.app-user-btn__avatar { width: 30px; height: 30px; flex-shrink: 0; border-radius: 99px; display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 13px; background: var(--accent); }
|
||||
.app-user-btn__avatar { display: block; width: 30px; min-width: 30px; height: 30px; min-height: 30px; flex: 0 0 30px; overflow: visible; }
|
||||
.app-user-btn__avatar-circle { fill: var(--purple); stroke: color-mix(in srgb, var(--purple) 76%, var(--border-subtle)); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.app-user-btn__avatar-text { fill: #fff; font-family: var(--font-sans); font-size: 13px; font-weight: 750; text-anchor: middle; dominant-baseline: central; }
|
||||
.app-user-btn__name { font-size: 13px; font-weight: 700; }
|
||||
|
||||
/* ==================== Main ==================== */
|
||||
.app-main {
|
||||
flex: 1; min-width: 0; min-height: 0; padding: 24px 28px 0;
|
||||
overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable;
|
||||
background:
|
||||
radial-gradient(ellipse at 88% -12%, var(--ambient-blue), transparent 34%),
|
||||
radial-gradient(ellipse at 98% 8%, var(--ambient-violet), transparent 28%),
|
||||
radial-gradient(ellipse at 76% -18%, var(--ambient-warm), transparent 25%),
|
||||
var(--bg-base);
|
||||
}
|
||||
.backend-alert { width: min(100%, var(--page-max)); margin: 0 auto 18px; border-radius: var(--radius-md); }
|
||||
.workspace-tabs { display: flex; align-items: center; gap: 4px; width: min(100%, var(--page-max)); margin: 0 auto 16px; padding: 4px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); }
|
||||
.workspace-tabs button { min-height: 33px; padding: 0 13px; border: 0; border-radius: 7px; color: var(--text-muted); background: transparent; font-size: 11px; font-weight: 700; }
|
||||
.workspace-tabs button:hover { color: var(--text-primary); background: var(--surface-muted); }
|
||||
.workspace-tabs button.is-active { color: var(--accent); background: var(--accent-soft); }
|
||||
.app-bottom-nav { display: none; height: calc(65px + env(safe-area-inset-bottom)); flex: 0 0 calc(65px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); border-top: 1px solid var(--border-subtle); background: var(--surface); }
|
||||
.app-bottom-nav button { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; padding: 4px 0 3px; border: 0; color: var(--text-muted); background: transparent; font-size: 9px; transition: color .2s ease; }
|
||||
.app-bottom-nav__icon { display: grid; width: 44px; height: 28px; flex: 0 0 28px; place-items: center; border-radius: 99px; background: transparent; transform: scale(.84); transition: background-color .18s ease, transform .24s cubic-bezier(.2,.8,.2,1); }
|
||||
.app-bottom-nav__icon :deep(.el-icon) { transition: transform .24s cubic-bezier(.2,.8,.2,1); }
|
||||
.app-bottom-nav__label { max-width: 100%; overflow: hidden; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; transition: color .2s ease, font-weight .2s ease; }
|
||||
.app-bottom-nav button.is-active { color: var(--accent); }
|
||||
.app-bottom-nav button.is-active .app-bottom-nav__icon { background: var(--accent-soft); transform: scale(1); }
|
||||
.app-bottom-nav button.is-active .app-bottom-nav__icon :deep(.el-icon) { transform: scale(1.05); }
|
||||
.app-bottom-nav button.is-active .app-bottom-nav__label { font-weight: 750; }
|
||||
.app-bottom-nav button:active .app-bottom-nav__icon { transform: scale(.9); transition-duration: .08s; }
|
||||
|
||||
.page-swap-enter-active { transition: opacity .2s ease, transform .24s cubic-bezier(.2,.8,.2,1); }
|
||||
.page-swap-leave-active { transition: opacity .12s ease, transform .12s ease; }
|
||||
.page-swap-enter-from { opacity: 0; transform: translateY(7px); }
|
||||
.page-swap-leave-to { opacity: 0; transform: translateY(-3px); }
|
||||
|
||||
/* ==================== Responsive ==================== */
|
||||
@media (max-width: 768px) {
|
||||
.app-sidebar { transform: translateX(-100%); transition: transform .24s cubic-bezier(.4,0,.2,1); }
|
||||
.app-sidebar.mobile-open { transform: translateX(0); width: 280px; z-index: 50; }
|
||||
.app-sidebar:not(.mobile-open) .app-brand { display: none; }
|
||||
.app-sidebar:not(.mobile-open) .app-sidebar__foot { display: none; }
|
||||
.app-sidebar { display: none; }
|
||||
.app-main-col { margin-left: 0 !important; }
|
||||
.app-main { padding: 12px 12px 0; }
|
||||
.app-main {
|
||||
padding: 12px 12px 0;
|
||||
background:
|
||||
radial-gradient(240px 170px at 108% -5%, color-mix(in srgb, var(--ambient-blue) 58%, transparent), transparent 74%),
|
||||
radial-gradient(180px 130px at 96% -12%, color-mix(in srgb, var(--ambient-violet) 46%, transparent), transparent 76%),
|
||||
var(--bg-base);
|
||||
}
|
||||
.app-topbar { padding: 0 10px; gap: 6px; }
|
||||
.app-topbar__menu-btn { display: grid; }
|
||||
.app-topbar__menu-btn { display: none; }
|
||||
.app-mobile-brand { display: grid; }
|
||||
.app-breadcrumb { font-size: 11.5px; max-width: 130px; }
|
||||
.app-topbar :deep(.el-dropdown) { display: block; width: 36px; min-width: 36px; height: 36px; flex: 0 0 36px; line-height: 0; }
|
||||
.app-user-btn { display: grid; width: 36px; min-width: 36px; height: 36px; min-height: 36px; padding: 0; flex: 0 0 36px; place-items: center; border: 0; border-radius: 50%; background: transparent; box-shadow: none; }
|
||||
.app-user-btn:hover { background: transparent; }
|
||||
.app-user-btn__avatar { width: 36px; min-width: 36px; height: 36px; min-height: 36px; flex-basis: 36px; filter: drop-shadow(0 3px 5px color-mix(in srgb, var(--purple) 24%, transparent)); }
|
||||
.app-user-btn__name { display: none; }
|
||||
.workspace-tabs { width: 100%; overflow-x: auto; margin-bottom: 12px; }
|
||||
.workspace-tabs button { flex: 1; min-width: max-content; }
|
||||
.app-bottom-nav { display: flex; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name?: string | null;
|
||||
showLabel?: boolean;
|
||||
}>(), {
|
||||
name: "",
|
||||
showLabel: true
|
||||
});
|
||||
|
||||
const label = computed(() => props.name?.trim() || "未知平台");
|
||||
const kind = computed(() => {
|
||||
const value = label.value.toLowerCase();
|
||||
if (value.includes("哔哩") || value.includes("bili")) return "bilibili";
|
||||
if (value.includes("抖音") || value.includes("douyin") || value.includes("tiktok")) return "douyin";
|
||||
if (value.includes("斗鱼") || value.includes("douyu")) return "douyu";
|
||||
return "generic";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="platform-mark" :class="`platform-mark--${kind}`" :title="label">
|
||||
<svg v-if="kind === 'bilibili'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m8 3 3 3M16 3l-3 3" />
|
||||
<rect x="3" y="6" width="18" height="14" rx="4" />
|
||||
<path d="M8.5 11.5v2.5M15.5 11.5v2.5M9 17c1.8 1.1 4.2 1.1 6 0" />
|
||||
</svg>
|
||||
<svg v-else-if="kind === 'douyin'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14 4v10.2a4 4 0 1 1-3-3.86V7.1c2.25 1.9 4.35 2.9 7 2.9V7.1c-1.8-.1-3.15-1.05-4-3.1Z" />
|
||||
</svg>
|
||||
<svg v-else-if="kind === 'douyu'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 12c3.8-5.4 9.5-6.9 16-4.1l2-2v7.8l-2-1.6C14.1 17.6 7.8 17.8 3 12Z" />
|
||||
<circle cx="15.8" cy="9.5" r="1" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M3.5 12h17M12 3.5c2.4 2.4 3.6 5.2 3.6 8.5S14.4 18.1 12 20.5C9.6 18.1 8.4 15.3 8.4 12S9.6 5.9 12 3.5Z" />
|
||||
</svg>
|
||||
<span v-if="showLabel">{{ label }}</span>
|
||||
<span v-else class="sr-only">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-mark { display: inline-flex; align-items: center; gap: 5px; min-width: 0; color: var(--text-muted); font-size: inherit; line-height: 1.2; white-space: nowrap; }
|
||||
.platform-mark svg { width: 17px; height: 17px; flex: 0 0 auto; padding: 2px; border-radius: 4px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.platform-mark--bilibili svg { color: #c93e6d; background: #ffedf3; }
|
||||
.platform-mark--douyin svg { color: #141923; background: #edf0f5; }
|
||||
.platform-mark--douyu svg { color: #b94c00; background: #fff0e4; }
|
||||
.platform-mark--generic svg { color: var(--accent); background: var(--accent-soft); }
|
||||
:global(html[data-theme="dark"]) .platform-mark--bilibili svg { color: #ff92b4; background: rgba(217, 72, 117, .20); }
|
||||
:global(html[data-theme="dark"]) .platform-mark--douyin svg { color: #f7f9fc; background: rgba(236, 241, 248, .14); }
|
||||
:global(html[data-theme="dark"]) .platform-mark--douyu svg { color: #ff9b57; background: rgba(218, 92, 8, .20); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
||||
</style>
|
||||
@@ -51,8 +51,14 @@ const visible = computed({
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.right-drawer .el-drawer__body) {
|
||||
:global(.right-drawer) {
|
||||
max-width: 100vw;
|
||||
max-width: 100dvw;
|
||||
}
|
||||
|
||||
:global(.right-drawer .el-drawer__body) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -76,6 +82,10 @@ const visible = computed({
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.right-drawer__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.right-drawer__eyebrow {
|
||||
margin-bottom: 8px;
|
||||
color: var(--accent);
|
||||
@@ -111,6 +121,8 @@ const visible = computed({
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.right-drawer__footer {
|
||||
@@ -122,4 +134,42 @@ const visible = computed({
|
||||
flex: 0 0 auto;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.right-drawer__header {
|
||||
gap: 10px;
|
||||
padding: 18px 16px 14px;
|
||||
}
|
||||
|
||||
.right-drawer__eyebrow {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.right-drawer__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.right-drawer__subtitle {
|
||||
margin-top: 5px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.right-drawer__body {
|
||||
padding: 16px;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.right-drawer__footer {
|
||||
gap: 8px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
:global(.right-drawer__footer .el-button) {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
margin: 0;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,6 +43,9 @@ const barColor = computed(() => {
|
||||
if (tier.value === "yellow") return "var(--warning)";
|
||||
return "var(--danger)";
|
||||
});
|
||||
const ringStyle = computed(() => ({
|
||||
background: `conic-gradient(${barColor.value} ${percentage.value}%, var(--surface-strong) 0)`
|
||||
}));
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "--";
|
||||
@@ -59,58 +62,58 @@ function formatBytes(bytes: number) {
|
||||
|
||||
<template>
|
||||
<section class="storage-capacity" data-testid="storage-capacity">
|
||||
<div class="storage-capacity__header">
|
||||
<div>
|
||||
<div class="storage-capacity__eyebrow">录制存储</div>
|
||||
<div class="storage-capacity__headline">
|
||||
<strong>{{ status.isAvailable ? `${percentage.toFixed(1)}%` : "--" }}</strong>
|
||||
<span>已使用</span>
|
||||
</div>
|
||||
<div
|
||||
class="storage-capacity__ring"
|
||||
:style="ringStyle"
|
||||
role="img"
|
||||
:aria-label="`存储已使用 ${percentage.toFixed(1)}%`"
|
||||
>
|
||||
<div class="storage-capacity__ring-inner">
|
||||
<strong>{{ status.isAvailable ? `${percentage.toFixed(1)}%` : "--" }}</strong>
|
||||
<span>已使用</span>
|
||||
</div>
|
||||
<el-tag :type="tagType" effect="light">{{ statusLabel }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:stroke-width="12"
|
||||
:show-text="false"
|
||||
:color="barColor"
|
||||
:aria-label="`存储已使用 ${percentage.toFixed(1)}%`"
|
||||
/>
|
||||
<div class="storage-capacity__detail">
|
||||
<div class="storage-capacity__summary">
|
||||
<strong>{{ status.isAvailable ? `${formatBytes(status.usedBytes)} / ${formatBytes(status.totalBytes)}` : "容量不可用" }}</strong>
|
||||
<el-tooltip v-if="status.message" :content="status.message" placement="top">
|
||||
<el-tag :type="tagType" effect="light" tabindex="0">{{ statusLabel }}</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag v-else :type="tagType" effect="light">{{ statusLabel }}</el-tag>
|
||||
</div>
|
||||
<p class="storage-capacity__path" :title="status.checkedPath">{{ status.checkedPath || "未配置输出路径" }}</p>
|
||||
|
||||
<dl class="storage-capacity__metrics">
|
||||
<div><dt>已使用</dt><dd>{{ status.isAvailable ? formatBytes(status.usedBytes) : "--" }}</dd></div>
|
||||
<div><dt>可用</dt><dd>{{ status.isAvailable ? formatBytes(status.availableBytes) : "--" }}</dd></div>
|
||||
<div><dt>总容量</dt><dd>{{ status.isAvailable ? formatBytes(status.totalBytes) : "--" }}</dd></div>
|
||||
<div><dt>剩余比例</dt><dd>{{ status.isAvailable ? `${status.freePercent.toFixed(1)}%` : "--" }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="storage-capacity__foot">
|
||||
<span class="storage-capacity__path" :title="status.checkedPath">{{ status.checkedPath || "未配置输出路径" }}</span>
|
||||
<el-tooltip v-if="status.message" :content="status.message" placement="top">
|
||||
<span class="storage-capacity__help" tabindex="0">状态说明</span>
|
||||
</el-tooltip>
|
||||
<dl class="storage-capacity__metrics">
|
||||
<div><dt>已使用</dt><dd>{{ status.isAvailable ? formatBytes(status.usedBytes) : "--" }}</dd></div>
|
||||
<div><dt>可用</dt><dd>{{ status.isAvailable ? formatBytes(status.availableBytes) : "--" }}</dd></div>
|
||||
<div><dt>剩余比例</dt><dd>{{ status.isAvailable ? `${status.freePercent.toFixed(1)}%` : "--" }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.storage-capacity { display: grid; gap: 18px; }
|
||||
.storage-capacity__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.storage-capacity__eyebrow { color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
||||
.storage-capacity__headline { display: flex; align-items: baseline; gap: 8px; margin-top: 5px; }
|
||||
.storage-capacity__headline strong { color: var(--text-primary); font-size: 30px; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.storage-capacity__headline span { color: var(--text-muted); font-size: 13px; }
|
||||
.storage-capacity__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0; }
|
||||
.storage-capacity__metrics > div { min-width: 0; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--surface-muted); }
|
||||
.storage-capacity__metrics dt { color: var(--text-muted); font-size: 11px; }
|
||||
.storage-capacity__metrics dd { margin: 4px 0 0; color: var(--text-primary); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.storage-capacity__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; color: var(--text-muted); font-size: 12px; }
|
||||
.storage-capacity__path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); }
|
||||
.storage-capacity__help { flex: 0 0 auto; color: var(--accent); cursor: help; }
|
||||
.storage-capacity { display: grid; grid-template-columns: 106px minmax(0, 1fr); align-items: center; gap: 20px; }
|
||||
.storage-capacity__ring { width: 100px; height: 100px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; }
|
||||
.storage-capacity__ring-inner { width: 76px; height: 76px; display: grid; place-content: center; border-radius: 50%; background: var(--surface); text-align: center; }
|
||||
.storage-capacity__ring strong { display: block; color: var(--text-primary); font-size: 19px; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.storage-capacity__ring span { margin-top: 3px; color: var(--text-muted); font-size: 9px; }
|
||||
.storage-capacity__detail { min-width: 0; }
|
||||
.storage-capacity__summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.storage-capacity__summary > strong { min-width: 0; color: var(--text-primary); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.storage-capacity__path { margin: 7px 0 0; overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.storage-capacity__metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 13px 0 0; }
|
||||
.storage-capacity__metrics > div { min-width: 0; padding: 9px; border-radius: var(--radius-sm); background: var(--surface-muted); }
|
||||
.storage-capacity__metrics dt { color: var(--text-muted); font-size: 9px; }
|
||||
.storage-capacity__metrics dd { margin: 3px 0 0; overflow: hidden; color: var(--text-primary); font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@media (max-width: 640px) {
|
||||
.storage-capacity__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.storage-capacity__foot { align-items: flex-start; flex-direction: column; }
|
||||
.storage-capacity__path { width: 100%; white-space: normal; overflow-wrap: anywhere; }
|
||||
.storage-capacity { grid-template-columns: 88px minmax(0, 1fr); gap: 14px; }
|
||||
.storage-capacity__ring { width: 82px; height: 82px; }
|
||||
.storage-capacity__ring-inner { width: 62px; height: 62px; }
|
||||
.storage-capacity__ring strong { font-size: 16px; }
|
||||
.storage-capacity__summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.storage-capacity__metrics { gap: 5px; }
|
||||
.storage-capacity__metrics > div { padding: 7px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
// CSS mobile rules use `max-width: 768px`, so 768px must follow the same
|
||||
// rendering branch instead of receiving the desktop/table markup.
|
||||
const MOBILE_BREAKPOINT = 769;
|
||||
const TABLET_BREAKPOINT = 1024;
|
||||
const DESKTOP_BREAKPOINT = 1280;
|
||||
|
||||
|
||||
+126
-90
@@ -13,65 +13,73 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
/* neutrals */
|
||||
--bg-base: #eef1f6;
|
||||
--bg-subtle: #e7ebf2;
|
||||
--bg-base: #f4f7fc;
|
||||
--bg-subtle: #edf2f9;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-muted: #f4f7fb;
|
||||
--surface-strong: #eef2f8;
|
||||
--surface-hover: #eef2f8;
|
||||
--border-subtle: #e3e7ef;
|
||||
--border-base: #d2d8e3;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #45526a;
|
||||
--text-muted: #6a7689;
|
||||
--text-soft: #97a1b3;
|
||||
--surface-muted: #f8fafe;
|
||||
--surface-subtle: #f8fafe;
|
||||
--surface-strong: #edf2f9;
|
||||
--surface-hover: #f1f6ff;
|
||||
--border-subtle: #e3eaf4;
|
||||
--border-base: #cfd9e8;
|
||||
--text-primary: #17233a;
|
||||
--text-secondary: #42526d;
|
||||
--text-muted: #62718a;
|
||||
--text-soft: #68768d;
|
||||
|
||||
/* sidebar */
|
||||
--side-bg: #f3f5f9;
|
||||
--side-border: #e3e7ef;
|
||||
--side-title: #7a8699;
|
||||
--side-item: #44526b;
|
||||
--side-item-hover-bg: rgba(26, 86, 219, 0.07);
|
||||
--side-item-hover: #1e40af;
|
||||
--side-active-bg: rgba(26, 86, 219, 0.10);
|
||||
--side-active: #1a56db;
|
||||
--side-bg: #fbfcff;
|
||||
--side-border: #e3eaf4;
|
||||
--side-title: #68768d;
|
||||
--side-item: #40506a;
|
||||
--side-item-hover-bg: #f8fafe;
|
||||
--side-item-hover: #17233a;
|
||||
--side-active-bg: #f1f6ff;
|
||||
--side-active: #185dcc;
|
||||
|
||||
/* brand */
|
||||
--accent: #1a56db;
|
||||
--accent-strong: #1e40af;
|
||||
--accent-soft: rgba(26, 86, 219, 0.12);
|
||||
--accent-soft-2: rgba(26, 86, 219, 0.07);
|
||||
--info: #2563eb;
|
||||
--info-soft: rgba(37, 99, 235, 0.11);
|
||||
--success: #16a34a;
|
||||
--success-soft: rgba(22, 163, 74, 0.12);
|
||||
--warning: #d97706;
|
||||
--warning-soft: rgba(217, 119, 6, 0.13);
|
||||
--danger: #dc2626;
|
||||
--danger-soft: rgba(220, 38, 38, 0.11);
|
||||
--purple: #7c3aed;
|
||||
--purple-soft: rgba(124, 58, 237, 0.11);
|
||||
--accent: #185dcc;
|
||||
--accent-strong: #144ba8;
|
||||
--accent-soft: #eaf2ff;
|
||||
--accent-soft-2: #f1f6ff;
|
||||
--action: #246bfe;
|
||||
--action-hover: #1856d7;
|
||||
--info: #185dcc;
|
||||
--info-soft: #eaf2ff;
|
||||
--cyan: #0d89ad;
|
||||
--cyan-soft: #e7f7fb;
|
||||
--success: #137e5c;
|
||||
--success-soft: #e6f7f0;
|
||||
--warning: #a45f00;
|
||||
--warning-soft: #fff4dd;
|
||||
--danger: #b83342;
|
||||
--danger-soft: #ffebef;
|
||||
--purple: #6558d9;
|
||||
--purple-soft: #f0edff;
|
||||
--ambient-blue: rgba(86, 137, 255, .13);
|
||||
--ambient-violet: rgba(146, 119, 255, .09);
|
||||
--ambient-warm: rgba(255, 187, 112, .08);
|
||||
|
||||
/* elevation */
|
||||
--shadow-xs: 0 1px 3px rgba(16, 24, 40, 0.09), 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
--shadow-sm: 0 2px 8px rgba(16, 24, 40, 0.10), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--shadow-md: 0 8px 20px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.07);
|
||||
--shadow-lg: 0 16px 40px rgba(16, 24, 40, 0.16), 0 4px 10px rgba(16, 24, 40, 0.08);
|
||||
--shadow-float: 0 24px 56px rgba(16, 24, 40, 0.22);
|
||||
--shadow-xs: 0 2px 8px rgba(37, 61, 98, .045);
|
||||
--shadow-sm: 0 8px 24px rgba(37, 61, 98, .08);
|
||||
--shadow-md: 0 14px 38px rgba(37, 61, 98, .10);
|
||||
--shadow-lg: 0 20px 52px rgba(22, 38, 68, .16);
|
||||
--shadow-float: 0 28px 76px rgba(22, 38, 68, .22);
|
||||
|
||||
/* radius */
|
||||
--radius-xs: 7px;
|
||||
--radius-sm: 9px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
|
||||
/* layout */
|
||||
--sidebar-w: 244px;
|
||||
--sidebar-w: 224px;
|
||||
--sidebar-w-collapsed: 72px;
|
||||
--topbar-h: 62px;
|
||||
--page-max: 1320px;
|
||||
--page-gap: 16px;
|
||||
--topbar-h: 64px;
|
||||
--page-max: 1440px;
|
||||
--page-gap: 14px;
|
||||
--content-padding: 20px;
|
||||
--content-padding-mobile: 14px;
|
||||
--control-height: 40px;
|
||||
@@ -88,42 +96,56 @@ html[data-density="compact"] {
|
||||
--control-height-sm: 30px;
|
||||
--table-row-padding: 12px;
|
||||
--header-row-height: 56px;
|
||||
--topbar-h: 56px;
|
||||
--topbar-h: 64px;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg-base: #0a1120;
|
||||
--bg-subtle: #0f172a;
|
||||
--surface: #121c30;
|
||||
--surface-raised: #162238;
|
||||
--surface-muted: #16223899;
|
||||
--surface-strong: #1b2942;
|
||||
--surface-hover: #1b2942;
|
||||
--border-subtle: #1f2c44;
|
||||
--border-base: #2c3a55;
|
||||
--text-primary: #e9eefb;
|
||||
--text-secondary: #b6c4dd;
|
||||
--text-muted: #8295b3;
|
||||
--text-soft: #647a9a;
|
||||
--bg-base: #0f1726;
|
||||
--bg-subtle: #111d30;
|
||||
--surface: #172235;
|
||||
--surface-raised: #1b2940;
|
||||
--surface-muted: #1b2940;
|
||||
--surface-subtle: #1b2940;
|
||||
--surface-strong: #22324a;
|
||||
--surface-hover: #22324a;
|
||||
--border-subtle: #2c3c55;
|
||||
--border-base: #40516a;
|
||||
--text-primary: #f2f6fd;
|
||||
--text-secondary: #d2dbea;
|
||||
--text-muted: #a9b7cb;
|
||||
--text-soft: #9aa9be;
|
||||
|
||||
--side-bg: #0c1424;
|
||||
--side-border: #1a2740;
|
||||
--side-title: #4a607d;
|
||||
--side-item: #8295b3;
|
||||
--side-item-hover-bg: rgba(255, 255, 255, 0.05);
|
||||
--side-item-hover: #d4def0;
|
||||
--side-active-bg: rgba(96, 165, 250, 0.18);
|
||||
--side-active: #93c5fd;
|
||||
--side-bg: #131e30;
|
||||
--side-border: #2c3c55;
|
||||
--side-title: #a1aec2;
|
||||
--side-item: #d6e0ee;
|
||||
--side-item-hover-bg: #1b2940;
|
||||
--side-item-hover: #f2f6fd;
|
||||
--side-active-bg: #1b2b43;
|
||||
--side-active: #79a9ff;
|
||||
|
||||
--accent: #60a5fa;
|
||||
--accent-strong: #3b82f6;
|
||||
--accent-soft: rgba(96, 165, 250, 0.18);
|
||||
--accent-soft-2: rgba(96, 165, 250, 0.10);
|
||||
--info: #60a5fa;
|
||||
--success: #34d399;
|
||||
--warning: #fbbf24;
|
||||
--danger: #f87171;
|
||||
--accent: #79a9ff;
|
||||
--accent-strong: #a5c6ff;
|
||||
--accent-soft: rgba(86, 143, 238, .20);
|
||||
--accent-soft-2: rgba(86, 143, 238, .12);
|
||||
--action: #2763d2;
|
||||
--action-hover: #3575e6;
|
||||
--info: #79a9ff;
|
||||
--info-soft: rgba(86, 143, 238, .20);
|
||||
--cyan: #62c9e4;
|
||||
--cyan-soft: rgba(43, 165, 196, .17);
|
||||
--success: #65d0a4;
|
||||
--success-soft: rgba(40, 159, 114, .20);
|
||||
--warning: #f0b75c;
|
||||
--warning-soft: rgba(207, 139, 35, .20);
|
||||
--danger: #ff8b94;
|
||||
--danger-soft: rgba(211, 68, 82, .22);
|
||||
--purple: #aa9cff;
|
||||
--purple-soft: rgba(123, 102, 221, .20);
|
||||
--ambient-blue: rgba(58, 112, 220, .17);
|
||||
--ambient-violet: rgba(119, 88, 207, .13);
|
||||
--ambient-warm: rgba(205, 132, 55, .07);
|
||||
|
||||
--shadow-xs: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.45);
|
||||
@@ -133,6 +155,7 @@ html[data-theme="dark"] {
|
||||
}
|
||||
|
||||
/* ============================ Element Plus overrides ============================ */
|
||||
:root,
|
||||
#app {
|
||||
--el-color-primary: var(--accent);
|
||||
--el-color-primary-light-3: #5b8cf0;
|
||||
@@ -162,16 +185,18 @@ html[data-theme="dark"] {
|
||||
--el-mask-color: rgba(15, 23, 42, 0.54);
|
||||
}
|
||||
|
||||
html[data-theme="dark"],
|
||||
html[data-theme="dark"] #app {
|
||||
--el-color-primary-light-3: #93c5fd;
|
||||
--el-color-primary-light-5: #bfdbfe;
|
||||
--el-color-primary-light-7: #dbeafe;
|
||||
--el-color-primary-light-9: #eff6ff;
|
||||
--el-color-primary-light-3: #5d8fe7;
|
||||
--el-color-primary-light-5: #426baf;
|
||||
--el-color-primary-light-7: #2c4775;
|
||||
--el-color-primary-light-9: #1b2b43;
|
||||
--el-mask-color: rgba(3, 7, 14, 0.72);
|
||||
}
|
||||
|
||||
/* ============================ Base ============================ */
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-tap-highlight-color: transparent; }
|
||||
html, body, #app { margin: 0; width: 100%; height: 100%; min-height: 100%; }
|
||||
body {
|
||||
color: var(--text-primary);
|
||||
@@ -182,6 +207,14 @@ body {
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* Keep keyboard focus visible without leaving a browser-default frame after
|
||||
mouse or touch activation. */
|
||||
:where(a, button, [role="button"], input, textarea, select):focus { outline: none; }
|
||||
:where(a, button, [role="button"], input, textarea, select):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 99px; border: 3px solid transparent; background-clip: content-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-soft); background-clip: content-box; }
|
||||
@@ -193,9 +226,9 @@ button { font-family: inherit; cursor: pointer; }
|
||||
.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; padding-bottom: 24px; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.page-header > div:first-child { flex: 1; min-width: 0; }
|
||||
.page-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.25; color: var(--text-primary); }
|
||||
.page-subtitle { max-width: 76ch; margin: 8px 0 0; color: var(--text-muted); font-size: 13.5px; line-height: 1.6; }
|
||||
.page-kicker { margin-bottom: 7px; color: var(--accent); font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.page-title { margin: 0; font-size: clamp(24px, 2vw, 28px); font-weight: 780; letter-spacing: -.035em; line-height: 1.2; color: var(--text-primary); }
|
||||
.page-subtitle { max-width: 72ch; margin: 7px 0 0; color: var(--text-muted); font-size: 12.5px; line-height: 1.55; }
|
||||
.page-kicker { margin-bottom: 5px; color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.page-toolbar, .header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
|
||||
/* cards */
|
||||
@@ -241,6 +274,7 @@ button { font-family: inherit; cursor: pointer; }
|
||||
.cell-mono { margin-top: 4px; color: var(--text-muted); font-size: 12px; word-break: break-all; }
|
||||
.table-date-text { color: var(--text-muted); font-size: 12px; white-space: nowrap; }
|
||||
.path-text, .log-detail, .detail-popover { color: var(--text-secondary); font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.platform-line { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 5px; min-width: 0; }
|
||||
|
||||
/* badges/tags */
|
||||
.badge-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
@@ -288,12 +322,12 @@ button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* primary button — flat, blue glow */
|
||||
.el-button.el-button--primary {
|
||||
background: var(--accent) !important; border-color: var(--accent-strong) !important; color: #fff !important;
|
||||
box-shadow: 0 1px 3px rgba(26, 86, 219, 0.4), 0 4px 14px rgba(26, 86, 219, 0.2);
|
||||
background: var(--action) !important; border-color: var(--action) !important; color: #fff !important;
|
||||
box-shadow: 0 7px 18px color-mix(in srgb, var(--action) 22%, transparent);
|
||||
}
|
||||
.el-button.el-button--primary:not(.is-disabled):hover {
|
||||
background: var(--accent-strong) !important; border-color: var(--accent-strong) !important; color: #fff !important;
|
||||
box-shadow: 0 2px 5px rgba(26, 86, 219, 0.45), 0 6px 18px rgba(26, 86, 219, 0.28);
|
||||
background: var(--action-hover) !important; border-color: var(--action-hover) !important; color: #fff !important;
|
||||
box-shadow: 0 9px 22px color-mix(in srgb, var(--action) 28%, transparent);
|
||||
}
|
||||
.el-button.is-plain:not(.el-button--danger):not(.el-button--primary) { background: var(--accent-soft) !important; border-color: transparent !important; color: var(--accent) !important; }
|
||||
.el-button.el-button--danger.is-plain { background: var(--danger-soft) !important; border-color: transparent !important; color: var(--danger) !important; }
|
||||
@@ -327,10 +361,10 @@ button { font-family: inherit; cursor: pointer; }
|
||||
background: transparent;
|
||||
}
|
||||
.el-table::before, .el-table__inner-wrapper::before { display: none; }
|
||||
.el-table th.el-table__cell { padding: 12px 20px; border-bottom: 2px solid var(--border-base); background: #eef2f8; }
|
||||
html[data-theme="dark"] .el-table th.el-table__cell { background: #16223a; }
|
||||
.el-table th.el-table__cell { padding: 11px 16px; border-bottom: 1px solid var(--border-subtle); background: var(--surface-muted); }
|
||||
html[data-theme="dark"] .el-table th.el-table__cell { background: var(--surface-muted); }
|
||||
.el-table th.el-table__cell > .cell { color: var(--text-muted); font-size: 12px; font-weight: 700; letter-spacing: .02em; text-transform: uppercase; }
|
||||
.el-table td.el-table__cell { padding: 14px 20px; border-bottom: 1px solid var(--border-subtle); background: transparent; }
|
||||
.el-table td.el-table__cell { padding: 13px 16px; border-bottom: 1px solid var(--border-subtle); background: transparent; }
|
||||
.el-table tr { background: transparent; }
|
||||
.el-table tbody tr:nth-child(even) { background: rgba(15, 23, 42, 0.018); }
|
||||
html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); }
|
||||
@@ -351,7 +385,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
|
||||
border-radius: var(--radius-md); border: 1px solid var(--border-base);
|
||||
background: var(--surface); box-shadow: var(--shadow-float);
|
||||
}
|
||||
.el-message-box, .el-popover.el-popper, .el-select__popper.el-popper, .el-picker__popper.el-popper { border-color: var(--border-subtle); background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-float); }
|
||||
.el-message-box, .el-popover.el-popper, .el-select__popper.el-popper, .el-picker__popper.el-popper, .el-dropdown__popper.el-popper { border-color: var(--border-subtle); background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-float); }
|
||||
.el-dropdown__popper.el-popper .el-dropdown-menu { border-color: var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-md); border-radius: var(--radius-sm); }
|
||||
.el-dropdown-menu__item.danger-menu-item { color: var(--danger); }
|
||||
.el-dialog__header { margin: 0; padding: 20px 24px 10px; }
|
||||
@@ -368,6 +402,8 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
|
||||
.el-tabs--border-card > .el-tabs__header { background: transparent; }
|
||||
.el-tabs--border-card > .el-tabs__header .el-tabs__item { color: var(--text-secondary); }
|
||||
.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active { color: var(--text-primary); background: var(--surface); }
|
||||
.session-recovery-alert { margin-block-end: 16px; }
|
||||
.session-recovery-alert .el-alert__description, .recovery-status-copy { overflow-wrap: anywhere; }
|
||||
|
||||
/* ============================ Skeleton loading ============================ */
|
||||
@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
|
||||
@@ -388,7 +424,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25
|
||||
@media (max-width: 768px) {
|
||||
.page-header, .section-header, .toolbar-row { flex-direction: column; }
|
||||
.page-title { font-size: 22px; }
|
||||
.page-subtitle { display: none; }
|
||||
.page-subtitle { display: block; font-size: 11px; }
|
||||
.surface-card .el-card__body { padding: 14px; }
|
||||
.stats-grid, .data-card__grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.stat-card { padding: 14px; gap: 8px; }
|
||||
|
||||
@@ -141,6 +141,8 @@ export interface RecordTask {
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
durationSeconds?: number;
|
||||
isHiddenArtifactSource: boolean;
|
||||
mergedIntoRecordTaskId?: string;
|
||||
uploadStatus?: number;
|
||||
postProcessStage?: string;
|
||||
postProcessProgressPercent?: number;
|
||||
@@ -170,6 +172,7 @@ export interface RecordSession {
|
||||
id: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
anchorName?: string;
|
||||
platform: number;
|
||||
roomId: string;
|
||||
status: number;
|
||||
@@ -180,6 +183,8 @@ export interface RecordSession {
|
||||
segmentCount: number;
|
||||
recorderProcessId?: number;
|
||||
errorMessage?: string;
|
||||
isRecovering: boolean;
|
||||
recoveryReason?: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
@@ -318,6 +323,16 @@ export interface UploadTaskListResponse {
|
||||
queuedCount: number;
|
||||
uploadingCount: number;
|
||||
waitingRetryCount: number;
|
||||
matchingRetryableCount: number;
|
||||
queueHealth: UploadQueueHealth;
|
||||
}
|
||||
|
||||
export interface UploadQueueHealth {
|
||||
state: "Healthy" | "RateLimited" | "AuthenticationBlocked" | "Disabled" | string;
|
||||
isPaused: boolean;
|
||||
reason?: string;
|
||||
retryAt?: string;
|
||||
lastErrorAt?: string;
|
||||
}
|
||||
|
||||
export interface ManualSegmentCompletedTriggerResult {
|
||||
@@ -668,6 +683,19 @@ export interface RecoveryOverview {
|
||||
storage: StorageGuardStatus;
|
||||
liveRooms: RecoverableLiveRoom[];
|
||||
finalizations: RecoverableFinalization[];
|
||||
mergedArtifacts: MergedArtifactRecord[];
|
||||
}
|
||||
|
||||
export interface MergedArtifactRecord {
|
||||
sourceRecordTaskId: string;
|
||||
recordSessionId: string;
|
||||
mergedIntoRecordTaskId?: string;
|
||||
sourceVideoPath?: string;
|
||||
mergedVideoPath?: string;
|
||||
recoveryDirectory?: string;
|
||||
manifestPath?: string;
|
||||
sourceDurationSeconds?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StorageGuardStatus {
|
||||
@@ -722,6 +750,34 @@ export interface RecoveryActionResult {
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
export interface RecordingFailureListResponse {
|
||||
items: RecordingFailureItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface RecordingFailureItem {
|
||||
recordTaskId: string;
|
||||
recordSessionId: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
roomId: string;
|
||||
platformName: string;
|
||||
segmentIndex: number;
|
||||
failureKind: string;
|
||||
failureLabel: string;
|
||||
recommendedAction: string;
|
||||
errorMessage?: string;
|
||||
filePath?: string;
|
||||
fileSizeBytes?: number;
|
||||
durationSeconds?: number;
|
||||
fileExists: boolean;
|
||||
canAccept: boolean;
|
||||
canRepair: boolean;
|
||||
canRetryRoom: boolean;
|
||||
isRepairing: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TranscodeTaskItem {
|
||||
task: RecordTask;
|
||||
result?: RecordResult;
|
||||
|
||||
@@ -6,6 +6,7 @@ import axios from "axios";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
DailyReviewPushResult,
|
||||
@@ -194,7 +195,9 @@ onMounted(loadReport);
|
||||
<div class="cell-subtitle">{{ row.anchorName || row.roomId }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平台" width="120" prop="platformName" />
|
||||
<el-table-column label="平台" width="140">
|
||||
<template #default="{ row }"><PlatformMark :name="row.platformName" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会话" width="90" prop="sessionCount" />
|
||||
<el-table-column label="分片" width="90" prop="segmentCount" />
|
||||
<el-table-column label="录制时长" width="120">
|
||||
@@ -231,7 +234,7 @@ onMounted(loadReport);
|
||||
<div class="highlight-item__label">{{ item.label }}</div>
|
||||
<div class="highlight-item__title">{{ item.liveRoomTitle }}</div>
|
||||
<div class="highlight-item__meta">
|
||||
<span>{{ item.platformName }}</span>
|
||||
<PlatformMark :name="item.platformName" />
|
||||
<span>{{ item.roomId }}</span>
|
||||
<span>{{ formatDuration(item.durationSeconds) }}</span>
|
||||
</div>
|
||||
@@ -273,7 +276,9 @@ onMounted(loadReport);
|
||||
<div class="cell-subtitle">分片 #{{ row.segmentIndex }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平台" width="120" prop="platformName" />
|
||||
<el-table-column label="平台" width="140">
|
||||
<template #default="{ row }"><PlatformMark :name="row.platformName" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="弹幕数" width="100" prop="danmakuCount" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Clock, House, Refresh, Upload, VideoCamera, Warning } from "@element-plus/icons-vue";
|
||||
import {
|
||||
ArrowRight,
|
||||
Film,
|
||||
FolderOpened,
|
||||
Plus,
|
||||
Refresh,
|
||||
Upload,
|
||||
Warning
|
||||
} from "@element-plus/icons-vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
|
||||
import type { DashboardData, DashboardRecentSession } from "@/types";
|
||||
@@ -16,19 +24,29 @@ const loadError = ref("");
|
||||
const data = ref<DashboardData | null>(null);
|
||||
|
||||
const queueTotal = computed(() => (data.value?.pendingTranscodeCount ?? 0) + (data.value?.pendingUploadCount ?? 0));
|
||||
const hasAttention = computed(() => Boolean(
|
||||
data.value && (
|
||||
data.value.currentErrorCount > 0 ||
|
||||
data.value.storageStatus.tier !== "Green" ||
|
||||
queueTotal.value > 0
|
||||
)
|
||||
));
|
||||
const attentionCount = computed(() => {
|
||||
if (!data.value) return 0;
|
||||
return Number(data.value.currentErrorCount > 0)
|
||||
+ Number(data.value.storageStatus.tier !== "Green")
|
||||
+ Number(queueTotal.value > 0);
|
||||
});
|
||||
const hasAttention = computed(() => attentionCount.value > 0);
|
||||
const activeSessions = computed(() => data.value?.recentSessions.filter((item) => [1, 2, 3, 7].includes(item.status)).slice(0, 2) ?? []);
|
||||
|
||||
function formatDuration(seconds?: number) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "--";
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return hours > 0 ? `${hours} 小时 ${minutes} 分` : `${minutes} 分钟`;
|
||||
if (hours > 0) return `${hours} 小时 ${minutes} 分`;
|
||||
return `${Math.max(1, minutes)} 分钟`;
|
||||
}
|
||||
|
||||
function formatTimer(seconds?: number) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) return "--:--:--";
|
||||
const hours = Math.floor(seconds / 3600).toString().padStart(2, "0");
|
||||
const minutes = Math.floor((seconds % 3600) / 60).toString().padStart(2, "0");
|
||||
const remainder = Math.floor(seconds % 60).toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}:${remainder}`;
|
||||
}
|
||||
|
||||
function formatDataSize(bytes?: number) {
|
||||
@@ -36,17 +54,31 @@ function formatDataSize(bytes?: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "--";
|
||||
if (!value) return "--";
|
||||
return new Date(value).toLocaleString([], { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function formatRelativeHint(value?: string | null) {
|
||||
if (!value) return "暂无等待中的任务";
|
||||
const elapsed = Math.max(0, Date.now() - new Date(value).getTime());
|
||||
const minutes = Math.floor(elapsed / 60_000);
|
||||
if (minutes < 1) return "最早任务刚刚进入队列";
|
||||
if (minutes < 60) return `最早任务已等待 ${minutes} 分钟`;
|
||||
return `最早任务已等待 ${Math.floor(minutes / 60)} 小时`;
|
||||
}
|
||||
|
||||
function sessionStatus(session: DashboardRecentSession) {
|
||||
return sessionStatusLabelMap[session.status] ?? "未知";
|
||||
}
|
||||
|
||||
function roomInitial(session: DashboardRecentSession) {
|
||||
return session.liveRoomTitle.trim().slice(0, 1) || "播";
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
@@ -54,7 +86,7 @@ async function loadData() {
|
||||
const { data: result } = await apiClient.get<DashboardData>("/dashboard");
|
||||
data.value = result;
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "仪表盘数据加载失败。");
|
||||
loadError.value = getApiErrorMessage(error, "运行中心数据加载失败。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -67,12 +99,13 @@ onMounted(loadData);
|
||||
<div class="page-stack dashboard-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-kicker">运行中心</div>
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
<p class="page-subtitle">先处理异常与积压,再查看录制产出和最近活动。</p>
|
||||
<div class="page-kicker">OPERATIONS</div>
|
||||
<h1 class="page-title">运行中心</h1>
|
||||
<p class="page-subtitle">优先处理异常和积压,再关注正在进行的录制与今日产出。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新状态</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="router.push({ name: 'live-rooms', query: { action: 'create' } })">添加直播间</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,120 +114,207 @@ onMounted(loadData);
|
||||
|
||||
<template v-else-if="data">
|
||||
<section v-if="hasAttention" class="attention-bar" aria-label="需要关注">
|
||||
<div class="attention-bar__icon"><el-icon><Warning /></el-icon></div>
|
||||
<div class="attention-bar__copy">
|
||||
<strong>有需要关注的运行状态</strong>
|
||||
<span class="attention-bar__icon"><el-icon><Warning /></el-icon></span>
|
||||
<span class="attention-bar__copy">
|
||||
<strong>有 {{ attentionCount }} 项需要关注</strong>
|
||||
<span>
|
||||
最近 30 分钟 {{ data.currentErrorCount }} 个异常,
|
||||
{{ queueTotal }} 个处理任务等待完成,存储状态为 {{ data.storageStatus.tier === "Green" ? "正常" : "受限" }}。
|
||||
最近 30 分钟 {{ data.currentErrorCount }} 个异常,{{ queueTotal }} 个处理任务等待完成,
|
||||
存储状态{{ data.storageStatus.tier === "Green" ? "正常" : "已触发预警" }}。
|
||||
</span>
|
||||
</div>
|
||||
<el-button size="small" @click="router.push({ name: 'logs' })">查看日志</el-button>
|
||||
</span>
|
||||
<el-button size="small" @click="router.push({ name: queueTotal > 0 ? 'upload-tasks' : 'logs' })">立即处理</el-button>
|
||||
</section>
|
||||
|
||||
<div v-if="data.recentErrorCount > data.currentErrorCount" class="history-note">
|
||||
近 24 小时共记录 {{ data.recentErrorCount }} 个历史异常;最近 30 分钟为 {{ data.currentErrorCount }} 个,历史记录不代表系统当前仍有故障。
|
||||
<span>近 24 小时共记录 {{ data.recentErrorCount }} 个历史异常;当前状态以最近 30 分钟的 {{ data.currentErrorCount }} 个异常为准。</span>
|
||||
<el-button link size="small" @click="router.push({ name: 'logs' })">查看历史日志</el-button>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-metrics">
|
||||
<MetricCard label="正在录制" :value="data.activeRecordingCount" description="当前活动录制会话" :icon="VideoCamera" />
|
||||
<MetricCard label="在线直播间" :value="`${data.liveRoomCount} / ${data.totalRoomCount}`" description="在线 / 已接入" :icon="House" />
|
||||
<MetricCard label="今日录制" :value="formatDuration(data.todayRecordingSeconds)" :description="`${formatDataSize(data.todayDataBytes)} · ${data.todayDanmakuCount.toLocaleString()} 条弹幕`" :icon="Clock" />
|
||||
<MetricCard label="待处理" :value="queueTotal" :description="`${data.pendingTranscodeCount} 转码 · ${data.pendingUploadCount} 上传`" :icon="Upload" />
|
||||
<div class="primary-grid">
|
||||
<section class="live-hero" aria-label="当前录制概况">
|
||||
<div class="live-hero__top">
|
||||
<span class="live-hero__label"><i class="live-pulse" />正在录制</span>
|
||||
<span class="live-hero__badge">{{ data.activeRecordingCount }} 路活跃</span>
|
||||
</div>
|
||||
<div class="live-hero__count">{{ String(data.activeRecordingCount).padStart(2, "0") }}<small>当前录制会话</small></div>
|
||||
|
||||
<div v-if="activeSessions.length" class="active-session-list">
|
||||
<button
|
||||
v-for="session in activeSessions"
|
||||
:key="session.id"
|
||||
class="active-session"
|
||||
type="button"
|
||||
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
|
||||
>
|
||||
<span class="active-session__avatar">{{ roomInitial(session) }}</span>
|
||||
<span class="active-session__copy">
|
||||
<strong>{{ session.liveRoomTitle }}</strong>
|
||||
<span><PlatformMark :name="session.platformName" /> · {{ session.segmentCount }} 个分片</span>
|
||||
</span>
|
||||
<time>{{ formatTimer(session.durationSeconds) }}</time>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="live-hero__empty">
|
||||
{{ data.activeRecordingCount > 0 ? "活动会话将在下一次状态刷新后显示" : "当前没有正在录制的直播间" }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-card class="surface-card process-card" shadow="never">
|
||||
<div class="panel-heading panel-heading--bordered">
|
||||
<div><h2>处理概况</h2><p>录制后的自动化流水线</p></div>
|
||||
<el-button link @click="router.push({ name: 'transcode-tasks' })">查看队列</el-button>
|
||||
</div>
|
||||
<div class="process-list">
|
||||
<button class="process-row" type="button" @click="router.push({ name: 'transcode-tasks' })">
|
||||
<span class="process-row__icon"><el-icon><Film /></el-icon></span>
|
||||
<span><strong>等待转码</strong><small>{{ formatRelativeHint(data.oldestTranscodeUpdatedAt) }}</small></span>
|
||||
<b>{{ data.pendingTranscodeCount }}</b>
|
||||
</button>
|
||||
<button class="process-row" type="button" @click="router.push({ name: 'upload-tasks' })">
|
||||
<span class="process-row__icon"><el-icon><Upload /></el-icon></span>
|
||||
<span><strong>等待上传</strong><small>{{ data.stalledUploadCount ?? 0 }} 个停滞 · {{ data.uploadCleanupFailureCount ?? 0 }} 个待清理</small></span>
|
||||
<b>{{ data.pendingUploadCount }}</b>
|
||||
</button>
|
||||
<button class="process-row" type="button" @click="router.push({ name: 'media-browser' })">
|
||||
<span class="process-row__icon"><el-icon><FolderOpened /></el-icon></span>
|
||||
<span><strong>今日产出</strong><small>{{ formatDuration(data.todayRecordingSeconds) }} · {{ data.todayDanmakuCount.toLocaleString() }} 条弹幕</small></span>
|
||||
<b>{{ formatDataSize(data.todayDataBytes) }}</b>
|
||||
</button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="operations-grid">
|
||||
<div class="secondary-grid">
|
||||
<el-card class="surface-card storage-card" shadow="never">
|
||||
<div class="panel-heading">
|
||||
<div><h2>存储空间</h2><p>输出目录实际容量与录制保护状态。</p></div>
|
||||
<div class="panel-heading panel-heading--bordered">
|
||||
<div><h2>存储容量</h2><p>录制目录与保护阈值</p></div>
|
||||
</div>
|
||||
<StorageCapacity :status="data.storageStatus" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card queue-card" shadow="never">
|
||||
<div class="panel-heading">
|
||||
<div><h2>处理队列</h2><p>需要系统继续处理的本地文件。</p></div>
|
||||
</div>
|
||||
<button class="queue-row" type="button" @click="router.push({ name: 'transcode-tasks' })">
|
||||
<span><strong>待转码</strong><small>FFmpeg 后处理</small></span><b>{{ data.pendingTranscodeCount }}</b>
|
||||
</button>
|
||||
<button class="queue-row" type="button" @click="router.push({ name: 'upload-tasks' })">
|
||||
<span><strong>待上传</strong><small>远端归档</small></span><b>{{ data.pendingUploadCount }}</b>
|
||||
</button>
|
||||
<div v-if="data.stalledUploadCount > 0" class="queue-volume">
|
||||
<span>上传停滞(超过 60 分钟)</span><strong>{{ data.stalledUploadCount }}</strong>
|
||||
</div>
|
||||
<div v-if="data.uploadCleanupFailureCount > 0" class="queue-volume">
|
||||
<span>本地清理等待重试</span><strong>{{ data.uploadCleanupFailureCount }}</strong>
|
||||
</div>
|
||||
<div class="queue-volume"><span>积压数据量</span><strong>{{ formatDataSize(data.queuedDataBytes) }}</strong></div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="activity-grid">
|
||||
<el-card class="surface-card activity-card" shadow="never">
|
||||
<div class="panel-heading">
|
||||
<div><h2>最近录制</h2><p>最新创建的录制会话。</p></div>
|
||||
<el-button link @click="router.push({ name: 'record-tasks' })">查看全部</el-button>
|
||||
<div class="panel-heading panel-heading--bordered">
|
||||
<div><h2>最近动态</h2><p>最新创建的录制会话</p></div>
|
||||
<el-button link @click="router.push({ name: 'record-tasks' })">全部会话</el-button>
|
||||
</div>
|
||||
<EmptyState v-if="data.recentSessions.length === 0" title="暂无录制会话" description="直播开始录制后会出现在这里" />
|
||||
<button
|
||||
v-for="session in data.recentSessions"
|
||||
v-for="session in data.recentSessions.slice(0, 4)"
|
||||
v-else
|
||||
:key="session.id"
|
||||
class="activity-row"
|
||||
type="button"
|
||||
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
|
||||
>
|
||||
<i class="activity-row__dot" :class="{ 'is-error': session.status === 5, 'is-warn': [0, 1, 3, 7].includes(session.status) }" />
|
||||
<span class="activity-row__main"><strong>{{ session.liveRoomTitle }}</strong><small>{{ formatDate(session.startedAt) }} · {{ session.segmentCount }} 个分片</small></span>
|
||||
<StatusBadge :label="sessionStatus(session)" :status="session.status" />
|
||||
</button>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card activity-card" shadow="never">
|
||||
<div class="panel-heading">
|
||||
<div><h2>今日录制排行</h2><p>按录制时长排序的直播间。</p></div>
|
||||
<el-button link @click="router.push({ name: 'live-rooms' })">直播间</el-button>
|
||||
</div>
|
||||
<EmptyState v-if="data.topRooms.length === 0" title="今日暂无录制" description="完成录制后会生成今日排行" />
|
||||
<button v-for="(room, index) in data.topRooms" v-else :key="room.liveRoomId" class="activity-row" type="button" @click="router.push({ name: 'live-rooms' })">
|
||||
<span class="rank">{{ index + 1 }}</span>
|
||||
<span class="activity-row__main"><strong>{{ room.title || room.anchorName || room.roomId }}</strong><small>{{ room.platformName }} · {{ room.sessionCount }} 个会话</small></span>
|
||||
<span class="activity-row__value">{{ formatDuration(room.totalDurationSeconds) }}</span>
|
||||
</button>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card ranking-card" shadow="never">
|
||||
<div class="panel-heading panel-heading--bordered">
|
||||
<div><h2>今日录制排行</h2><p>按录制时长排序的直播间</p></div>
|
||||
<el-button link @click="router.push({ name: 'live-rooms' })">直播间 <el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
|
||||
</div>
|
||||
<EmptyState v-if="data.topRooms.length === 0" title="今日暂无录制" description="完成录制后会生成今日排行" />
|
||||
<div v-else class="ranking-list">
|
||||
<button v-for="(room, index) in data.topRooms" :key="room.liveRoomId" class="ranking-row" type="button" @click="router.push({ name: 'live-rooms' })">
|
||||
<span class="rank" :class="`rank--${index + 1}`">{{ index + 1 }}</span>
|
||||
<span class="activity-row__main">
|
||||
<strong>{{ room.title || room.anchorName || room.roomId }}</strong>
|
||||
<small><PlatformMark :name="room.platformName" /> · {{ room.sessionCount }} 个会话</small>
|
||||
</span>
|
||||
<span class="ranking-row__duration">{{ formatDuration(room.totalDurationSeconds) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page { gap: 18px; }
|
||||
.attention-bar { display: flex; align-items: center; gap: 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--warning) 28%, var(--border-subtle)); border-radius: var(--radius-md); background: var(--warning-soft); }
|
||||
.attention-bar__icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; color: var(--warning); background: var(--surface); }
|
||||
.attention-bar__copy { display: grid; flex: 1; min-width: 0; gap: 3px; }
|
||||
.attention-bar__copy strong { font-size: 14px; }
|
||||
.attention-bar__copy span { color: var(--text-secondary); font-size: 12.5px; line-height: 1.5; }
|
||||
.history-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--text-muted); font-size: 12px; line-height: 1.5; }
|
||||
.dashboard-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.operations-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(280px, .8fr); gap: 14px; }
|
||||
.activity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
|
||||
.panel-heading h2 { margin: 0; font-size: 16px; }
|
||||
.panel-heading p { margin: 5px 0 0; color: var(--text-muted); font-size: 12.5px; }
|
||||
.queue-card :deep(.el-card__body), .activity-card :deep(.el-card__body) { display: grid; }
|
||||
.queue-row, .activity-row { display: flex; align-items: center; width: 100%; gap: 12px; padding: 12px; border: 0; border-top: 1px solid var(--border-subtle); background: transparent; color: var(--text-primary); text-align: left; }
|
||||
.queue-row:hover, .activity-row:hover { background: var(--surface-hover); }
|
||||
.queue-row span, .activity-row__main { display: grid; flex: 1; min-width: 0; gap: 4px; }
|
||||
.queue-row small, .activity-row small { color: var(--text-muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-row b { font-size: 24px; font-variant-numeric: tabular-nums; }
|
||||
.queue-volume { display: flex; justify-content: space-between; gap: 12px; padding: 14px 12px 0; color: var(--text-muted); font-size: 12px; }
|
||||
.queue-volume strong { color: var(--text-primary); font-size: 14px; }
|
||||
.activity-row__main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; }
|
||||
.activity-row__value { flex: 0 0 auto; color: var(--text-secondary); font-size: 12.5px; font-weight: 700; }
|
||||
.rank { display: grid; place-items: center; width: 26px; height: 26px; flex: 0 0 auto; border-radius: 7px; background: var(--surface-muted); color: var(--text-muted); font-weight: 800; }
|
||||
@media (max-width: 1100px) { .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .operations-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 768px) { .activity-grid { grid-template-columns: 1fr; } .attention-bar { align-items: flex-start; flex-wrap: wrap; } .attention-bar .el-button { width: 100%; } }
|
||||
@media (max-width: 480px) { .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } }
|
||||
.dashboard-page { gap: 14px; }
|
||||
.attention-bar { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--warning) 27%, var(--border-subtle)); border-radius: 11px; background: var(--warning-soft); }
|
||||
.attention-bar__icon { width: 32px; height: 32px; display: grid; place-items: center; overflow: visible; border-radius: 9px; color: var(--warning); background: var(--surface); }
|
||||
.attention-bar__icon .el-icon { display: grid; place-items: center; }
|
||||
.attention-bar__copy { display: grid; min-width: 0; gap: 2px; }
|
||||
.attention-bar__copy strong { font-size: 12.5px; }
|
||||
.attention-bar__copy > span { color: var(--text-secondary); font-size: 11px; line-height: 1.55; }
|
||||
.history-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--text-muted); font-size: 11.5px; line-height: 1.5; }
|
||||
.primary-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(290px, .72fr); gap: 14px; }
|
||||
.secondary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.panel-heading--bordered { margin: -18px -18px 0; padding: 17px 18px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.panel-heading h2 { margin: 0; font-size: 14px; letter-spacing: -.01em; }
|
||||
.panel-heading p { margin: 4px 0 0; color: var(--text-muted); font-size: 11px; }
|
||||
.live-hero { position: relative; min-height: 282px; overflow: hidden; padding: 20px; border-radius: var(--radius-md); color: #f7f9ff; background: radial-gradient(circle at 88% 8%, rgba(104, 220, 235, .22), transparent 31%), linear-gradient(135deg, #185ecf, #3f67d8 54%, #6658ce); box-shadow: 0 16px 38px rgba(24, 94, 207, .22); }
|
||||
:global(html[data-theme="dark"]) .live-hero { background: radial-gradient(circle at 88% 8%, rgba(104, 220, 235, .16), transparent 31%), linear-gradient(135deg, #17498f, #304f9d 54%, #56469f); box-shadow: 0 16px 38px rgba(0, 0, 0, .28); }
|
||||
.live-hero::after { content: ""; position: absolute; right: -65px; top: -90px; width: 260px; height: 260px; border: 45px solid rgba(255,255,255,.07); border-radius: 50%; pointer-events: none; }
|
||||
.live-hero__top { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.live-hero__label { display: flex; align-items: center; gap: 8px; color: #d7e3ff; font-size: 11px; font-weight: 700; }
|
||||
.live-pulse { width: 8px; height: 8px; border-radius: 50%; background: #ff8b94; box-shadow: 0 0 0 5px rgba(255, 139, 148, .18); }
|
||||
.live-hero__badge { padding: 4px 9px; border-radius: 99px; color: #ffe8ec; background: rgba(255, 139, 148, .18); font-size: 11px; font-weight: 700; }
|
||||
.live-hero__count { position: relative; z-index: 1; margin-top: 15px; font-size: 38px; line-height: 1; font-weight: 800; letter-spacing: -.05em; }
|
||||
.live-hero__count small { margin-left: 7px; color: #d7e3ff; font-size: 12px; font-weight: 500; letter-spacing: 0; }
|
||||
.active-session-list { position: relative; z-index: 1; display: grid; }
|
||||
.active-session { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; padding: 15px 0; border: 0; border-top: 1px solid rgba(255,255,255,.22); color: inherit; background: transparent; text-align: left; }
|
||||
.active-session:first-child { margin-top: 18px; }
|
||||
.active-session:hover strong { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.active-session__avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; color: #244fa9; background: #e8efff; font-size: 13px; font-weight: 800; }
|
||||
.active-session__copy { display: grid; min-width: 0; gap: 4px; }
|
||||
.active-session__copy strong { overflow: hidden; color: #f7f9ff; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.active-session__copy > span { display: flex; align-items: center; color: #d7e3ff; font-size: 10px; }
|
||||
.active-session__copy :deep(.platform-mark) { color: inherit; }
|
||||
.active-session time { color: #f7f9ff; font-family: var(--font-mono); font-size: 15px; font-weight: 700; }
|
||||
.live-hero__empty { position: relative; z-index: 1; margin-top: 32px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,.22); color: #d7e3ff; font-size: 12px; }
|
||||
.process-card :deep(.el-card__body), .activity-card :deep(.el-card__body), .ranking-card :deep(.el-card__body), .storage-card :deep(.el-card__body) { display: grid; }
|
||||
.process-list { display: grid; }
|
||||
.process-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; padding: 14px 0; border: 0; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); background: transparent; text-align: left; }
|
||||
.process-row:last-child { border-bottom: 0; }
|
||||
.process-row__icon { width: 34px; height: 34px; display: grid; place-items: center; overflow: visible; border-radius: 9px; color: var(--accent); background: var(--accent-soft); }
|
||||
.process-row:nth-child(2) .process-row__icon { color: var(--purple); background: var(--purple-soft); }
|
||||
.process-row:nth-child(3) .process-row__icon { color: var(--cyan); background: var(--cyan-soft); }
|
||||
.process-row__icon .el-icon { display: grid; place-items: center; }
|
||||
.process-row > span:nth-child(2) { display: grid; min-width: 0; gap: 2px; }
|
||||
.process-row strong { font-size: 12px; }
|
||||
.process-row small { overflow: hidden; color: var(--text-muted); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.process-row b { max-width: 96px; overflow: hidden; font-size: 18px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.storage-card :deep(.storage-capacity) { padding-top: 18px; }
|
||||
.activity-row, .ranking-row { display: flex; align-items: center; width: 100%; gap: 10px; padding: 12px 0; border: 0; border-bottom: 1px solid var(--border-subtle); background: transparent; color: var(--text-primary); text-align: left; }
|
||||
.activity-row:last-child, .ranking-row:last-child { border-bottom: 0; }
|
||||
.activity-row:hover, .ranking-row:hover, .process-row:hover { color: var(--accent); }
|
||||
.activity-row__dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--success); }
|
||||
.activity-row__dot.is-warn { background: var(--warning); }
|
||||
.activity-row__dot.is-error { background: var(--danger); }
|
||||
.activity-row__main { display: grid; flex: 1; min-width: 0; gap: 4px; }
|
||||
.activity-row__main strong { overflow: hidden; color: var(--text-primary); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-row__main small { display: flex; align-items: center; min-width: 0; overflow: hidden; color: var(--text-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ranking-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; }
|
||||
.rank { width: 26px; height: 26px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 7px; color: var(--text-muted); background: var(--surface-muted); font-size: 11px; font-weight: 800; }
|
||||
.rank--1 { color: #8a5700; background: #fff1cb; }
|
||||
.rank--2 { color: var(--accent); background: var(--accent-soft); }
|
||||
.rank--3 { color: #8b4d2e; background: #f9e7dd; }
|
||||
.ranking-row__duration { flex: 0 0 auto; color: var(--text-secondary); font-size: 11.5px; font-weight: 700; }
|
||||
@media (max-width: 1100px) {
|
||||
.primary-grid { grid-template-columns: 1fr; }
|
||||
.live-hero { min-height: auto; }
|
||||
.ranking-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.secondary-grid { grid-template-columns: 1fr; }
|
||||
.attention-bar { grid-template-columns: auto minmax(0, 1fr); align-items: start; }
|
||||
.attention-bar .el-button { grid-column: 1 / -1; width: 100%; }
|
||||
.history-note { align-items: flex-start; flex-direction: column; }
|
||||
.live-hero { padding: 17px; }
|
||||
.live-hero__count { font-size: 34px; }
|
||||
.active-session time { font-size: 12px; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.header-actions { width: 100%; }
|
||||
.header-actions .el-button { flex: 1; margin-left: 0; }
|
||||
.active-session { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.active-session time { grid-column: 2; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useRoute } from "vue-router";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import RightDrawer from "@/components/ui/RightDrawer.vue";
|
||||
import SafeAvatar from "@/components/ui/SafeAvatar.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
@@ -25,6 +27,7 @@ import { House, RefreshRight, SwitchButton, VideoCamera } from "@element-plus/ic
|
||||
|
||||
const inheritValue = "__inherit__";
|
||||
const AUTO_REFRESH_INTERVAL_MS = 15000;
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
@@ -687,6 +690,7 @@ function nullableStringFromSelect(value: string | number) {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRooms();
|
||||
if (route.query.action === "create") openCreateDialog();
|
||||
startAutoRefresh();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
@@ -787,7 +791,7 @@ onBeforeUnmount(() => {
|
||||
:status="row.currentRecordingState"
|
||||
context="recording"
|
||||
/>
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
<PlatformMark :name="row.platformName" />
|
||||
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
|
||||
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
|
||||
</div>
|
||||
@@ -892,7 +896,7 @@ onBeforeUnmount(() => {
|
||||
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
|
||||
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
|
||||
<div class="config-summary">
|
||||
<span>{{ row.platformName || "--" }}</span>
|
||||
<PlatformMark :name="row.platformName" />
|
||||
<span>{{ row.roomId || "--" }}</span>
|
||||
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
|
||||
</div>
|
||||
@@ -1025,7 +1029,7 @@ onBeforeUnmount(() => {
|
||||
:status="activeRoom.currentRecordingState"
|
||||
context="recording"
|
||||
/>
|
||||
<StatusBadge :label="activeRoom.platformName || '--'" :status="activeRoom.platformName || 'unknown'" />
|
||||
<PlatformMark :name="activeRoom.platformName" />
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border class="detail-panel__descriptions">
|
||||
@@ -1033,7 +1037,7 @@ onBeforeUnmount(() => {
|
||||
{{ activeRoom.title || activeRoom.anchorName || activeRoom.roomId || "--" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台 + Room ID">
|
||||
{{ activeRoom.platformName || "--" }} · {{ activeRoom.roomId || "--" }}
|
||||
<span class="platform-room-id"><PlatformMark :name="activeRoom.platformName" /><span>· {{ activeRoom.roomId || "--" }}</span></span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直播状态">
|
||||
{{ roomAvailabilityLabel(activeRoom) }}
|
||||
@@ -1453,6 +1457,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-room-id { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
@@ -1691,6 +1696,10 @@ onBeforeUnmount(() => {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.detail-panel__hero > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-panel__title {
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
@@ -1922,6 +1931,17 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-panel__hero {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail-panel__descriptions :deep(.el-descriptions__label) {
|
||||
width: 104px;
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.header-actions :deep(.el-space__item) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Lock, Monitor, Moon, Sunny, User } from "@element-plus/icons-vue";
|
||||
import { ArrowRight, Lock, Monitor, Moon, Sunny, User, VideoCamera } from "@element-plus/icons-vue";
|
||||
import { getApiErrorMessage, isBackendUnavailableError } from "@/api/client";
|
||||
import { useBackendStatus } from "@/composables/useBackendStatus";
|
||||
import { useUiPreferences } from "@/composables/useUiPreferences";
|
||||
@@ -37,7 +37,7 @@ async function handleLogin() {
|
||||
try {
|
||||
await authStore.login(form.username, form.password);
|
||||
ElMessage.success("登录成功");
|
||||
await router.push({ name: "live-rooms" });
|
||||
await router.push({ name: "dashboard" });
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
isBackendUnavailableError(error)
|
||||
@@ -52,14 +52,22 @@ async function handleLogin() {
|
||||
|
||||
<template>
|
||||
<div class="login-screen">
|
||||
<section class="login-panel surface-card">
|
||||
<div class="login-panel__header">
|
||||
<div>
|
||||
<div class="login-panel__eyebrow">Live Recorder</div>
|
||||
<h1 class="login-panel__title">登录</h1>
|
||||
</div>
|
||||
<section class="login-brand" aria-label="Live Recorder 产品介绍">
|
||||
<div class="login-brand__logo">
|
||||
<span><el-icon :size="22"><VideoCamera /></el-icon></span>
|
||||
<strong>Live Recorder</strong>
|
||||
</div>
|
||||
<div class="login-brand__message">
|
||||
<span class="login-brand__status"><i />本地服务运行正常</span>
|
||||
<h1>让每一次开播,<br>都有迹可循。</h1>
|
||||
<p>集中监控直播状态、自动完成录制与归档,并在异常发生时第一时间告诉你。</p>
|
||||
</div>
|
||||
<div class="login-brand__meta">SELF-HOSTED · PRIVATE · RELIABLE</div>
|
||||
</section>
|
||||
|
||||
<el-select v-model="themeMode" size="small" class="login-panel__theme-select">
|
||||
<section class="login-panel">
|
||||
<div class="login-panel__toolbar">
|
||||
<el-select v-model="themeMode" size="small" aria-label="界面主题" class="login-panel__theme-select">
|
||||
<template #prefix>
|
||||
<el-icon><component :is="currentThemeIcon" /></el-icon>
|
||||
</template>
|
||||
@@ -69,41 +77,49 @@ async function handleLogin() {
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="backendUnavailable"
|
||||
class="login-panel__alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="后端服务暂时不可用"
|
||||
:description="backendMessage"
|
||||
/>
|
||||
<div class="login-card surface-card">
|
||||
<div class="login-card__header">
|
||||
<div class="login-panel__eyebrow">LIVE RECORDER</div>
|
||||
<h2 class="login-panel__title">欢迎回来</h2>
|
||||
<p>登录后进入 Live Recorder 运行中心</p>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
:prefix-icon="User"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
v-if="backendUnavailable"
|
||||
class="login-panel__alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="后端服务暂时不可用"
|
||||
:description="backendMessage"
|
||||
/>
|
||||
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
:prefix-icon="Lock"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
:prefix-icon="User"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
:prefix-icon="Lock"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
|
||||
登录控制台 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -112,67 +128,48 @@ async function handleLogin() {
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
grid-template-columns: minmax(420px, .95fr) minmax(480px, 1.05fr);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(100%, 420px);
|
||||
padding: 24px;
|
||||
.login-brand { position: relative; display: flex; flex-direction: column; min-height: 100vh; overflow: hidden; padding: clamp(32px, 5vw, 68px); border-right: 1px solid var(--border-subtle); background: radial-gradient(circle at 12% 8%, rgba(86,137,255,.18), transparent 34%), radial-gradient(circle at 92% 88%, rgba(146,119,255,.16), transparent 36%), linear-gradient(145deg, var(--accent-soft-2), var(--surface) 58%, var(--purple-soft)); }
|
||||
.login-brand::after { content: ""; position: absolute; right: -170px; bottom: -180px; width: 480px; height: 480px; border: 75px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; }
|
||||
.login-brand__logo { position: relative; z-index: 1; display: flex; align-items: center; gap: 12px; color: var(--text-primary); font-size: 15px; }
|
||||
.login-brand__logo > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; color: #fff; background: var(--action); box-shadow: 0 9px 24px color-mix(in srgb, var(--action) 26%, transparent); }
|
||||
.login-brand__message { position: relative; z-index: 1; width: min(100%, 570px); margin: auto 0; }
|
||||
.login-brand__status { display: inline-flex; align-items: center; gap: 9px; color: var(--success); font-size: 11px; font-weight: 750; }
|
||||
.login-brand__status i { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 5px color-mix(in srgb, var(--success) 14%, transparent); }
|
||||
.login-brand__message h1 { margin: 24px 0 0; color: var(--text-primary); font-size: clamp(40px, 5vw, 66px); font-weight: 800; letter-spacing: -.065em; line-height: 1.08; }
|
||||
.login-brand__message p { max-width: 500px; margin: 24px 0 0; color: var(--text-secondary); font-size: 14px; line-height: 1.8; }
|
||||
.login-brand__meta { position: relative; z-index: 1; color: var(--text-muted); font-size: 9px; font-weight: 750; letter-spacing: .18em; }
|
||||
.login-panel { position: relative; display: grid; place-items: center; min-height: 100vh; padding: 70px clamp(24px, 7vw, 96px); background: var(--surface); }
|
||||
.login-panel__toolbar { position: absolute; top: 24px; right: 28px; }
|
||||
.login-card { width: min(100%, 420px); padding: 30px; box-shadow: var(--shadow-sm); }
|
||||
.login-card__header { margin-bottom: 24px; }
|
||||
.login-card__header p { margin: 8px 0 0; color: var(--text-muted); font-size: 12px; }
|
||||
.login-panel__eyebrow { color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .12em; }
|
||||
.login-panel__title { margin: 7px 0 0; color: var(--text-primary); font-size: 28px; font-weight: 780; letter-spacing: -.045em; }
|
||||
.login-panel__theme-select { width: 122px; }
|
||||
.login-panel__alert { margin-bottom: 18px; }
|
||||
.login-form__submit { width: 100%; margin-top: 8px; }
|
||||
:global(html[data-theme="dark"]) .login-brand { background: radial-gradient(circle at 12% 8%, rgba(58,112,220,.22), transparent 34%), radial-gradient(circle at 92% 88%, rgba(119,88,207,.19), transparent 36%), linear-gradient(145deg, #111d30, #172235 58%, #1c223b); }
|
||||
@media (max-width: 900px) {
|
||||
.login-screen { grid-template-columns: 1fr; min-height: 100dvh; }
|
||||
.login-brand { min-height: 240px; padding: 26px 24px 30px; }
|
||||
.login-brand__message { margin: 38px 0 0; }
|
||||
.login-brand__message h1 { margin-top: 16px; font-size: 34px; }
|
||||
.login-brand__message p { margin-top: 13px; font-size: 12px; line-height: 1.65; }
|
||||
.login-brand__meta { display: none; }
|
||||
.login-panel { min-height: 0; padding: 70px 18px 28px; }
|
||||
.login-panel__toolbar { top: 18px; right: 18px; }
|
||||
}
|
||||
|
||||
.login-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-panel__eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-panel__title {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 28px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.login-panel__theme-select {
|
||||
width: 122px;
|
||||
}
|
||||
|
||||
.login-panel__alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-form__submit {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.login-screen {
|
||||
padding: 18px 14px 24px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-panel__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-panel__theme-select {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.login-brand { min-height: 218px; padding: 21px 18px 26px; }
|
||||
.login-brand__logo > span { width: 34px; height: 34px; }
|
||||
.login-brand__message { margin-top: 30px; }
|
||||
.login-brand__status { display: none; }
|
||||
.login-brand__message h1 { margin-top: 0; font-size: 29px; }
|
||||
.login-brand__message p { display: none; }
|
||||
.login-panel { padding-inline: 14px; }
|
||||
.login-card { padding: 22px 18px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
|
||||
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import type {
|
||||
RecordArtifactUploadBatchResult,
|
||||
RecordPreviewTicket,
|
||||
@@ -28,6 +30,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// Danmaku replay dialog
|
||||
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
|
||||
@@ -301,14 +304,20 @@ onMounted(loadDetail);
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="会话状态">
|
||||
<el-tag :type="sessionStatusTagType(detail.session.status)">
|
||||
{{ sessionStatusLabelMap[detail.session.status] }}
|
||||
{{ detail.session.isRecovering ? "恢复中" : sessionStatusLabelMap[detail.session.status] }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.session.isRecovering" label="恢复进度">
|
||||
<span class="recovery-status-copy">{{ detail.session.recoveryReason || "正在等待下一次自动重试" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直播间">
|
||||
{{ detail.session.liveRoomTitle }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="主播">
|
||||
{{ detail.session.anchorName || "未知主播" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">
|
||||
{{ platformLabelMap[detail.session.platform] }}
|
||||
<PlatformMark :name="platformLabelMap[detail.session.platform]" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Room ID">
|
||||
<span class="monospace">{{ detail.session.roomId }}</span>
|
||||
@@ -510,7 +519,40 @@ onMounted(loadDetail);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll-shell">
|
||||
<div v-if="isMobile" class="segment-card-list">
|
||||
<article v-for="row in detail.timeline.segments" :key="row.recordTaskId" class="segment-card">
|
||||
<div class="segment-card__head">
|
||||
<span>
|
||||
<strong class="monospace">分片 #{{ row.segmentIndex }}</strong>
|
||||
<small>{{ formatDuration(row.durationSeconds) }}</small>
|
||||
</span>
|
||||
<el-tag :type="sessionStatusTagType(row.status)">{{ taskStatusLabelMap[row.status] }}</el-tag>
|
||||
</div>
|
||||
|
||||
<dl class="segment-card__facts">
|
||||
<div class="segment-card__file">
|
||||
<dt>文件</dt>
|
||||
<dd class="monospace">{{ row.label || "-" }}</dd>
|
||||
</div>
|
||||
<div><dt>开始</dt><dd>{{ formatDate(row.startedAt) }}</dd></div>
|
||||
<div><dt>结束</dt><dd>{{ formatDate(row.endedAt) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="segment-card__actions">
|
||||
<el-button size="small" type="primary" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
plain
|
||||
:disabled="row.status !== 4 && row.status !== 6"
|
||||
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
|
||||
>
|
||||
弹幕回放
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-scroll-shell">
|
||||
<el-table :data="detail.timeline.segments" class="premium-table" table-layout="auto">
|
||||
<el-table-column label="分片" width="90">
|
||||
<template #default="{ row }">
|
||||
@@ -789,6 +831,91 @@ onMounted(loadDetail);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.segment-card-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.segment-card {
|
||||
display: grid;
|
||||
gap: 13px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.segment-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.segment-card__head > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.segment-card__head strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.segment-card__head small {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.segment-card__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.segment-card__facts > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.segment-card__facts dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.segment-card__facts dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.segment-card__file {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.segment-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.segment-card__actions :deep(.el-button) {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.segment-label,
|
||||
.log-detail {
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElNotification } from "element-plus";
|
||||
import { Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
|
||||
import { ArrowLeft, ArrowRight, Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
|
||||
import apiClient, {
|
||||
buildApiUrl,
|
||||
getApiErrorMessage,
|
||||
@@ -1104,12 +1104,14 @@ onBeforeUnmount(() => {
|
||||
<div class="data-card__header">
|
||||
<div>
|
||||
<div class="data-card__title">{{ session.liveRoomTitle }}</div>
|
||||
<div class="data-card__subtitle monospace">{{ session.roomId }}</div>
|
||||
<div class="session-card__anchor">主播 · {{ session.anchorName || "未知主播" }}</div>
|
||||
<div class="data-card__subtitle monospace">Room ID · {{ session.roomId }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
<StatusBadge :label="session.isRecovering ? '恢复中' : sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
</div>
|
||||
|
||||
<div class="badge-row">
|
||||
<span v-if="session.isRecovering" class="info-pill">自动重试中</span>
|
||||
<span class="info-pill">{{ saveModeLabelMap[session.saveMode] }}</span>
|
||||
<span class="info-pill">{{ outputFormatLabelMap[session.outputFormat] }}</span>
|
||||
<span class="info-pill">分片 {{ session.segmentCount }}</span>
|
||||
@@ -1225,6 +1227,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="session-title__main">
|
||||
<div class="session-title__name">{{ session.liveRoomTitle }}</div>
|
||||
<div class="session-title__anchor">主播 · {{ session.anchorName || "未知主播" }}</div>
|
||||
<div class="session-title__meta">
|
||||
<span class="monospace">{{ session.roomId }}</span>
|
||||
<span>会话 {{ session.id.slice(0, 8) }}</span>
|
||||
@@ -1232,7 +1235,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="session-title__stats">
|
||||
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
<StatusBadge :label="session.isRecovering ? '恢复中' : sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
<span>{{ saveModeLabelMap[session.saveMode] }}</span>
|
||||
<span>{{ outputFormatLabelMap[session.outputFormat] }}</span>
|
||||
<span>分片 {{ session.segmentCount }}</span>
|
||||
@@ -1245,6 +1248,15 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<div class="session-body">
|
||||
<el-alert
|
||||
v-if="session.isRecovering"
|
||||
class="session-recovery-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="录制连接正在自动恢复"
|
||||
:description="session.recoveryReason || '系统会在直播仍在线时持续重试,并保留当前会话。'"
|
||||
/>
|
||||
<div class="session-summary">
|
||||
<div class="session-summary__item">
|
||||
<span class="session-summary__label">开始时间</span>
|
||||
@@ -1370,7 +1382,24 @@ onBeforeUnmount(() => {
|
||||
</el-collapse>
|
||||
|
||||
<div v-if="totalCount > pageSize" class="session-pagination">
|
||||
<div v-if="isMobile" class="session-pagination__mobile" aria-label="会话分页">
|
||||
<el-button
|
||||
class="btn-prev"
|
||||
:icon="ArrowLeft"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
>上一页</el-button>
|
||||
<span class="session-pagination__position" aria-live="polite">
|
||||
<strong>{{ currentPage }}</strong><span>/ {{ totalPages }}</span>
|
||||
</span>
|
||||
<el-button
|
||||
class="btn-next"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
>下一页 <el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-else
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="currentPage"
|
||||
@@ -1612,9 +1641,43 @@ onBeforeUnmount(() => {
|
||||
.session-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.session-pagination__mobile {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-pagination__mobile :deep(.el-button) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-pagination__position {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 54px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-pagination__position strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -1742,6 +1805,14 @@ onBeforeUnmount(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-card__anchor,
|
||||
.session-title__anchor {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.session-card__tasks {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import axios from "axios";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
RecoverableFinalization,
|
||||
RecoverableLiveRoom,
|
||||
RecordingFailureItem,
|
||||
RecordingFailureListResponse,
|
||||
RecoveryActionResult,
|
||||
RecoveryOverview
|
||||
} from "@/types";
|
||||
@@ -23,11 +26,29 @@ const resumeAllLoading = ref(false);
|
||||
const runningKey = ref("");
|
||||
const loadError = ref("");
|
||||
const overview = ref<RecoveryOverview | null>(null);
|
||||
const recordingFailures = ref<RecordingFailureItem[]>([]);
|
||||
const recordingFailureTotal = ref(0);
|
||||
const recordingFailurePage = ref(1);
|
||||
const recordingFailureKind = ref("");
|
||||
const recordingFailureLoading = ref(false);
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
const storage = computed(() => overview.value?.storage ?? null);
|
||||
const liveRooms = computed(() => overview.value?.liveRooms ?? []);
|
||||
const finalizations = computed(() => overview.value?.finalizations ?? []);
|
||||
const mergedArtifacts = computed(() => overview.value?.mergedArtifacts ?? []);
|
||||
const recordingFailurePageSize = computed(() => isMobile.value ? 8 : 20);
|
||||
const recordingFailureKinds = [
|
||||
{ label: "全部原因", value: "" },
|
||||
{ label: "异常退出分片", value: "ReadableFragment" },
|
||||
{ label: "短分片", value: "TooShort" },
|
||||
{ label: "媒体不可读", value: "UnreadableMedia" },
|
||||
{ label: "封装或收尾失败", value: "FinalizationFailed" },
|
||||
{ label: "文件缺失", value: "MissingMedia" },
|
||||
{ label: "直播离线", value: "SourceOffline" },
|
||||
{ label: "未取得播放地址", value: "StreamUrlUnavailable" },
|
||||
{ label: "待检测", value: "Unknown" }
|
||||
];
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true;
|
||||
@@ -46,6 +67,80 @@ async function loadOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecordingFailures() {
|
||||
recordingFailureLoading.value = true;
|
||||
try {
|
||||
const { data } = await apiClient.get<RecordingFailureListResponse>("/recovery/recording-failures", {
|
||||
params: {
|
||||
failureKind: recordingFailureKind.value || undefined,
|
||||
skip: (recordingFailurePage.value - 1) * recordingFailurePageSize.value,
|
||||
take: recordingFailurePageSize.value
|
||||
}
|
||||
});
|
||||
recordingFailures.value = data.items;
|
||||
recordingFailureTotal.value = data.totalCount;
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "录制失败产物加载失败。"));
|
||||
} finally {
|
||||
recordingFailureLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRecordingFailureFilter() {
|
||||
recordingFailurePage.value = 1;
|
||||
void loadRecordingFailures();
|
||||
}
|
||||
|
||||
function handleRecordingFailurePage(page: number) {
|
||||
recordingFailurePage.value = page;
|
||||
void loadRecordingFailures();
|
||||
}
|
||||
|
||||
async function acceptRecordingArtifact(item: RecordingFailureItem) {
|
||||
let confirmShortArtifact = false;
|
||||
if ((item.durationSeconds ?? 0) < 5) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`该分片只有 ${formatDuration(item.durationSeconds)}。确认后它会转为待上传,但不会自动上传。`,
|
||||
"确认认领短分片",
|
||||
{ confirmButtonText: "确认认领", cancelButtonText: "取消", type: "warning" }
|
||||
);
|
||||
confirmShortArtifact = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
runningKey.value = `accept:${item.recordTaskId}`;
|
||||
try {
|
||||
const { data } = await apiClient.post<RecoveryActionResult>(
|
||||
`/recovery/recording-failures/${item.recordTaskId}/accept`,
|
||||
{ confirmShortArtifact }
|
||||
);
|
||||
showActionResult(data);
|
||||
await Promise.all([loadOverview(), loadRecordingFailures()]);
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "分片校验未通过。"));
|
||||
} finally {
|
||||
runningKey.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function repairRecordingArtifact(item: RecordingFailureItem) {
|
||||
runningKey.value = `repair:${item.recordTaskId}`;
|
||||
try {
|
||||
const { data } = await apiClient.post<RecoveryActionResult>(
|
||||
`/recovery/recording-failures/${item.recordTaskId}/repair`
|
||||
);
|
||||
showActionResult(data);
|
||||
await loadRecordingFailures();
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "无法启动非破坏修复。"));
|
||||
} finally {
|
||||
runningKey.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function retryLiveRoom(liveRoomId: string) {
|
||||
runningKey.value = `retry:${liveRoomId}`;
|
||||
|
||||
@@ -114,8 +209,8 @@ function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
function formatBytes(bytes?: number) {
|
||||
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
@@ -130,6 +225,13 @@ function formatBytes(bytes: number) {
|
||||
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds)) {
|
||||
return "未知时长";
|
||||
}
|
||||
return seconds < 60 ? `${seconds.toFixed(seconds < 10 ? 1 : 0)} 秒` : `${(seconds / 60).toFixed(1)} 分钟`;
|
||||
}
|
||||
|
||||
function autoStartDecisionLabel(code?: string) {
|
||||
if (!code) {
|
||||
return "未记录";
|
||||
@@ -171,7 +273,9 @@ function finalizationTitle(item: RecoverableFinalization) {
|
||||
return item.liveRoomTitle || item.roomId;
|
||||
}
|
||||
|
||||
onMounted(loadOverview);
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadOverview(), loadRecordingFailures()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -195,12 +299,200 @@ onMounted(loadOverview);
|
||||
<div class="stats-grid recovery-metrics" v-loading="loading">
|
||||
<MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
|
||||
<MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
|
||||
<MetricCard label="已收纳短片" :value="mergedArtifacts.length" description="已合并或转存到恢复目录的源分片" :icon="VideoCamera" />
|
||||
</div>
|
||||
|
||||
<el-card v-if="storage" class="surface-card" shadow="never">
|
||||
<StorageCapacity :status="storage" />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="mergedArtifacts.length > 0" class="surface-card table-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">短分片合并记录</h3>
|
||||
<p class="section-subtitle">源文件不会被覆盖;恢复目录和清单可用于审计或人工还原。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="data-card-list">
|
||||
<article v-for="row in mergedArtifacts" :key="row.sourceRecordTaskId" class="data-card">
|
||||
<div class="data-card__header">
|
||||
<div>
|
||||
<div class="data-card__title">源分片 {{ row.sourceRecordTaskId.slice(0, 8) }}</div>
|
||||
<div class="data-card__subtitle">{{ formatDuration(row.sourceDurationSeconds) }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="row.mergedIntoRecordTaskId ? '已合并' : '仅恢复保留'" status="completed" />
|
||||
</div>
|
||||
<div class="data-card__grid">
|
||||
<div style="grid-column: 1 / -1;"><dt>合并结果</dt><dd class="monospace failure-path">{{ row.mergedVideoPath || "未生成独立结果" }}</dd></div>
|
||||
<div style="grid-column: 1 / -1;"><dt>恢复目录</dt><dd class="monospace failure-path">{{ row.recoveryDirectory || "-" }}</dd></div>
|
||||
<div style="grid-column: 1 / -1;"><dt>恢复清单</dt><dd class="monospace failure-path">{{ row.manifestPath || "-" }}</dd></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<el-table v-else :data="mergedArtifacts" class="premium-table" table-layout="fixed" row-key="sourceRecordTaskId">
|
||||
<el-table-column label="源分片" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title monospace">{{ row.sourceRecordTaskId.slice(0, 8) }}</div>
|
||||
<div class="cell-subtitle">{{ formatDuration(row.sourceDurationSeconds) }} · {{ formatDate(row.createdAt) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合并结果" min-width="280">
|
||||
<template #default="{ row }"><div class="monospace failure-path">{{ row.mergedVideoPath || "仅恢复保留" }}</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="恢复目录 / 清单" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="monospace failure-path">{{ row.recoveryDirectory || "-" }}</div>
|
||||
<div class="cell-subtitle monospace failure-path">{{ row.manifestPath || "未找到清单" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">录制失败产物</h3>
|
||||
<p class="section-subtitle">先区分可用分片、可修复媒体和不可恢复记录;所有修复都会保留原文件。</p>
|
||||
</div>
|
||||
<div class="recovery-filter-actions">
|
||||
<el-select
|
||||
v-model="recordingFailureKind"
|
||||
aria-label="录制失败原因筛选"
|
||||
@change="handleRecordingFailureFilter"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in recordingFailureKinds"
|
||||
:key="option.value || 'all'"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button :loading="recordingFailureLoading" @click="loadRecordingFailures">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="!recordingFailureLoading && recordingFailures.length === 0"
|
||||
title="没有匹配的录制失败产物"
|
||||
description="当前分类下没有需要人工处理的文件。"
|
||||
action-text="刷新列表"
|
||||
@action="loadRecordingFailures"
|
||||
/>
|
||||
|
||||
<div v-else-if="isMobile" class="data-card-list" v-loading="recordingFailureLoading">
|
||||
<article v-for="row in recordingFailures" :key="row.recordTaskId" class="data-card failure-card">
|
||||
<div class="data-card__header">
|
||||
<div class="failure-card__identity">
|
||||
<div class="data-card__title">{{ row.liveRoomTitle }}</div>
|
||||
<div class="data-card__subtitle platform-line">
|
||||
<PlatformMark :name="row.platformName" /> · 分片 #{{ row.segmentIndex }}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge :label="row.failureLabel" :status="row.isRepairing ? 'processing' : 'warning'" />
|
||||
</div>
|
||||
|
||||
<p class="failure-card__action">{{ row.recommendedAction }}</p>
|
||||
<div class="data-card__grid">
|
||||
<div><dt>媒体状态</dt><dd>{{ row.fileExists ? "本地文件存在" : "本地文件缺失" }}</dd></div>
|
||||
<div><dt>时长 / 大小</dt><dd>{{ formatDuration(row.durationSeconds) }} · {{ formatBytes(row.fileSizeBytes) }}</dd></div>
|
||||
<div v-if="row.filePath" style="grid-column: 1 / -1;"><dt>文件</dt><dd class="monospace failure-path">{{ row.filePath }}</dd></div>
|
||||
<div v-if="row.errorMessage" style="grid-column: 1 / -1;"><dt>原始错误</dt><dd class="failure-error">{{ row.errorMessage }}</dd></div>
|
||||
</div>
|
||||
|
||||
<div class="data-card__actions failure-actions">
|
||||
<el-button
|
||||
v-if="row.canAccept"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="runningKey === `accept:${row.recordTaskId}`"
|
||||
@click="acceptRecordingArtifact(row)"
|
||||
>确认有效</el-button>
|
||||
<el-button
|
||||
v-if="row.canRepair"
|
||||
size="small"
|
||||
:loading="runningKey === `repair:${row.recordTaskId}`"
|
||||
@click="repairRecordingArtifact(row)"
|
||||
>非破坏修复</el-button>
|
||||
<el-button
|
||||
v-if="row.canRetryRoom"
|
||||
size="small"
|
||||
:loading="runningKey === `retry:${row.liveRoomId}`"
|
||||
@click="retryLiveRoom(row.liveRoomId)"
|
||||
>重新开录</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
:data="recordingFailures"
|
||||
v-loading="recordingFailureLoading"
|
||||
class="premium-table"
|
||||
table-layout="fixed"
|
||||
row-key="recordTaskId"
|
||||
>
|
||||
<el-table-column label="直播间 / 分片" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ row.liveRoomTitle }}</div>
|
||||
<div class="cell-subtitle platform-line"><PlatformMark :name="row.platformName" /> · #{{ row.segmentIndex }}</div>
|
||||
<div class="cell-mono monospace">{{ row.roomId }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因与建议" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<StatusBadge :label="row.failureLabel" :status="row.isRepairing ? 'processing' : 'warning'" />
|
||||
<div class="cell-subtitle failure-recommendation">{{ row.recommendedAction }}</div>
|
||||
<div v-if="row.errorMessage" class="failure-error line-clamp-2">{{ row.errorMessage }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本地产物" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="monospace failure-path">{{ row.filePath || "-" }}</div>
|
||||
<div class="cell-subtitle">{{ formatDuration(row.durationSeconds) }} · {{ formatBytes(row.fileSizeBytes) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="failure-actions failure-actions--desktop">
|
||||
<el-button
|
||||
v-if="row.canAccept"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="runningKey === `accept:${row.recordTaskId}`"
|
||||
@click="acceptRecordingArtifact(row)"
|
||||
>确认有效</el-button>
|
||||
<el-button
|
||||
v-if="row.canRepair"
|
||||
size="small"
|
||||
:loading="runningKey === `repair:${row.recordTaskId}`"
|
||||
@click="repairRecordingArtifact(row)"
|
||||
>修复</el-button>
|
||||
<el-button
|
||||
v-if="row.canRetryRoom"
|
||||
size="small"
|
||||
:loading="runningKey === `retry:${row.liveRoomId}`"
|
||||
@click="retryLiveRoom(row.liveRoomId)"
|
||||
>开录</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="recordingFailureTotal > recordingFailurePageSize" class="pagination-row">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:pager-count="isMobile ? 3 : 7"
|
||||
:page-size="recordingFailurePageSize"
|
||||
:total="recordingFailureTotal"
|
||||
:current-page="recordingFailurePage"
|
||||
@current-change="handleRecordingFailurePage"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
@@ -233,7 +525,7 @@ onMounted(loadOverview);
|
||||
<div class="data-card__title">{{ liveRoomTitle(row) }}</div>
|
||||
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
<PlatformMark :name="row.platformName" />
|
||||
</div>
|
||||
|
||||
<div class="badge-row">
|
||||
@@ -291,7 +583,7 @@ onMounted(loadOverview);
|
||||
|
||||
<el-table-column label="平台" width="120">
|
||||
<template #default="{ row }">
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
<PlatformMark :name="row.platformName" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -364,7 +656,7 @@ onMounted(loadOverview);
|
||||
<div class="data-card__header">
|
||||
<div>
|
||||
<div class="data-card__title">{{ finalizationTitle(row) }}</div>
|
||||
<div class="data-card__subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
|
||||
<div class="data-card__subtitle platform-line"><PlatformMark :name="row.platformName" /> · Segment #{{ row.segmentIndex }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
|
||||
</div>
|
||||
@@ -412,7 +704,7 @@ onMounted(loadOverview);
|
||||
<el-table-column label="任务" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ finalizationTitle(row) }}</div>
|
||||
<div class="cell-subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
|
||||
<div class="cell-subtitle platform-line"><PlatformMark :name="row.platformName" /> · Segment #{{ row.segmentIndex }}</div>
|
||||
<div class="cell-mono monospace">{{ row.roomId }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -477,4 +769,65 @@ onMounted(loadOverview);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.recovery-filter-actions,
|
||||
.failure-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.recovery-filter-actions :deep(.el-select) {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.failure-card__identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.failure-card__action,
|
||||
.failure-recommendation {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.failure-card__action {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.failure-path,
|
||||
.failure-error {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.failure-error {
|
||||
margin-top: 6px;
|
||||
color: var(--el-color-danger);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.failure-actions--desktop {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.recovery-filter-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recovery-filter-actions :deep(.el-select) {
|
||||
flex: 1 1 180px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type { RecordArtifactUploadItemResult, UploadTaskItem, UploadTaskListResponse } from "@/types";
|
||||
import type {
|
||||
RecordArtifactUploadBatchResult,
|
||||
RecordArtifactUploadItemResult,
|
||||
UploadQueueHealth,
|
||||
UploadTaskItem,
|
||||
UploadTaskListResponse
|
||||
} from "@/types";
|
||||
import {
|
||||
platformLabelMap,
|
||||
uploadStatusLabelMap
|
||||
@@ -28,6 +35,8 @@ const failedCount = ref(0);
|
||||
const queuedCount = ref(0);
|
||||
const uploadingCount = ref(0);
|
||||
const waitingRetryCount = ref(0);
|
||||
const matchingRetryableCount = ref(0);
|
||||
const queueHealth = ref<UploadQueueHealth>({ state: "Healthy", isPaused: false });
|
||||
const uploadStatusFilter = ref<number | null>(null);
|
||||
const taskSearch = ref("");
|
||||
const currentPage = ref(1);
|
||||
@@ -39,6 +48,7 @@ let refreshTimer: number | null = null;
|
||||
let activeRequest: AbortController | null = null;
|
||||
let requestSerial = 0;
|
||||
let appMain: HTMLElement | null = null;
|
||||
let searchTimer: number | null = null;
|
||||
|
||||
const filterOptions = [
|
||||
{ label: "全部", value: null as number | null },
|
||||
@@ -51,21 +61,7 @@ const filterOptions = [
|
||||
];
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)));
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = taskSearch.value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return items.value;
|
||||
}
|
||||
|
||||
return items.value.filter((item) => [
|
||||
item.liveRoomTitle,
|
||||
item.roomId,
|
||||
item.filePath,
|
||||
item.remoteVideoPath,
|
||||
item.uploadErrorMessage,
|
||||
item.recordTaskId
|
||||
].some((value) => String(value || "").toLowerCase().includes(keyword)));
|
||||
});
|
||||
const filteredItems = computed(() => items.value);
|
||||
|
||||
function mergeItems(nextItems: UploadTaskItem[]) {
|
||||
const previousById = new Map(items.value.map((item) => [item.recordTaskId, item]));
|
||||
@@ -120,6 +116,9 @@ async function loadUploadStatus(options: { background?: boolean; cancelPrevious?
|
||||
if (uploadStatusFilter.value !== null) {
|
||||
params.uploadStatus = uploadStatusFilter.value;
|
||||
}
|
||||
if (taskSearch.value.trim()) {
|
||||
params.query = taskSearch.value.trim();
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<UploadTaskListResponse>("/record-tasks/upload-status", {
|
||||
params,
|
||||
@@ -145,6 +144,8 @@ async function loadUploadStatus(options: { background?: boolean; cancelPrevious?
|
||||
queuedCount.value = data.queuedCount;
|
||||
uploadingCount.value = data.uploadingCount;
|
||||
waitingRetryCount.value = data.waitingRetryCount;
|
||||
matchingRetryableCount.value = data.matchingRetryableCount ?? 0;
|
||||
queueHealth.value = data.queueHealth ?? { state: "Healthy", isPaused: false };
|
||||
await nextTick();
|
||||
if (options.preserveScroll !== false && appMain) {
|
||||
appMain.scrollTop = scrollTop;
|
||||
@@ -179,7 +180,11 @@ async function uploadTask(task: UploadTaskItem) {
|
||||
uploadingTaskId.value = task.recordTaskId;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${task.recordTaskId}/upload`);
|
||||
const retrying = [2, 5].includes(task.uploadStatus);
|
||||
const endpoint = retrying
|
||||
? `/record-tasks/${task.recordTaskId}/upload/retry`
|
||||
: `/record-tasks/${task.recordTaskId}/upload`;
|
||||
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(endpoint);
|
||||
ElMessage[data.success ? "success" : "warning"](data.message);
|
||||
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
|
||||
} catch (error) {
|
||||
@@ -190,32 +195,31 @@ async function uploadTask(task: UploadTaskItem) {
|
||||
}
|
||||
|
||||
async function retryAllFailed() {
|
||||
const failedItems = items.value.filter(item => item.uploadStatus === 2);
|
||||
if (failedItems.length === 0) {
|
||||
ElMessage.info("当前没有上传失败的任务。");
|
||||
const retryableCount = matchingRetryableCount.value;
|
||||
if (retryableCount === 0) {
|
||||
ElMessage.info("当前筛选条件下没有可重试任务。");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将立即重试全部 ${retryableCount} 个匹配任务,不受当前分页限制。`,
|
||||
"重试全部匹配任务",
|
||||
{ confirmButtonText: "确认重试", cancelButtonText: "取消", type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
retryingFailed.value = true;
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
try {
|
||||
for (const item of failedItems) {
|
||||
try {
|
||||
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${item.recordTaskId}/upload`);
|
||||
if (data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage[successCount > 0 ? "success" : "warning"](
|
||||
`重试请求已处理:已受理 ${successCount},失败 ${failCount}。`
|
||||
const status = [2, 5].includes(uploadStatusFilter.value ?? -1) ? uploadStatusFilter.value : null;
|
||||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>("/record-tasks/upload/retry", {
|
||||
uploadStatus: status,
|
||||
query: taskSearch.value.trim() || null
|
||||
});
|
||||
ElMessage[data.successCount > 0 ? "success" : "warning"](
|
||||
`重试请求已处理:已受理 ${data.successCount},失败 ${data.failedCount}。`
|
||||
);
|
||||
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
|
||||
} finally {
|
||||
@@ -262,7 +266,7 @@ function openDetail(task: UploadTaskItem) {
|
||||
}
|
||||
|
||||
function canUpload(task: UploadTaskItem) {
|
||||
return ![1, 3, 4, 5].includes(task.uploadStatus);
|
||||
return ![1, 3, 4].includes(task.uploadStatus);
|
||||
}
|
||||
|
||||
function isUploadInProgress(task: UploadTaskItem) {
|
||||
@@ -307,11 +311,32 @@ function handleVisibilityChange() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeQueue() {
|
||||
try {
|
||||
const { data } = await apiClient.post<UploadQueueHealth>("/upload-queue/openlist/resume");
|
||||
queueHealth.value = data;
|
||||
ElMessage.success("OpenList 认证成功,上传队列已恢复。");
|
||||
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "OpenList 仍不可用,请检查账号、密码和限流状态。"));
|
||||
}
|
||||
}
|
||||
|
||||
watch(isWideDesktop, () => {
|
||||
currentPage.value = 1;
|
||||
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }).finally(scheduleRefresh);
|
||||
});
|
||||
|
||||
watch(taskSearch, () => {
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = window.setTimeout(() => {
|
||||
currentPage.value = 1;
|
||||
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }).finally(scheduleRefresh);
|
||||
}, 350);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
appMain = document.querySelector<HTMLElement>(".app-main");
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
@@ -321,6 +346,9 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRefreshTimer();
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
}
|
||||
activeRequest?.abort();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
@@ -345,6 +373,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<div v-if="queueHealth.isPaused" class="queue-health-banner" role="status">
|
||||
<div>
|
||||
<strong>{{ queueHealth.state === "RateLimited" ? "OpenList 正在限流" : queueHealth.state === "Disabled" ? "上传队列已暂停" : "OpenList 认证需要处理" }}</strong>
|
||||
<p>{{ queueHealth.reason || "队列已停止发起新请求,任务重试次数不会继续消耗。" }}</p>
|
||||
<span v-if="queueHealth.retryAt">预计可重试:{{ formatDate(queueHealth.retryAt) }}</span>
|
||||
</div>
|
||||
<el-button v-if="queueHealth.state !== 'Disabled'" type="primary" @click="resumeQueue">验证并恢复队列</el-button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<MetricCard label="全部任务" :value="totalCount" :description="`已完成 ${succeededCount} 个`" :icon="Bell" />
|
||||
<MetricCard label="待上传" :value="notUploadedCount" description="尚未进入上传队列" :icon="UploadFilled" />
|
||||
@@ -361,7 +398,7 @@ onBeforeUnmount(() => {
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">上传队列</h3>
|
||||
<p class="section-subtitle">按状态和文件信息定位任务,批量操作仅作用于当前页。</p>
|
||||
<p class="section-subtitle">按状态和文件信息定位任务,批量重试会处理全部匹配结果。</p>
|
||||
</div>
|
||||
<div class="toolbar-row__actions">
|
||||
<el-button
|
||||
@@ -378,7 +415,7 @@ onBeforeUnmount(() => {
|
||||
:loading="retryingFailed"
|
||||
@click="retryAllFailed"
|
||||
>
|
||||
重试本页失败
|
||||
重试全部匹配
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -388,7 +425,7 @@ onBeforeUnmount(() => {
|
||||
<el-select v-model="uploadStatusFilter" placeholder="全部状态" aria-label="上传状态筛选" @change="handleFilterChange">
|
||||
<el-option v-for="opt in filterOptions" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<span class="list-filterbar__count">本页 {{ filteredItems.length }} / {{ items.length }}</span>
|
||||
<span class="list-filterbar__count">本页 {{ filteredItems.length }} / 共 {{ totalCount }}</span>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="initialLoading && items.length === 0" :rows="6" animated />
|
||||
@@ -407,7 +444,7 @@ onBeforeUnmount(() => {
|
||||
<div class="upload-item-card__header">
|
||||
<div class="upload-item-card__identity">
|
||||
<strong>{{ row.liveRoomTitle }}</strong>
|
||||
<span>{{ platformLabelMap[row.platform] ?? "-" }} · {{ row.roomId }} · 分片 #{{ row.segmentIndex }}</span>
|
||||
<span class="platform-line"><PlatformMark :name="platformLabelMap[row.platform] ?? '-'" /> · {{ row.roomId }} · 分片 #{{ row.segmentIndex }}</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
:label="uploadStatusLabelMap[row.uploadStatus]"
|
||||
@@ -445,7 +482,7 @@ onBeforeUnmount(() => {
|
||||
type="primary"
|
||||
:loading="uploadingTaskId === row.recordTaskId"
|
||||
@click="uploadTask(row)"
|
||||
>{{ row.uploadStatus === 2 ? "重试上传" : "立即上传" }}</el-button>
|
||||
>{{ [2, 5].includes(row.uploadStatus) ? "立即重试" : "立即上传" }}</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -461,7 +498,7 @@ onBeforeUnmount(() => {
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div class="cell-primary">{{ row.liveRoomTitle }}</div>
|
||||
<div class="cell-subtitle">{{ platformLabelMap[row.platform] ?? "-" }} · <span class="monospace">{{ row.roomId }}</span> · #{{ row.segmentIndex }}</div>
|
||||
<div class="cell-subtitle platform-line"><PlatformMark :name="platformLabelMap[row.platform] ?? '-'" /> · <span class="monospace">{{ row.roomId }}</span> · #{{ row.segmentIndex }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -513,7 +550,7 @@ onBeforeUnmount(() => {
|
||||
size="small"
|
||||
:loading="uploadingTaskId === row.recordTaskId"
|
||||
@click="uploadTask(row)"
|
||||
>{{ row.uploadStatus === 2 ? "重试" : "上传" }}</el-button>
|
||||
>{{ [2, 5].includes(row.uploadStatus) ? "重试" : "上传" }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -545,6 +582,47 @@ onBeforeUnmount(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.queue-health-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--el-color-warning) 34%, var(--border-subtle));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--el-color-warning) 9%, var(--surface-card));
|
||||
}
|
||||
|
||||
.queue-health-banner > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-health-banner strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.queue-health-banner p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.queue-health-banner span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.queue-health-banner {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
|
||||
@@ -63,6 +63,7 @@ const session = {
|
||||
id: "session-12345678",
|
||||
liveRoomId: room.id,
|
||||
liveRoomTitle: room.title,
|
||||
anchorName: room.anchorName,
|
||||
platform: 0,
|
||||
roomId: room.roomId,
|
||||
status: 4,
|
||||
@@ -82,6 +83,28 @@ const session = {
|
||||
tasks: [task]
|
||||
};
|
||||
|
||||
const sessionDetail = {
|
||||
session,
|
||||
timeline: {
|
||||
anchorAt: now,
|
||||
totalDurationSeconds: 5400,
|
||||
segments: Array.from({ length: 3 }, (_, index) => ({
|
||||
recordTaskId: `task-${index + 1}`,
|
||||
segmentIndex: index + 1,
|
||||
status: 4,
|
||||
startedAt: new Date(Date.parse(now) + index * 1_800_000).toISOString(),
|
||||
endedAt: new Date(Date.parse(now) + (index + 1) * 1_800_000).toISOString(),
|
||||
offsetSeconds: index * 1800,
|
||||
durationSeconds: 1800,
|
||||
label: `/volume1/录制/示例主播/2026-08-02/分片-${String(index + 1).padStart(3, "0")}.mp4`,
|
||||
detail: "录制完成"
|
||||
})),
|
||||
events: [],
|
||||
heatBuckets: []
|
||||
},
|
||||
logs: []
|
||||
};
|
||||
|
||||
const uploadTask = {
|
||||
recordTaskId: task.id,
|
||||
recordSessionId: session.id,
|
||||
@@ -99,6 +122,29 @@ const uploadTask = {
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
const recordingFailure = {
|
||||
recordTaskId: "failed-task-1",
|
||||
recordSessionId: "failed-session-1",
|
||||
liveRoomId: room.id,
|
||||
liveRoomTitle: room.title,
|
||||
roomId: room.roomId,
|
||||
platformName: room.platformName,
|
||||
segmentIndex: 7,
|
||||
failureKind: "ReadableFragment",
|
||||
failureLabel: "异常退出分片",
|
||||
recommendedAction: "文件可以读取。确认内容有效后,将它转为待上传任务。",
|
||||
errorMessage: "FFmpeg 异常退出;原始文件会保留,不会被修复流程覆盖。",
|
||||
filePath: "/volume1/录制/示例主播/2026-08-02/一个用于验证窄屏换行的很长文件名-007.mp4",
|
||||
fileSizeBytes: 4_294_967_296,
|
||||
durationSeconds: 1789,
|
||||
fileExists: true,
|
||||
canAccept: true,
|
||||
canRepair: false,
|
||||
canRetryRoom: false,
|
||||
isRepairing: false,
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
const dashboard = {
|
||||
activeRecordingCount: 1,
|
||||
liveRoomCount: 3,
|
||||
@@ -160,6 +206,7 @@ async function mockApi(page: Page) {
|
||||
totalTaskCount: 1,
|
||||
totalDanmakuCount: session.totalDanmakuMessageCount
|
||||
};
|
||||
else if (path === `/api/record-sessions/${session.id}`) body = sessionDetail;
|
||||
else if (path === "/api/record-sessions") body = [session];
|
||||
else if (path === "/api/record-tasks/upload-status") body = {
|
||||
items: [uploadTask],
|
||||
@@ -170,7 +217,18 @@ async function mockApi(page: Page) {
|
||||
failedCount: 0,
|
||||
queuedCount: 0,
|
||||
uploadingCount: 0,
|
||||
waitingRetryCount: 0
|
||||
waitingRetryCount: 0,
|
||||
matchingRetryableCount: 0,
|
||||
queueHealth: { state: "Healthy", isPaused: false }
|
||||
};
|
||||
else if (path === "/api/recovery/recording-failures") body = {
|
||||
items: [recordingFailure],
|
||||
totalCount: 1
|
||||
};
|
||||
else if (path === "/api/recovery") body = {
|
||||
storage: dashboard.storageStatus,
|
||||
liveRooms: [],
|
||||
finalizations: []
|
||||
};
|
||||
else if (path === "/api/settings") body = {};
|
||||
else if (path.includes("/record-sessions/stream")) {
|
||||
@@ -204,23 +262,117 @@ test.beforeEach(async ({ page }) => {
|
||||
await mockApi(page);
|
||||
});
|
||||
|
||||
test("login preserves the product hierarchy without viewport overflow", async ({ page }, testInfo) => {
|
||||
await page.addInitScript(() => localStorage.clear());
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading", { name: /让每一次开播/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /登录控制台/ })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "login");
|
||||
});
|
||||
|
||||
test("dashboard keeps storage semantics and the shell within the viewport", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "仪表盘" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "运行中心" })).toBeVisible();
|
||||
const storage = page.getByTestId("storage-capacity");
|
||||
await expect(storage).toContainText("75.0%");
|
||||
await expect(storage).toContainText("已使用");
|
||||
await expect(storage).toContainText("25.0%");
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
const avatarGeometry = await page.locator(".app-user-btn").evaluate((button) => {
|
||||
const avatar = button.querySelector<SVGSVGElement>(".app-user-btn__avatar")!;
|
||||
const circle = avatar.querySelector<SVGCircleElement>("circle")!;
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const avatarRect = avatar.getBoundingClientRect();
|
||||
const circleRect = circle.getBoundingClientRect();
|
||||
return {
|
||||
tagName: avatar.tagName.toLowerCase(),
|
||||
buttonDelta: Math.abs(buttonRect.width - buttonRect.height),
|
||||
avatarDelta: Math.abs(avatarRect.width - avatarRect.height),
|
||||
circleDelta: Math.abs(circleRect.width - circleRect.height)
|
||||
};
|
||||
});
|
||||
expect(avatarGeometry.tagName).toBe("svg");
|
||||
expect(avatarGeometry.buttonDelta).toBeLessThanOrEqual(1);
|
||||
expect(avatarGeometry.avatarDelta).toBeLessThanOrEqual(1);
|
||||
expect(avatarGeometry.circleDelta).toBeLessThanOrEqual(1);
|
||||
}
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "dashboard");
|
||||
});
|
||||
|
||||
test("mobile navigation animates route and active state without a tap frame", async ({ page }, testInfo) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile navigation contract");
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "运行中心" })).toBeVisible();
|
||||
await expect(page.locator(".app-main")).toBeVisible();
|
||||
await expect(page.locator(".app-bottom-nav button[aria-current='page']")).toHaveCount(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved = false;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((mutation) =>
|
||||
mutation.target instanceof HTMLElement && mutation.target.className.includes("page-swap-")
|
||||
)) {
|
||||
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(document.querySelector(".app-main")!, {
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class"]
|
||||
});
|
||||
});
|
||||
|
||||
const recordNavigation = page.locator(".app-bottom-nav").getByRole("button", { name: "录制", exact: true });
|
||||
const tapHighlight = await recordNavigation.evaluate((element) =>
|
||||
getComputedStyle(element).webkitTapHighlightColor
|
||||
);
|
||||
expect(tapHighlight).toBe("rgba(0, 0, 0, 0)");
|
||||
|
||||
await recordNavigation.click();
|
||||
await expect(page).toHaveURL(/\/live-rooms$/);
|
||||
await expect(recordNavigation).toHaveClass(/is-active/);
|
||||
await expect(page.locator(".app-bottom-nav button[aria-current='page']")).toHaveCount(1);
|
||||
await expect.poll(() => page.evaluate(() =>
|
||||
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved
|
||||
)).toBeTruthy();
|
||||
const activeIndicator = await recordNavigation.locator(".app-bottom-nav__icon").evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
duration: getComputedStyle(element).transitionDuration
|
||||
}));
|
||||
expect(activeIndicator.background).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(activeIndicator.duration).not.toBe("0s");
|
||||
await capture(page, testInfo, "mobile-navigation-active");
|
||||
});
|
||||
|
||||
test("dark mode teleported overlays inherit dark surfaces and borders", async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem("live-recorder-ui-theme", "dark"));
|
||||
await page.goto("/");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
|
||||
await page.locator(".app-user-btn").click();
|
||||
const dropdown = page.locator(".el-dropdown__popper:visible").first();
|
||||
await expect(dropdown).toBeVisible();
|
||||
const colors = await dropdown.evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
border: getComputedStyle(element).borderTopColor,
|
||||
rootBorder: getComputedStyle(document.documentElement).getPropertyValue("--el-border-color-light").trim()
|
||||
}));
|
||||
expect(colors.background).toBe("rgb(23, 34, 53)");
|
||||
expect(colors.border).toBe("rgb(44, 60, 85)");
|
||||
expect(colors.rootBorder).toBe("#2c3c55");
|
||||
});
|
||||
|
||||
test("live room table, drawer and dialog retain their final actions", async ({ page }, testInfo) => {
|
||||
await page.goto("/live-rooms");
|
||||
await expect(page.getByRole("heading", { name: "直播间列表" })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
|
||||
if ((page.viewportSize()?.width ?? 0) >= 768) {
|
||||
if ((page.viewportSize()?.width ?? 0) > 768) {
|
||||
const actionHeader = page.getByRole("columnheader", { name: "操作" }).last();
|
||||
await expect(actionHeader).toBeVisible();
|
||||
const box = await actionHeader.boundingBox();
|
||||
@@ -228,10 +380,38 @@ test("live room table, drawer and dialog retain their final actions", async ({ p
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /^查看/ }).first().click();
|
||||
const drawer = page.locator(".right-drawer");
|
||||
const drawerFooter = page.locator(".right-drawer__footer");
|
||||
await expect(drawerFooter).toBeVisible();
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
const viewportWidth = page.viewportSize()?.width ?? 0;
|
||||
await expect.poll(async () => {
|
||||
const box = await drawer.boundingBox();
|
||||
return Boolean(box && box.x >= -1 && box.x + box.width <= viewportWidth + 1);
|
||||
}).toBe(true);
|
||||
if (viewportWidth <= 640) {
|
||||
const box = await drawer.boundingBox();
|
||||
expect(Math.round(box?.width ?? 0)).toBe(viewportWidth);
|
||||
}
|
||||
const drawerBody = page.locator(".right-drawer__body");
|
||||
const bodyOverflow = await drawerBody.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
overflowY: getComputedStyle(element).overflowY
|
||||
}));
|
||||
expect(bodyOverflow.scrollWidth).toBeLessThanOrEqual(bodyOverflow.clientWidth);
|
||||
expect(bodyOverflow.overflowY).toBe("auto");
|
||||
await drawerBody.evaluate((element) => { element.scrollTop = element.scrollHeight; });
|
||||
const lastDetailRow = page.locator(".detail-panel__descriptions tr").last();
|
||||
await expect(lastDetailRow).toBeVisible();
|
||||
const lastRowBox = await lastDetailRow.boundingBox();
|
||||
const fixedFooterBox = await drawerFooter.boundingBox();
|
||||
expect(lastRowBox && fixedFooterBox && lastRowBox.y + lastRowBox.height)
|
||||
.toBeLessThanOrEqual((fixedFooterBox?.y ?? 0) + 1);
|
||||
}
|
||||
const footerBox = await drawerFooter.boundingBox();
|
||||
expect(footerBox && footerBox.y + footerBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
|
||||
await capture(page, testInfo, "live-room-drawer");
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
await expect(drawerFooter).toBeHidden();
|
||||
|
||||
@@ -246,7 +426,7 @@ test("live room table, drawer and dialog retain their final actions", async ({ p
|
||||
});
|
||||
|
||||
test("mobile live room list paginates large collections", async ({ page }) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile-only pagination contract");
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile-only pagination contract");
|
||||
const manyRooms = Array.from({ length: 25 }, (_, index) => ({
|
||||
...room,
|
||||
id: `room-${index + 1}`,
|
||||
@@ -269,6 +449,13 @@ test("mobile live room list paginates large collections", async ({ page }) => {
|
||||
test("record task cards and tables expose one primary action", async ({ page }, testInfo) => {
|
||||
await page.goto("/record-tasks");
|
||||
await expect(page.getByRole("heading", { name: "录制任务" })).toBeVisible();
|
||||
await expect(page.getByText("主播 · 示例主播", { exact: true })).toBeVisible();
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
const tapHighlight = await page.getByRole("button", { name: "刷新列表" }).evaluate((element) =>
|
||||
getComputedStyle(element).webkitTapHighlightColor
|
||||
);
|
||||
expect(tapHighlight).toBe("rgba(0, 0, 0, 0)");
|
||||
}
|
||||
await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /查看/ }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /更多/ }).first()).toBeVisible();
|
||||
@@ -276,7 +463,7 @@ test("record task cards and tables expose one primary action", async ({ page },
|
||||
await capture(page, testInfo, "record-tasks");
|
||||
});
|
||||
|
||||
test("record sessions paginate on the server and bound the rendered page", async ({ page }) => {
|
||||
test("record sessions paginate on the server and bound the rendered page", async ({ page }, testInfo) => {
|
||||
const allSessions = Array.from({ length: 200 }, (_, index) => ({
|
||||
...session,
|
||||
id: `session-${index + 1}`,
|
||||
@@ -308,11 +495,11 @@ test("record sessions paginate on the server and bound the rendered page", async
|
||||
});
|
||||
|
||||
await page.goto("/record-tasks");
|
||||
const expectedPageSize = (page.viewportSize()?.width ?? 0) < 768 ? 12 : 20;
|
||||
const expectedPageSize = (page.viewportSize()?.width ?? 0) <= 768 ? 12 : 20;
|
||||
await expect.poll(() => requests.length).toBeGreaterThan(0);
|
||||
expect(requests[0]).toEqual({ skip: 0, take: expectedPageSize });
|
||||
await expect(page.getByText("当前页 " + expectedPageSize + " / 共 200")).toBeVisible();
|
||||
await expect(page.locator((page.viewportSize()?.width ?? 0) < 768 ? ".session-card" : ".session-panel"))
|
||||
await expect(page.locator((page.viewportSize()?.width ?? 0) <= 768 ? ".session-card" : ".session-panel"))
|
||||
.toHaveCount(expectedPageSize);
|
||||
const renderedNodeCount = await page.evaluate(() => document.getElementsByTagName("*").length);
|
||||
expect(renderedNodeCount).toBeLessThan(5_000);
|
||||
@@ -320,10 +507,24 @@ test("record sessions paginate on the server and bound the rendered page", async
|
||||
await page.locator(".session-pagination .btn-next").click();
|
||||
await expect.poll(() => requests.some((item) => item.skip === expectedPageSize)).toBeTruthy();
|
||||
await expect(page.getByText(`分页录制会话 ${expectedPageSize + 1}`, { exact: true })).toBeVisible();
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
await expect(page.locator(".session-pagination__mobile .el-button")).toHaveCount(2);
|
||||
const pageCount = Math.ceil(allSessions.length / expectedPageSize);
|
||||
const middlePage = Math.ceil(pageCount / 2);
|
||||
for (let pageNumber = 2; pageNumber < middlePage; pageNumber++) {
|
||||
await page.locator(".session-pagination__mobile .btn-next").click();
|
||||
await expect(page.getByText(`分页录制会话 ${pageNumber * expectedPageSize + 1}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.locator(".session-pagination__position")).toContainText(`${middlePage}/ ${pageCount}`);
|
||||
await expect(page.locator(".session-pagination__mobile .el-button")).toHaveCount(2);
|
||||
await expectNoDocumentOverflow(page);
|
||||
await page.locator(".session-pagination__mobile").scrollIntoViewIfNeeded();
|
||||
await capture(page, testInfo, "record-session-middle-page");
|
||||
}
|
||||
});
|
||||
|
||||
test("mobile record session refresh keeps the current page and scroll position", async ({ page }) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile refresh contract");
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile refresh contract");
|
||||
|
||||
await page.addInitScript(() => {
|
||||
class MockEventSource {
|
||||
@@ -428,8 +629,99 @@ test("upload tasks use cards on mobile and never require table horizontal scroll
|
||||
await capture(page, testInfo, "upload-tasks");
|
||||
});
|
||||
|
||||
test("paused uploads expose retry controls without overflowing the viewport", async ({ page }, testInfo) => {
|
||||
await page.route("**/api/record-tasks/upload-status**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [{
|
||||
...uploadTask,
|
||||
uploadStatus: 5,
|
||||
uploadAttemptCount: 3,
|
||||
uploadErrorMessage: "OpenList 登录请求过多,队列已全局暂停,当前任务次数不会继续消耗。"
|
||||
}],
|
||||
totalCount: 1,
|
||||
notUploadedCount: 0,
|
||||
failedArtifactCount: 0,
|
||||
succeededCount: 0,
|
||||
failedCount: 0,
|
||||
queuedCount: 0,
|
||||
uploadingCount: 0,
|
||||
waitingRetryCount: 1,
|
||||
matchingRetryableCount: 1,
|
||||
queueHealth: {
|
||||
state: "RateLimited",
|
||||
isPaused: true,
|
||||
reason: "OpenList 登录请求触发限流。队列暂停期间不会发起新的上传请求,也不会消耗单任务重试额度。",
|
||||
retryAt: "2026-08-02T12:15:00Z"
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/upload-tasks");
|
||||
await expect(page.getByText("OpenList 正在限流", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "验证并恢复队列" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "重试全部匹配" })).toBeVisible();
|
||||
const retryLabel = (page.viewportSize()?.width ?? 0) < 1280 ? "立即重试" : "重试";
|
||||
await expect(page.getByRole("button", { name: retryLabel, exact: true })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "upload-queue-paused");
|
||||
});
|
||||
|
||||
test("recording failure recovery adapts its actions and long paths to each viewport", async ({ page }, testInfo) => {
|
||||
await page.goto("/recovery");
|
||||
await expect(page.getByRole("heading", { name: "恢复中心" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "录制失败产物" })).toBeVisible();
|
||||
await expect(page.getByText(recordingFailure.filePath, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "确认有效" })).toBeVisible();
|
||||
|
||||
if ((page.viewportSize()?.width ?? 0) <= 767) {
|
||||
await expect(page.locator(".failure-card")).toHaveCount(1);
|
||||
await expect(page.getByRole("columnheader", { name: "操作" })).toHaveCount(0);
|
||||
} else {
|
||||
await expect(page.locator(".failure-card")).toHaveCount(0);
|
||||
await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible();
|
||||
}
|
||||
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "recording-failure-recovery");
|
||||
});
|
||||
|
||||
test("mobile session details keep segment actions visible and left aligned", async ({ page }, testInfo) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile session detail contract");
|
||||
|
||||
await page.goto("/record-tasks");
|
||||
const sessionCard = page.locator(".session-card").first();
|
||||
await expect(sessionCard).toBeVisible();
|
||||
await sessionCard.locator(":scope > .data-card__actions").getByRole("button", { name: "查看", exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "会话详情" })).toBeVisible();
|
||||
await expect(page.locator(".segment-card")).toHaveCount(3);
|
||||
await expect(page.getByRole("columnheader", { name: "操作" })).toHaveCount(0);
|
||||
|
||||
const firstCard = page.locator(".segment-card").first();
|
||||
const firstActions = firstCard.locator(".segment-card__actions");
|
||||
const cardBox = await firstCard.boundingBox();
|
||||
const actionsBox = await firstActions.boundingBox();
|
||||
expect(cardBox).not.toBeNull();
|
||||
expect(actionsBox).not.toBeNull();
|
||||
expect(actionsBox!.x - cardBox!.x).toBeLessThanOrEqual(20);
|
||||
|
||||
const lastActions = page.locator(".segment-card__actions").last();
|
||||
await lastActions.scrollIntoViewIfNeeded();
|
||||
await expect(lastActions.getByRole("button", { name: "查看分片" })).toBeVisible();
|
||||
const actionsBottom = await lastActions.boundingBox();
|
||||
const bottomNav = await page.locator(".app-bottom-nav").boundingBox();
|
||||
expect(actionsBottom).not.toBeNull();
|
||||
expect(bottomNav).not.toBeNull();
|
||||
expect(actionsBottom!.y + actionsBottom!.height).toBeLessThanOrEqual(bottomNav!.y + 1);
|
||||
await capture(page, testInfo, "mobile-session-detail");
|
||||
});
|
||||
|
||||
test("mobile upload polling keeps cards and scroll position while progress updates", async ({ page }) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) >= 768, "mobile polling contract");
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile polling contract");
|
||||
|
||||
const allItems = Array.from({ length: 60 }, (_, index) => ({
|
||||
...uploadTask,
|
||||
@@ -466,7 +758,9 @@ test("mobile upload polling keeps cards and scroll position while progress updat
|
||||
failedCount: 0,
|
||||
queuedCount: 0,
|
||||
uploadingCount: allItems.length,
|
||||
waitingRetryCount: 0
|
||||
waitingRetryCount: 0,
|
||||
matchingRetryableCount: 0,
|
||||
queueHealth: { state: "Healthy", isPaused: false }
|
||||
})
|
||||
});
|
||||
inFlight--;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
VERSION=1.2.13
|
||||
VERSION=1.2.19
|
||||
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}"
|
||||
WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd)
|
||||
DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}"
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
command -v ffmpeg >/dev/null || { echo "ffmpeg is required" >&2; exit 127; }
|
||||
command -v ffprobe >/dev/null || { echo "ffprobe is required" >&2; exit 127; }
|
||||
|
||||
test_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
make_sample() {
|
||||
local output="$1"
|
||||
local duration="$2"
|
||||
ffmpeg -hide_banner -loglevel error \
|
||||
-f lavfi -i "testsrc2=size=320x180:rate=25" \
|
||||
-f lavfi -i "sine=frequency=1000:sample_rate=48000" \
|
||||
-t "$duration" -c:v libx264 -preset ultrafast -pix_fmt yuv420p \
|
||||
-c:a aac -b:a 96k -movflags +faststart -y "$output"
|
||||
}
|
||||
|
||||
make_sample "$test_dir/short-a.mp4" 1.2
|
||||
make_sample "$test_dir/short-b.mp4" 2.1
|
||||
make_sample "$test_dir/normal.mp4" 6.4
|
||||
|
||||
run_case() {
|
||||
local name="$1"
|
||||
local minimum_duration="$2"
|
||||
shift 2
|
||||
local list="$test_dir/$name.txt"
|
||||
local output="$test_dir/$name.mp4"
|
||||
: > "$list"
|
||||
for source in "$@"; do
|
||||
printf "file '%s'\n" "$source" >> "$list"
|
||||
done
|
||||
|
||||
ffmpeg -hide_banner -loglevel error -f concat -safe 0 -i "$list" \
|
||||
-map 0 -c copy -movflags +faststart -y "$output"
|
||||
|
||||
local duration
|
||||
duration="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$output")"
|
||||
local video_streams
|
||||
video_streams="$(ffprobe -v error -select_streams v:0 -show_entries stream=index -of csv=p=0 "$output" | wc -l)"
|
||||
awk -v actual="$duration" -v minimum="$minimum_duration" 'BEGIN { exit !(actual >= minimum) }'
|
||||
test "$video_streams" -ge 1
|
||||
echo "$name ok: duration=${duration}s"
|
||||
}
|
||||
|
||||
run_case short_then_normal 7.0 "$test_dir/short-a.mp4" "$test_dir/normal.mp4"
|
||||
run_case normal_then_short 7.0 "$test_dir/normal.mp4" "$test_dir/short-a.mp4"
|
||||
run_case consecutive_shorts 9.0 "$test_dir/short-a.mp4" "$test_dir/short-b.mp4" "$test_dir/normal.mp4"
|
||||
@@ -120,12 +120,14 @@ public interface IRecordResultRepository
|
||||
|
||||
Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
string? query,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
string? query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -47,6 +47,12 @@ public interface IFfmpegService
|
||||
|
||||
Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> StartArtifactRepairAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> ResumeArtifactRepairsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> ResumeRecoveringSessionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
bool IsRunning(Guid recordSessionId);
|
||||
|
||||
IReadOnlyDictionary<Guid, RecordTaskRuntimeState> GetTaskRuntimeStates(IReadOnlyCollection<Guid> recordTaskIds);
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed class RecordSessionDto
|
||||
|
||||
public required string LiveRoomTitle { get; init; }
|
||||
|
||||
public string? AnchorName { get; init; }
|
||||
|
||||
public required LivePlatformType Platform { get; init; }
|
||||
|
||||
public required string RoomId { get; init; }
|
||||
@@ -32,6 +34,10 @@ public sealed class RecordSessionDto
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public bool IsRecovering { get; init; }
|
||||
|
||||
public string? RecoveryReason { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
@@ -85,6 +85,10 @@ public sealed class RecordTaskDto
|
||||
|
||||
public double? DurationSeconds { get; init; }
|
||||
|
||||
public bool IsHiddenArtifactSource { get; init; }
|
||||
|
||||
public Guid? MergedIntoRecordTaskId { get; init; }
|
||||
|
||||
public RecordArtifactUploadStatus? UploadStatus { get; init; }
|
||||
|
||||
public string? PostProcessStage { get; init; }
|
||||
@@ -171,6 +175,13 @@ public sealed class RecordArtifactUploadBatchResultDto
|
||||
public required IReadOnlyList<RecordArtifactUploadItemResultDto> Items { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RetryRecordArtifactUploadsRequest
|
||||
{
|
||||
public RecordArtifactUploadStatus? UploadStatus { get; set; }
|
||||
|
||||
public string? Query { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ManualSegmentCompletedTriggerResultDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
@@ -250,4 +261,21 @@ public sealed class UploadTaskListResponse
|
||||
public int UploadingCount { get; init; }
|
||||
|
||||
public int WaitingRetryCount { get; init; }
|
||||
|
||||
public int MatchingRetryableCount { get; init; }
|
||||
|
||||
public required UploadQueueHealthDto QueueHealth { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UploadQueueHealthDto
|
||||
{
|
||||
public required string State { get; init; }
|
||||
|
||||
public bool IsPaused { get; init; }
|
||||
|
||||
public string? Reason { get; init; }
|
||||
|
||||
public DateTimeOffset? RetryAt { get; init; }
|
||||
|
||||
public DateTimeOffset? LastErrorAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,21 @@ public sealed class RecoveryOverviewDto
|
||||
public required IReadOnlyList<RecoverableLiveRoomDto> LiveRooms { get; init; }
|
||||
|
||||
public required IReadOnlyList<RecoverableFinalizationDto> Finalizations { get; init; }
|
||||
|
||||
public required IReadOnlyList<MergedArtifactRecordDto> MergedArtifacts { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MergedArtifactRecordDto
|
||||
{
|
||||
public Guid SourceRecordTaskId { get; init; }
|
||||
public Guid RecordSessionId { get; init; }
|
||||
public Guid? MergedIntoRecordTaskId { get; init; }
|
||||
public string? SourceVideoPath { get; init; }
|
||||
public string? MergedVideoPath { get; init; }
|
||||
public string? RecoveryDirectory { get; init; }
|
||||
public string? ManifestPath { get; init; }
|
||||
public double? SourceDurationSeconds { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StorageGuardStatusDto
|
||||
@@ -104,3 +119,58 @@ public sealed class RecoveryActionResultDto
|
||||
|
||||
public required IReadOnlyList<string> Messages { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecordingFailureListResponse
|
||||
{
|
||||
public required IReadOnlyList<RecordingFailureItemDto> Items { get; init; }
|
||||
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecordingFailureItemDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
public Guid LiveRoomId { get; init; }
|
||||
|
||||
public required string LiveRoomTitle { get; init; }
|
||||
|
||||
public required string RoomId { get; init; }
|
||||
|
||||
public required string PlatformName { get; init; }
|
||||
|
||||
public int SegmentIndex { get; init; }
|
||||
|
||||
public required string FailureKind { get; init; }
|
||||
|
||||
public required string FailureLabel { get; init; }
|
||||
|
||||
public required string RecommendedAction { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
public long? FileSizeBytes { get; init; }
|
||||
|
||||
public double? DurationSeconds { get; init; }
|
||||
|
||||
public bool FileExists { get; init; }
|
||||
|
||||
public bool CanAccept { get; init; }
|
||||
|
||||
public bool CanRepair { get; init; }
|
||||
|
||||
public bool CanRetryRoom { get; init; }
|
||||
|
||||
public bool IsRepairing { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AcceptRecordingArtifactRequest
|
||||
{
|
||||
public bool ConfirmShortArtifact { get; set; }
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ internal static class RecordModelMapper
|
||||
StartedAt = recordTask.StartedAt,
|
||||
EndedAt = recordTask.EndedAt,
|
||||
DurationSeconds = recordTask.DurationSeconds,
|
||||
IsHiddenArtifactSource = recordTask.IsHiddenArtifactSource,
|
||||
MergedIntoRecordTaskId = recordTask.MergedIntoRecordTaskId,
|
||||
UploadStatus = recordTask.Result?.UploadStatus,
|
||||
PostProcessStage = runtimeState?.Stage,
|
||||
PostProcessProgressPercent = runtimeState?.ProgressPercent,
|
||||
@@ -58,6 +60,7 @@ internal static class RecordModelMapper
|
||||
IReadOnlyDictionary<Guid, RecordTaskRuntimeState>? runtimeStates = null)
|
||||
{
|
||||
var orderedTasks = recordSession.RecordTasks
|
||||
.Where(static item => !item.IsHiddenArtifactSource)
|
||||
.OrderBy(static item => item.SegmentIndex)
|
||||
.ThenBy(static item => item.CreatedAt)
|
||||
.ToList();
|
||||
@@ -73,6 +76,7 @@ internal static class RecordModelMapper
|
||||
Id = recordSession.Id,
|
||||
LiveRoomId = recordSession.LiveRoomId,
|
||||
LiveRoomTitle = recordSession.LiveRoom?.Title ?? recordSession.LiveRoom?.AnchorName ?? recordSession.LiveRoom?.RoomId ?? "Unknown Room",
|
||||
AnchorName = recordSession.LiveRoom?.AnchorName,
|
||||
Platform = recordSession.LiveRoom?.Platform ?? LivePlatformType.Unknown,
|
||||
RoomId = recordSession.LiveRoom?.RoomId ?? string.Empty,
|
||||
Status = recordSession.Status,
|
||||
@@ -83,6 +87,12 @@ internal static class RecordModelMapper
|
||||
SegmentCount = Math.Max(recordSession.SegmentCount, orderedTasks.Count),
|
||||
RecorderProcessId = recordSession.RecorderProcessId,
|
||||
ErrorMessage = recordSession.ErrorMessage,
|
||||
IsRecovering = recordSession.Status == RecordSessionStatus.Starting &&
|
||||
recordSession.ErrorMessage?.StartsWith("[runtime-recovery]", StringComparison.Ordinal) == true,
|
||||
RecoveryReason = recordSession.Status == RecordSessionStatus.Starting &&
|
||||
recordSession.ErrorMessage?.StartsWith("[runtime-recovery]", StringComparison.Ordinal) == true
|
||||
? recordSession.ErrorMessage["[runtime-recovery]".Length..].TrimStart(' ', ':', ';')
|
||||
: null,
|
||||
CreatedAt = recordSession.CreatedAt,
|
||||
StartedAt = recordSession.StartedAt,
|
||||
EndedAt = recordSession.EndedAt,
|
||||
@@ -126,6 +136,8 @@ internal static class RecordModelMapper
|
||||
StartedAt = recordTask.StartedAt,
|
||||
EndedAt = recordTask.EndedAt,
|
||||
DurationSeconds = recordTask.DurationSeconds,
|
||||
IsHiddenArtifactSource = recordTask.IsHiddenArtifactSource,
|
||||
MergedIntoRecordTaskId = recordTask.MergedIntoRecordTaskId,
|
||||
UploadStatus = recordTask.Result?.UploadStatus,
|
||||
PostProcessStage = runtimeState?.Stage,
|
||||
PostProcessProgressPercent = runtimeState?.ProgressPercent,
|
||||
|
||||
@@ -49,6 +49,16 @@ public sealed class RecordCompletionDispatch
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void MarkSkipped(string reason, DateTimeOffset updatedAt)
|
||||
{
|
||||
ScriptDispatched = true;
|
||||
UploadDispatched = true;
|
||||
LastError = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
NextAttemptAt = null;
|
||||
CompletedAt = updatedAt;
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
private void CompleteIfReady(DateTimeOffset updatedAt)
|
||||
{
|
||||
if (!ScriptDispatched || !UploadDispatched)
|
||||
|
||||
@@ -149,6 +149,17 @@ public class RecordResult
|
||||
DeletedLocalFilesAfterUpload = false;
|
||||
}
|
||||
|
||||
public void ResetUploadForRecoveredArtifact()
|
||||
{
|
||||
UploadStatus = RecordArtifactUploadStatus.NotUploaded;
|
||||
LastUploadProvider = null;
|
||||
RemoteVideoPath = null;
|
||||
RemoteDanmakuPath = null;
|
||||
LastUploadedAt = null;
|
||||
UploadErrorMessage = null;
|
||||
DeletedLocalFilesAfterUpload = false;
|
||||
}
|
||||
|
||||
private static string? NormalizeNullable(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ public class RecordSession
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void MarkRecovering(string reason, DateTimeOffset updatedAt)
|
||||
{
|
||||
Status = RecordSessionStatus.Starting;
|
||||
RecorderProcessId = null;
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(reason) ? "[runtime-recovery]" : reason.Trim();
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void MarkStopping(DateTimeOffset updatedAt)
|
||||
{
|
||||
Status = RecordSessionStatus.Stopping;
|
||||
|
||||
@@ -67,6 +67,10 @@ public class RecordTask
|
||||
|
||||
public RecordUploadJob? UploadJob { get; private set; }
|
||||
|
||||
public bool IsHiddenArtifactSource { get; private set; }
|
||||
|
||||
public Guid? MergedIntoRecordTaskId { get; private set; }
|
||||
|
||||
public void AssignToSession(Guid recordSessionId, int segmentIndex, DateTimeOffset updatedAt)
|
||||
{
|
||||
RecordSessionId = recordSessionId;
|
||||
@@ -153,4 +157,25 @@ public class RecordTask
|
||||
RecorderProcessId = null;
|
||||
UpdatedAt = endedAt;
|
||||
}
|
||||
|
||||
public void MarkMergedSource(Guid mergedIntoRecordTaskId, DateTimeOffset updatedAt)
|
||||
{
|
||||
if (mergedIntoRecordTaskId == Guid.Empty || mergedIntoRecordTaskId == Id)
|
||||
{
|
||||
throw new ArgumentException("Merged target must be another recording task.", nameof(mergedIntoRecordTaskId));
|
||||
}
|
||||
|
||||
IsHiddenArtifactSource = true;
|
||||
MergedIntoRecordTaskId = mergedIntoRecordTaskId;
|
||||
RecorderProcessId = null;
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void MarkRecoveryOnly(DateTimeOffset updatedAt)
|
||||
{
|
||||
IsHiddenArtifactSource = true;
|
||||
MergedIntoRecordTaskId = null;
|
||||
RecorderProcessId = null;
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,34 @@ public sealed class RecordUploadJob
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void RequestImmediateRetry(DateTimeOffset requestedAt)
|
||||
{
|
||||
if (Status is not (RecordArtifactUploadStatus.Failed or RecordArtifactUploadStatus.WaitingRetry))
|
||||
{
|
||||
throw new InvalidOperationException("Only failed or waiting upload jobs can be retried immediately.");
|
||||
}
|
||||
|
||||
Status = RecordArtifactUploadStatus.Queued;
|
||||
AttemptCount = 0;
|
||||
NextAttemptAt = null;
|
||||
ErrorMessage = null;
|
||||
CompletedAt = null;
|
||||
RequestedAt = requestedAt;
|
||||
UpdatedAt = requestedAt;
|
||||
}
|
||||
|
||||
public void SuspendForProviderFailure(string? errorMessage, DateTimeOffset updatedAt, bool rollbackAttempt)
|
||||
{
|
||||
Status = RecordArtifactUploadStatus.WaitingRetry;
|
||||
if (rollbackAttempt)
|
||||
{
|
||||
AttemptCount = Math.Max(0, AttemptCount - 1);
|
||||
}
|
||||
NextAttemptAt = null;
|
||||
ErrorMessage = NormalizeNullable(errorMessage);
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void BeginAttempt(DateTimeOffset updatedAt)
|
||||
{
|
||||
MarkProcessing(updatedAt);
|
||||
|
||||
@@ -75,6 +75,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
builder.Property(static x => x.StreamUrl).HasMaxLength(2048);
|
||||
builder.Property(static x => x.OutputFilePath).HasMaxLength(2048);
|
||||
builder.Property(static x => x.ErrorMessage).HasMaxLength(2048);
|
||||
builder.Property(static x => x.IsHiddenArtifactSource).HasDefaultValue(false);
|
||||
builder.HasIndex(static x => x.MergedIntoRecordTaskId);
|
||||
builder.HasOne(static x => x.LiveRoom)
|
||||
.WithMany(static x => x.RecordTasks)
|
||||
.HasForeignKey(static x => x.LiveRoomId)
|
||||
@@ -228,6 +230,7 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
.Where(entry => entry.State is EntityState.Added or EntityState.Modified)
|
||||
.Select(static entry => entry.Entity)
|
||||
.Where(static task => task.Status is Domain.Enums.RecordTaskStatus.Completed or Domain.Enums.RecordTaskStatus.Stopped)
|
||||
.Where(static task => !task.IsHiddenArtifactSource)
|
||||
.ToArray();
|
||||
foreach (var task in terminalTasks)
|
||||
{
|
||||
@@ -239,7 +242,7 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
.FirstOrDefaultAsync(item => item.RecordTaskId == task.Id, cancellationToken);
|
||||
var path = result?.FilePath;
|
||||
if (string.IsNullOrWhiteSpace(path) ||
|
||||
result?.DurationSeconds is null or < 5 ||
|
||||
result?.DurationSeconds is null or <= 0 ||
|
||||
(task.OutputFormat == Domain.Enums.RecordOutputFormat.Mp4 && !path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Persistence.Migrations;
|
||||
|
||||
[DbContext(typeof(LiveRecorderDbContext))]
|
||||
[Migration("20260805143000_AddMergedArtifactTracking")]
|
||||
public sealed class AddMergedArtifactTracking : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsHiddenArtifactSource",
|
||||
table: "RecordTasks",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "MergedIntoRecordTaskId",
|
||||
table: "RecordTasks",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RecordTasks_MergedIntoRecordTaskId",
|
||||
table: "RecordTasks",
|
||||
column: "MergedIntoRecordTaskId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(name: "IX_RecordTasks_MergedIntoRecordTaskId", table: "RecordTasks");
|
||||
migrationBuilder.DropColumn(name: "IsHiddenArtifactSource", table: "RecordTasks");
|
||||
migrationBuilder.DropColumn(name: "MergedIntoRecordTaskId", table: "RecordTasks");
|
||||
}
|
||||
}
|
||||
+10
@@ -192,6 +192,11 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<bool>("IsHiddenArtifactSource")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("LastAutoStartDecisionSummary")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
@@ -411,6 +416,9 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid>("LiveRoomId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("MergedIntoRecordTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("OutputFormat")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -514,6 +522,8 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("LiveRoomId");
|
||||
|
||||
b.HasIndex("MergedIntoRecordTaskId");
|
||||
|
||||
b.HasIndex("RecordSessionId", "SegmentIndex")
|
||||
.IsUnique();
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
|
||||
.Include(item => item.UploadJob)
|
||||
.AsNoTracking();
|
||||
|
||||
query = query.Where(static item => !item.IsHiddenArtifactSource);
|
||||
|
||||
if (liveRoomId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.LiveRoomId == liveRoomId.Value);
|
||||
@@ -143,11 +145,11 @@ public sealed class RecordTaskRepository : IRecordTaskRepository
|
||||
public Task<double> SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks
|
||||
.Include(item => item.RecordSession)
|
||||
.Where(item => item.RecordSession != null && item.RecordSession.StartedAt >= startedFrom && item.RecordSession.StartedAt <= startedTo)
|
||||
.Where(item => !item.IsHiddenArtifactSource && item.RecordSession != null && item.RecordSession.StartedAt >= startedFrom && item.RecordSession.StartedAt <= startedTo)
|
||||
.SumAsync(item => item.DurationSeconds ?? 0, cancellationToken);
|
||||
|
||||
public Task<int> CountByStatusAsync(RecordTaskStatus status, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks.CountAsync(item => item.Status == status, cancellationToken);
|
||||
_dbContext.RecordTasks.CountAsync(item => !item.IsHiddenArtifactSource && item.Status == status, cancellationToken);
|
||||
|
||||
public Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordTasks.AddAsync(recordTask, cancellationToken).AsTask();
|
||||
@@ -350,10 +352,11 @@ public sealed class RecordSessionRepository : IRecordSessionRepository
|
||||
cancellationToken);
|
||||
var totalTaskCount = await sessions
|
||||
.SelectMany(static item => item.RecordTasks)
|
||||
.Where(static item => !item.IsHiddenArtifactSource)
|
||||
.CountAsync(cancellationToken);
|
||||
var totalDanmakuCount = await sessions
|
||||
.SelectMany(static item => item.RecordTasks)
|
||||
.Where(static item => item.Result != null)
|
||||
.Where(static item => !item.IsHiddenArtifactSource && item.Result != null)
|
||||
.SumAsync(static item => (int?)item.Result!.DanmakuMessageCount, cancellationToken) ?? 0;
|
||||
|
||||
return new RecordSessionOverviewTotals(
|
||||
@@ -530,6 +533,7 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
||||
|
||||
public async Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
string? searchQuery,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -546,6 +550,8 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
||||
query = query.Where(item => item.UploadStatus == uploadStatusFilter.Value);
|
||||
}
|
||||
|
||||
query = ApplyUploadSearch(query, searchQuery);
|
||||
|
||||
var results = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Skip(skip)
|
||||
@@ -560,6 +566,7 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
||||
|
||||
public Task<int> CountUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
string? searchQuery,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbContext.RecordResults.AsQueryable();
|
||||
@@ -569,9 +576,33 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
||||
query = query.Where(item => item.UploadStatus == uploadStatusFilter.Value);
|
||||
}
|
||||
|
||||
query = ApplyUploadSearch(query, searchQuery);
|
||||
|
||||
return query.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static IQueryable<RecordResult> ApplyUploadSearch(
|
||||
IQueryable<RecordResult> query,
|
||||
string? searchQuery)
|
||||
{
|
||||
var normalized = searchQuery?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var parsedTaskId = Guid.TryParse(normalized, out var taskId) ? taskId : (Guid?)null;
|
||||
return query.Where(item =>
|
||||
parsedTaskId.HasValue && item.RecordTaskId == parsedTaskId.Value ||
|
||||
item.FilePath.ToLower().Contains(normalized) ||
|
||||
item.RemoteVideoPath != null && item.RemoteVideoPath.ToLower().Contains(normalized) ||
|
||||
item.UploadErrorMessage != null && item.UploadErrorMessage.ToLower().Contains(normalized) ||
|
||||
item.RecordTask != null && item.RecordTask.LiveRoom != null &&
|
||||
((item.RecordTask.LiveRoom.Title != null && item.RecordTask.LiveRoom.Title.ToLower().Contains(normalized)) ||
|
||||
item.RecordTask.LiveRoom.AnchorName != null && item.RecordTask.LiveRoom.AnchorName.ToLower().Contains(normalized) ||
|
||||
item.RecordTask.LiveRoom.RoomId.ToLower().Contains(normalized)));
|
||||
}
|
||||
|
||||
public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask();
|
||||
|
||||
|
||||
@@ -14,15 +14,18 @@ public sealed class CompletionDispatchService
|
||||
private readonly LiveRecorderDbContext _dbContext;
|
||||
private readonly IEventScriptService _eventScriptService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly ShortFragmentConsolidationService _shortFragmentConsolidationService;
|
||||
|
||||
public CompletionDispatchService(
|
||||
LiveRecorderDbContext dbContext,
|
||||
IEventScriptService eventScriptService,
|
||||
RecordUploadService recordUploadService)
|
||||
RecordUploadService recordUploadService,
|
||||
ShortFragmentConsolidationService shortFragmentConsolidationService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_eventScriptService = eventScriptService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_shortFragmentConsolidationService = shortFragmentConsolidationService;
|
||||
}
|
||||
|
||||
public async Task<bool> TryDispatchNextAsync(CancellationToken cancellationToken = default)
|
||||
@@ -69,6 +72,25 @@ public sealed class CompletionDispatchService
|
||||
|
||||
try
|
||||
{
|
||||
var consolidation = await _shortFragmentConsolidationService.PrepareDispatchAsync(task.Id, cancellationToken);
|
||||
if (consolidation.SkipDispatch)
|
||||
{
|
||||
dispatch.MarkSkipped(consolidation.Reason ?? "该分片无需独立分发。", DateTimeOffset.UtcNow);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!consolidation.CanDispatch)
|
||||
{
|
||||
var retryAt = DateTimeOffset.UtcNow.Add(consolidation.RetryDelay <= TimeSpan.Zero ? TimeSpan.FromSeconds(2) : consolidation.RetryDelay);
|
||||
dispatch.ScheduleRetry(consolidation.Reason ?? "等待短分片判定。", retryAt, DateTimeOffset.UtcNow);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await _dbContext.Entry(task).ReloadAsync(cancellationToken);
|
||||
await _dbContext.Entry(task).Reference(item => item.Result).LoadAsync(cancellationToken);
|
||||
|
||||
if (!dispatch.ScriptDispatched)
|
||||
{
|
||||
var scriptResult = await _eventScriptService.RunSegmentCompletedAsync(
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
public sealed partial class FfmpegService
|
||||
{
|
||||
private const int MaxInSessionRetryAttempts = 3;
|
||||
private const string RuntimeRecoveryMarker = "[runtime-recovery]";
|
||||
private const string ShortUnexpectedExitArtifactError =
|
||||
"Unexpected recorder exit produced only a short fragment. The file was kept locally and excluded from automatic upload.";
|
||||
internal const string FfprobeUnreadableArtifactError = "ffprobe could not read the recorded media file.";
|
||||
@@ -29,6 +30,15 @@ public sealed partial class FfmpegService
|
||||
private static readonly TimeSpan RuntimeSourceFailureWindow = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RuntimeOfflineVerificationStopTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RuntimeOfflineVerificationKillTimeout = TimeSpan.FromSeconds(8);
|
||||
private static readonly TimeSpan OfflineConfirmationDelay = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan[] RuntimeRecoveryBackoff =
|
||||
[
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(15),
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(60),
|
||||
TimeSpan.FromSeconds(120)
|
||||
];
|
||||
|
||||
private async Task HandleProcessOutputAsync(SessionProcessRuntime runtime, string? line, bool isError)
|
||||
{
|
||||
@@ -221,6 +231,9 @@ public sealed partial class FfmpegService
|
||||
internal static bool IsMeaningfulUnexpectedExitArtifact(double? durationSeconds, long? fileSizeBytes) =>
|
||||
durationSeconds >= MinimumUnexpectedExitArtifactDuration.TotalSeconds;
|
||||
|
||||
internal static bool CanResetInSessionRetryBudget(DateTimeOffset? processStartedAt, DateTimeOffset observedAt) =>
|
||||
processStartedAt.HasValue && observedAt - processStartedAt.Value >= StableRuntimeResetThreshold;
|
||||
|
||||
private static bool IsRuntimeSourceFailureLine(string line)
|
||||
{
|
||||
if (line.Contains("Will reconnect at", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -567,6 +580,19 @@ public sealed partial class FfmpegService
|
||||
|
||||
private async Task HandleProcessExitedAsync(SessionProcessRuntime runtime, Process process)
|
||||
{
|
||||
var transition = new SessionTransitionRuntime(runtime.RecordSessionId);
|
||||
if (!_sessionTransitions.TryAdd(runtime.RecordSessionId, transition))
|
||||
{
|
||||
transition.Dispose();
|
||||
if (_sessionTransitions.TryGetValue(runtime.RecordSessionId, out var activeTransition))
|
||||
{
|
||||
await activeTransition.Completion.Task;
|
||||
}
|
||||
|
||||
await HandleProcessExitedAsync(runtime, process);
|
||||
return;
|
||||
}
|
||||
|
||||
_processes.TryRemove(runtime.RecordSessionId, out _);
|
||||
|
||||
try
|
||||
@@ -604,7 +630,16 @@ public sealed partial class FfmpegService
|
||||
return;
|
||||
}
|
||||
|
||||
if (await TryRecoverUnexpectedExitAsync(runtime, activeDanmakuSummary))
|
||||
if (!runtime.HasOpenedFirstSegment &&
|
||||
!runtime.StopRequested &&
|
||||
!runtime.CompletionRequested &&
|
||||
!runtime.ShutdownRequested &&
|
||||
await TryRecoverStartupUntilAvailableAsync(runtime, transition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (await TryRecoverUnexpectedExitAsync(runtime, activeDanmakuSummary, transition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -617,12 +652,110 @@ public sealed partial class FfmpegService
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionTransitions.TryRemove(runtime.RecordSessionId, out _);
|
||||
transition.Completion.TrySetResult(true);
|
||||
transition.Dispose();
|
||||
runtime.ExitCompletion.TrySetResult(true);
|
||||
runtime.Dispose();
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryRecoverStartupUntilAvailableAsync(
|
||||
SessionProcessRuntime runtime,
|
||||
SessionTransitionRuntime transition)
|
||||
{
|
||||
var attempt = transition.NextAttempt(Math.Max(1, runtime.RetryAttemptCount + 1));
|
||||
while (!transition.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var session = await dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordTasks)
|
||||
.ThenInclude(item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == runtime.RecordSessionId, transition.Token);
|
||||
if (session?.LiveRoom is null || !IsActiveSessionStatus(session.Status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var currentTask = session.RecordTasks.FirstOrDefault(item => item.Id == runtime.CurrentTaskId)
|
||||
?? session.RecordTasks.OrderByDescending(item => item.SegmentIndex).FirstOrDefault();
|
||||
if (currentTask is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var delay = GetRuntimeRecoveryDelay(attempt);
|
||||
session.MarkRecovering(
|
||||
$"{RuntimeRecoveryMarker} attempt={attempt}; startup did not open a media segment",
|
||||
DateTimeOffset.UtcNow);
|
||||
currentTask.MarkStarting(runtime.StreamUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, DateTimeOffset.UtcNow);
|
||||
await dbContext.SaveChangesAsync(transition.Token);
|
||||
|
||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"FFmpeg",
|
||||
$"Recorder startup is recovering in the same session (attempt {attempt}).",
|
||||
$"delaySeconds={delay.TotalSeconds:0}; ffmpegExit={runtime.Process?.ExitCode}; curlExit={runtime.CurlExitCode?.ToString() ?? "unknown"}; output={runtime.GetRecentOutputSummary()}; curl={runtime.GetRecentCurlErrorSummary()}",
|
||||
session.LiveRoomId,
|
||||
session.Id,
|
||||
currentTask.Id,
|
||||
transition.Token);
|
||||
|
||||
await Task.Delay(delay, transition.Token);
|
||||
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
|
||||
var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform);
|
||||
var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId, transition.Token);
|
||||
if (!liveStatus.IsLive)
|
||||
{
|
||||
await Task.Delay(OfflineConfirmationDelay, transition.Token);
|
||||
liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId, transition.Token);
|
||||
}
|
||||
|
||||
if (!liveStatus.IsLive)
|
||||
{
|
||||
runtime.MarkStopRequested(markAsCompletedOnExit: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
var stream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality, transition.Token);
|
||||
var selected = SelectRetryStreamForCurrentSession(stream, runtime);
|
||||
var context = runtime.RecoveryContext with
|
||||
{
|
||||
AttemptCount = attempt,
|
||||
HasRetriedWithRefreshedStream = true
|
||||
};
|
||||
session.MarkStarting(selected.SelectedUrl, session.OutputPathPattern ?? runtime.OutputPathPattern, DateTimeOffset.UtcNow);
|
||||
currentTask.MarkStarting(selected.SelectedUrl, currentTask.OutputFilePath ?? runtime.CurrentOutputFilePath, DateTimeOffset.UtcNow);
|
||||
await dbContext.SaveChangesAsync(transition.Token);
|
||||
|
||||
await StartInternalAsync(session, currentTask, selected, runtime.RecordingSettings, context, transition.Token);
|
||||
var restartedAt = DateTimeOffset.UtcNow;
|
||||
session.MarkRunning(restartedAt);
|
||||
currentTask.MarkRunning(restartedAt);
|
||||
await dbContext.SaveChangesAsync(transition.Token);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (transition.Token.IsCancellationRequested)
|
||||
{
|
||||
runtime.MarkStopRequested(transition.MarkAsCompletedOnExit);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Recorder startup recovery attempt {Attempt} failed for session {RecordSessionId}", attempt, runtime.RecordSessionId);
|
||||
attempt = transition.NextAttempt(attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> TryRecoverStartupFailureAsync(SessionProcessRuntime runtime)
|
||||
{
|
||||
if (runtime.ShutdownRequested ||
|
||||
@@ -885,14 +1018,14 @@ public sealed partial class FfmpegService
|
||||
|
||||
private async Task<bool> TryRecoverUnexpectedExitAsync(
|
||||
SessionProcessRuntime runtime,
|
||||
SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary)
|
||||
SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary,
|
||||
SessionTransitionRuntime transition)
|
||||
{
|
||||
if (!runtime.HasOpenedFirstSegment ||
|
||||
runtime.StopRequested ||
|
||||
runtime.CompletionRequested ||
|
||||
runtime.ShutdownRequested ||
|
||||
runtime.SaveMode != RecordSaveMode.Segmented ||
|
||||
runtime.RetryAttemptCount >= MaxInSessionRetryAttempts)
|
||||
runtime.SaveMode != RecordSaveMode.Segmented)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -929,15 +1062,20 @@ public sealed partial class FfmpegService
|
||||
var observedAt = DateTimeOffset.UtcNow;
|
||||
var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform);
|
||||
var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId);
|
||||
if (!liveStatus.IsLive)
|
||||
{
|
||||
await Task.Delay(OfflineConfirmationDelay, transition.Token);
|
||||
liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId, transition.Token);
|
||||
}
|
||||
await liveRoomStatusService.ApplySnapshotAsync(session.LiveRoom, liveStatus, observedAt);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (!liveStatus.IsLive)
|
||||
{
|
||||
runtime.MarkStopRequested(markAsCompletedOnExit: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
var retryAttempt = runtime.RetryAttemptCount + 1;
|
||||
var refreshedStream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality);
|
||||
var retryStream = SelectRetryStreamForCurrentSession(refreshedStream, runtime);
|
||||
var nextSegmentIndex = Math.Max(currentTask.SegmentIndex + 1, session.ActiveSegmentIndex + 1);
|
||||
@@ -957,11 +1095,17 @@ public sealed partial class FfmpegService
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var retryInputOptionProfile = ResolveRetryInputOptionProfile(runtime);
|
||||
var stableBeforeExit = runtime.ProcessStartedAt.HasValue &&
|
||||
observedAt - runtime.ProcessStartedAt.Value >= StableRuntimeResetThreshold;
|
||||
var recoveryBase = stableBeforeExit
|
||||
? InitialRecoveryContext
|
||||
: runtime.RecoveryContext;
|
||||
var recoveryContext = AdvanceRecoveryContext(
|
||||
runtime.RecoveryContext,
|
||||
recoveryBase,
|
||||
retryInputOptionProfile,
|
||||
runtime.SelectedProtocol,
|
||||
retryStream.SelectedProtocol);
|
||||
var retryAttempt = recoveryContext.AttemptCount;
|
||||
try
|
||||
{
|
||||
await StartInternalAsync(
|
||||
@@ -1063,7 +1207,7 @@ public sealed partial class FfmpegService
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"FFmpeg",
|
||||
$"ffmpeg exited unexpectedly. Retrying within the current session ({retryAttempt}/{MaxInSessionRetryAttempts}).",
|
||||
$"ffmpeg exited unexpectedly. Retrying within the current session (attempt {retryAttempt}).",
|
||||
$"transition={runtime.InputOptionProfile}/{runtime.SelectedProtocol} -> {retryInputOptionProfile}/{retryStream.SelectedProtocol}; output={runtime.GetRecentOutputSummary()}",
|
||||
session.LiveRoomId,
|
||||
session.Id,
|
||||
@@ -1074,10 +1218,82 @@ public sealed partial class FfmpegService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "In-session ffmpeg retry failed for session {RecordSessionId}", runtime.RecordSessionId);
|
||||
return false;
|
||||
return await WaitAndRetryRecoveringSessionAsync(runtime, activeDanmakuSummary, transition, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> WaitAndRetryRecoveringSessionAsync(
|
||||
SessionProcessRuntime runtime,
|
||||
SessionDanmakuXmlRecorder.DanmakuSegmentSummary? activeDanmakuSummary,
|
||||
SessionTransitionRuntime transition,
|
||||
Exception failure)
|
||||
{
|
||||
var attempt = transition.NextAttempt(Math.Max(1, runtime.RetryAttemptCount + 1));
|
||||
while (!transition.Token.IsCancellationRequested && !runtime.ShutdownRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var scope = _serviceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var session = await dbContext.RecordSessions.FirstOrDefaultAsync(
|
||||
item => item.Id == runtime.RecordSessionId,
|
||||
CancellationToken.None);
|
||||
if (session is null || !IsActiveSessionStatus(session.Status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var reason = $"{RuntimeRecoveryMarker} attempt={attempt}; {failure.GetBaseException().Message}";
|
||||
session.MarkRecovering(reason, DateTimeOffset.UtcNow);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"FFmpeg",
|
||||
$"Recorder recovery is waiting before attempt {attempt}.",
|
||||
$"delaySeconds={GetRuntimeRecoveryDelay(attempt).TotalSeconds:0}; failure={failure}",
|
||||
runtime.LiveRoomId,
|
||||
runtime.RecordSessionId,
|
||||
runtime.CurrentTaskId,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
await Task.Delay(GetRuntimeRecoveryDelay(attempt), transition.Token);
|
||||
if (transition.MarkAsCompletedOnExit)
|
||||
{
|
||||
runtime.MarkStopRequested(markAsCompletedOnExit: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (transition.StopRequested)
|
||||
{
|
||||
runtime.MarkStopRequested(markAsCompletedOnExit: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-enter the normal recovery path with a fresh platform status and stream URL.
|
||||
return await TryRecoverUnexpectedExitAsync(runtime, activeDanmakuSummary, transition);
|
||||
}
|
||||
catch (OperationCanceledException) when (transition.Token.IsCancellationRequested)
|
||||
{
|
||||
runtime.MarkStopRequested(transition.MarkAsCompletedOnExit);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
attempt = transition.NextAttempt(attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static TimeSpan GetRuntimeRecoveryDelay(int attempt) =>
|
||||
RuntimeRecoveryBackoff[Math.Clamp(attempt - 1, 0, RuntimeRecoveryBackoff.Length - 1)];
|
||||
|
||||
private async Task<bool> TryRetryWithAlternateProtocolAsync(
|
||||
SessionProcessRuntime runtime,
|
||||
RecordSession session,
|
||||
@@ -1543,7 +1759,9 @@ public sealed partial class FfmpegService
|
||||
$"exitCode={exitCode}",
|
||||
$"output={effectiveOutputPath}",
|
||||
$"recorderOutput={runtime.RecorderOutputPath}",
|
||||
$"shutdownRequested={runtime.ShutdownRequested}"
|
||||
$"shutdownRequested={runtime.ShutdownRequested}",
|
||||
$"lifetimeSeconds={(runtime.ProcessStartedAt.HasValue ? Math.Max(0, (DateTimeOffset.UtcNow - runtime.ProcessStartedAt.Value).TotalSeconds).ToString("F1", CultureInfo.InvariantCulture) : "unknown")}",
|
||||
$"curlExitCode={runtime.CurlExitCode?.ToString(CultureInfo.InvariantCulture) ?? "unknown"}"
|
||||
};
|
||||
|
||||
if (toleratedNonZeroExit)
|
||||
@@ -1567,6 +1785,12 @@ public sealed partial class FfmpegService
|
||||
parts.Add($"recentOutput={recentOutput}");
|
||||
}
|
||||
|
||||
var recentCurlError = runtime.GetRecentCurlErrorSummary();
|
||||
if (!string.IsNullOrWhiteSpace(recentCurlError))
|
||||
{
|
||||
parts.Add($"curlStderr={recentCurlError}");
|
||||
}
|
||||
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
@@ -2280,6 +2504,7 @@ public sealed partial class FfmpegService
|
||||
private object RuntimeSourceFailureSync { get; } = new();
|
||||
private object RecentOutputSync { get; } = new();
|
||||
private Queue<string> RecentOutputLines { get; } = new();
|
||||
private Queue<string> RecentCurlErrorLines { get; } = new();
|
||||
private DateTimeOffset RuntimeSourceFailureWindowStartedAt { get; set; }
|
||||
private int RuntimeSourceFailureCount { get; set; }
|
||||
private bool RuntimeSourceFailureVerificationInProgress { get; set; }
|
||||
@@ -2290,6 +2515,14 @@ public sealed partial class FfmpegService
|
||||
ProcessStartedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
public void AttachCurlProcess(Process curlProcess) => CurlProcess = curlProcess;
|
||||
public int? CurlExitCode
|
||||
{
|
||||
get
|
||||
{
|
||||
try { return CurlProcess?.HasExited == true ? CurlProcess.ExitCode : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
public void MarkTimestampDiscontinuityFailure() => HasTimestampDiscontinuityFailure = true;
|
||||
public void MarkTimestampMuxerFailure() => HasTimestampMuxerFailure = true;
|
||||
public void MarkHlsOverlongHeadersFailure() => HasHlsOverlongHeadersFailure = true;
|
||||
@@ -2353,6 +2586,28 @@ public sealed partial class FfmpegService
|
||||
}
|
||||
}
|
||||
|
||||
public void RememberCurlErrorLine(string line)
|
||||
{
|
||||
lock (RecentOutputSync)
|
||||
{
|
||||
RecentCurlErrorLines.Enqueue(line.Trim());
|
||||
while (RecentCurlErrorLines.Count > 8)
|
||||
{
|
||||
RecentCurlErrorLines.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetRecentCurlErrorSummary()
|
||||
{
|
||||
lock (RecentOutputSync)
|
||||
{
|
||||
return RecentCurlErrorLines.Count == 0
|
||||
? null
|
||||
: string.Join(" | ", RecentCurlErrorLines);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetCurrentRecorderSegmentPaths(string openedPath)
|
||||
{
|
||||
CurrentRecorderSegmentPaths.Clear();
|
||||
@@ -2428,6 +2683,41 @@ public sealed partial class FfmpegService
|
||||
Gate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SessionTransitionRuntime : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
|
||||
public SessionTransitionRuntime(Guid recordSessionId) => RecordSessionId = recordSessionId;
|
||||
|
||||
public Guid RecordSessionId { get; }
|
||||
public bool StopRequested { get; private set; }
|
||||
public bool MarkAsCompletedOnExit { get; private set; }
|
||||
public CancellationToken Token => _cancellation.Token;
|
||||
public TaskCompletionSource<bool> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _attemptCount;
|
||||
|
||||
public int NextAttempt(int minimum)
|
||||
{
|
||||
var next = Interlocked.Increment(ref _attemptCount);
|
||||
if (next >= minimum)
|
||||
{
|
||||
return next;
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _attemptCount, minimum);
|
||||
return minimum;
|
||||
}
|
||||
|
||||
public void RequestStop(bool markAsCompletedOnExit)
|
||||
{
|
||||
StopRequested = true;
|
||||
MarkAsCompletedOnExit = markAsCompletedOnExit;
|
||||
_cancellation.Cancel();
|
||||
}
|
||||
|
||||
public void Dispose() => _cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal enum ExitedRecordingDisposition
|
||||
|
||||
@@ -678,6 +678,119 @@ public sealed partial class FfmpegService
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private async Task<(string OutputPath, string? ErrorMessage)> TryRepairArtifactFileAsync(
|
||||
string ffmpegPath,
|
||||
int maxConcurrentTranscodeTasks,
|
||||
int timeoutMinutes,
|
||||
Guid recordTaskId,
|
||||
string sourcePath,
|
||||
string targetPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
var existing = await ValidateMediaArtifactAsync(targetPath, RecordOutputFormat.Mp4, cancellationToken);
|
||||
if (existing.IsValid)
|
||||
{
|
||||
return (targetPath, null);
|
||||
}
|
||||
|
||||
File.Delete(targetPath);
|
||||
}
|
||||
|
||||
var temporaryPath = $"{targetPath}.repairing";
|
||||
string? lastError = null;
|
||||
foreach (var strategy in new[] { Mp4FinalizeStrategy.StreamCopy, Mp4FinalizeStrategy.RepairTranscode })
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
|
||||
using var transcodeSlot = await AcquireTranscodeSlotAsync(maxConcurrentTranscodeTasks, cancellationToken);
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
var processStarted = false;
|
||||
foreach (var argument in BuildMp4FinalizeArgumentList(sourcePath, temporaryPath, strategy))
|
||||
{
|
||||
process.StartInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
processStarted = true;
|
||||
_postProcessProcesses[recordTaskId] = process;
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromMinutes(Math.Clamp(timeoutMinutes, 1, 1440)));
|
||||
var stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
|
||||
var stderr = process.StandardError.ReadToEndAsync(timeout.Token);
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
await stdout;
|
||||
lastError = await stderr;
|
||||
|
||||
if (process.ExitCode == 0 && File.Exists(temporaryPath))
|
||||
{
|
||||
var validation = await ValidateMediaArtifactAsync(
|
||||
temporaryPath,
|
||||
RecordOutputFormat.Mp4,
|
||||
cancellationToken);
|
||||
if (validation.IsValid)
|
||||
{
|
||||
File.Move(temporaryPath, targetPath, overwrite: true);
|
||||
return (targetPath, null);
|
||||
}
|
||||
|
||||
lastError = validation.ErrorMessage;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
lastError = $"FFmpeg 修复超过 {Math.Clamp(timeoutMinutes, 1, 1440)} 分钟限制。";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_postProcessProcesses.TryRemove(recordTaskId, out _);
|
||||
if (processStarted && !process.HasExited)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to stop artifact repair process for task {RecordTaskId}", recordTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
targetPath,
|
||||
string.IsNullOrWhiteSpace(lastError)
|
||||
? "FFmpeg 无法从该文件生成可读取的视频。"
|
||||
: lastError.Trim());
|
||||
}
|
||||
|
||||
internal static bool IsRepairableMp4FinalizeError(string errorDetail)
|
||||
{
|
||||
if (IsLowStoragePauseError(errorDetail))
|
||||
@@ -1067,13 +1180,18 @@ public sealed partial class FfmpegService
|
||||
return MediaArtifactValidation.Invalid("The recorded media file does not contain a video stream.", metadata.DurationSeconds);
|
||||
}
|
||||
|
||||
if (!metadata.DurationSeconds.HasValue ||
|
||||
metadata.DurationSeconds.Value < MinimumUnexpectedExitArtifactDuration.TotalSeconds)
|
||||
if (!IsReadableMediaDuration(metadata.DurationSeconds))
|
||||
{
|
||||
return MediaArtifactValidation.Invalid(ShortUnexpectedExitArtifactError, metadata.DurationSeconds);
|
||||
return MediaArtifactValidation.Invalid("The recorded media file has no readable duration.", metadata.DurationSeconds);
|
||||
}
|
||||
|
||||
return new MediaArtifactValidation(true, metadata.DurationSeconds, null);
|
||||
return new MediaArtifactValidation(
|
||||
true,
|
||||
metadata.DurationSeconds,
|
||||
IsStandaloneMediaDuration(metadata.DurationSeconds),
|
||||
!IsStandaloneMediaDuration(metadata.DurationSeconds)
|
||||
? ShortUnexpectedExitArtifactError
|
||||
: null);
|
||||
}
|
||||
|
||||
private static async Task UpsertRecordResultAsync(
|
||||
@@ -1458,6 +1576,10 @@ public sealed partial class FfmpegService
|
||||
private static bool IsTerminalSessionStatus(RecordSessionStatus status) =>
|
||||
status is RecordSessionStatus.Completed or RecordSessionStatus.Failed or RecordSessionStatus.Stopped;
|
||||
|
||||
internal static bool IsReadableMediaDuration(double? durationSeconds) => durationSeconds is > 0;
|
||||
|
||||
internal static bool IsStandaloneMediaDuration(double? durationSeconds) => durationSeconds is >= 5;
|
||||
|
||||
private static bool TryParseFfmpegProgressSeconds(string line, out double seconds)
|
||||
{
|
||||
if (line.StartsWith("out_time=", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -1495,10 +1617,14 @@ public sealed partial class FfmpegService
|
||||
TimestampTranscode = 3
|
||||
}
|
||||
|
||||
private sealed record MediaArtifactValidation(bool IsValid, double? DurationSeconds, string? ErrorMessage)
|
||||
private sealed record MediaArtifactValidation(
|
||||
bool IsValid,
|
||||
double? DurationSeconds,
|
||||
bool IsStandaloneEligible,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public static MediaArtifactValidation Invalid(string errorMessage, double? durationSeconds = null) =>
|
||||
new(false, durationSeconds, errorMessage);
|
||||
new(false, durationSeconds, false, errorMessage);
|
||||
}
|
||||
|
||||
private enum StartupFailureKind
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
private const string ArtifactRepairMarker = "[artifact-repair]";
|
||||
private static readonly Regex SegmentOpeningRegex = new(
|
||||
"""Opening '([^']+)' for writing""",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
@@ -32,6 +33,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
private static readonly TimeSpan RuntimeFailureBaseBackoff = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new();
|
||||
private readonly ConcurrentDictionary<Guid, SessionTransitionRuntime> _sessionTransitions = new();
|
||||
private readonly ConcurrentDictionary<Guid, PostProcessRuntimeEntry> _postProcessStates = new();
|
||||
private readonly ConcurrentDictionary<Guid, Process> _postProcessProcesses = new();
|
||||
private readonly ConcurrentDictionary<Guid, RoomStartupFailureState> _roomStartupFailureStates = new();
|
||||
@@ -62,7 +64,16 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsRunning(Guid recordSessionId) => _processes.ContainsKey(recordSessionId);
|
||||
public bool IsRunning(Guid recordSessionId) =>
|
||||
IsSessionRuntimeActive(
|
||||
_processes.ContainsKey(recordSessionId),
|
||||
_sessionTransitions.ContainsKey(recordSessionId));
|
||||
|
||||
internal static bool IsSessionRuntimeActive(bool hasProcess, bool hasTransition) =>
|
||||
hasProcess || hasTransition;
|
||||
|
||||
internal static bool ShouldReuseRecoveryTask(bool hasMedia, RecordTaskStatus status) =>
|
||||
!hasMedia && status is RecordTaskStatus.Pending or RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping;
|
||||
|
||||
public IReadOnlyDictionary<Guid, RecordTaskRuntimeState> GetTaskRuntimeStates(IReadOnlyCollection<Guid> recordTaskIds)
|
||||
{
|
||||
@@ -83,6 +94,120 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async Task<int> ResumeRecoveringSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var lookupScope = _serviceScopeFactory.CreateScope();
|
||||
var lookupDb = lookupScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var candidates = await lookupDb.RecordSessions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Status == RecordSessionStatus.Starting &&
|
||||
item.ErrorMessage != null &&
|
||||
item.ErrorMessage.StartsWith("[runtime-recovery]"))
|
||||
.OrderBy(item => item.UpdatedAt)
|
||||
.Select(item => item.Id)
|
||||
.Take(20)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var resumed = 0;
|
||||
foreach (var sessionId in candidates)
|
||||
{
|
||||
if (IsRunning(sessionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var transition = new SessionTransitionRuntime(sessionId);
|
||||
if (!_sessionTransitions.TryAdd(sessionId, transition))
|
||||
{
|
||||
transition.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var session = await dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordTasks)
|
||||
.ThenInclude(item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == sessionId, cancellationToken);
|
||||
if (session?.LiveRoom is null || session.Status != RecordSessionStatus.Starting)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(session.OutputPathPattern))
|
||||
{
|
||||
_logger.LogWarning("Recovering session {RecordSessionId} has no output path pattern and cannot be resumed.", session.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
|
||||
var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform);
|
||||
var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId, cancellationToken);
|
||||
if (!liveStatus.IsLive)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var stream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality, cancellationToken);
|
||||
var latest = session.RecordTasks
|
||||
.OrderByDescending(item => item.SegmentIndex)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
var hasMedia = latest?.Result is { DurationSeconds: > 0 } result && File.Exists(result.FilePath);
|
||||
RecordTask task;
|
||||
if (latest is not null && ShouldReuseRecoveryTask(hasMedia, latest.Status))
|
||||
{
|
||||
task = latest;
|
||||
}
|
||||
else
|
||||
{
|
||||
var nextIndex = Math.Max(1, (latest?.SegmentIndex ?? 0) + 1);
|
||||
task = new RecordTask(session.LiveRoomId, session.Id, nextIndex, session.PreferredQuality, session.OutputFormat, DateTimeOffset.UtcNow);
|
||||
var output = NormalizeAbsolutePath(ResolveSegmentOutputPath(session.OutputPathPattern, session.SaveMode, nextIndex));
|
||||
task.MarkStarting(stream.SelectedUrl, output, DateTimeOffset.UtcNow);
|
||||
await dbContext.RecordTasks.AddAsync(task, cancellationToken);
|
||||
}
|
||||
|
||||
var settingsResolver = scope.ServiceProvider.GetRequiredService<LiveRoomRecordingSettingsResolver>();
|
||||
var recordingSettings = await settingsResolver.ResolveAsync(session.LiveRoom, cancellationToken);
|
||||
var taskOutputPath = string.IsNullOrWhiteSpace(task.OutputFilePath)
|
||||
? NormalizeAbsolutePath(ResolveSegmentOutputPath(session.OutputPathPattern, session.SaveMode, task.SegmentIndex))
|
||||
: task.OutputFilePath;
|
||||
session.MarkStarting(stream.SelectedUrl, session.OutputPathPattern, DateTimeOffset.UtcNow);
|
||||
session.ActivateSegment(task.SegmentIndex, DateTimeOffset.UtcNow);
|
||||
task.MarkStarting(stream.SelectedUrl, taskOutputPath, DateTimeOffset.UtcNow);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await StartInternalAsync(
|
||||
session,
|
||||
task,
|
||||
stream,
|
||||
recordingSettings,
|
||||
InitialRecoveryContext with { AttemptCount = 1, HasRetriedWithRefreshedStream = true },
|
||||
cancellationToken);
|
||||
session.MarkRunning(DateTimeOffset.UtcNow);
|
||||
task.MarkRunning(DateTimeOffset.UtcNow);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
resumed++;
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to resume persisted recorder recovery for session {RecordSessionId}", sessionId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionTransitions.TryRemove(sessionId, out _);
|
||||
transition.Completion.TrySetResult(true);
|
||||
transition.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return resumed;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
|
||||
RequestStopAsync(recordSessionId, markAsCompletedOnExit: true, cancellationToken);
|
||||
|
||||
@@ -229,6 +354,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(args.Data))
|
||||
{
|
||||
runtime.RememberCurlErrorLine(args.Data);
|
||||
_logger.LogDebug("curl[{SessionId}] {Line}", recordSession.Id, args.Data);
|
||||
}
|
||||
};
|
||||
@@ -283,6 +409,12 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
if (!_processes.TryGetValue(recordSessionId, out var runtime))
|
||||
{
|
||||
if (_sessionTransitions.TryGetValue(recordSessionId, out var transition))
|
||||
{
|
||||
transition.RequestStop(markAsCompletedOnExit);
|
||||
return await WaitForTransitionAsync(transition, timeout, cancellationToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -297,6 +429,12 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
if (!_processes.TryGetValue(recordSessionId, out var runtime))
|
||||
{
|
||||
if (_sessionTransitions.TryGetValue(recordSessionId, out var transition))
|
||||
{
|
||||
transition.RequestStop(markAsCompletedOnExit: false);
|
||||
return await WaitForTransitionAsync(transition, timeout, cancellationToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -340,6 +478,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var runtimes = _processes.Values.ToArray();
|
||||
var transitions = _sessionTransitions.Values.ToArray();
|
||||
foreach (var runtime in runtimes)
|
||||
{
|
||||
// Mark the captured runtime before looking it up again. The process may exit
|
||||
@@ -355,7 +494,13 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
cancellationToken,
|
||||
shutdownRequested: true)));
|
||||
|
||||
foreach (var transition in transitions)
|
||||
{
|
||||
transition.RequestStop(markAsCompletedOnExit: true);
|
||||
}
|
||||
|
||||
await WaitForRuntimeCompletionsAsync(runtimes, gracefulTimeout, cancellationToken);
|
||||
await WaitForTransitionCompletionsAsync(transitions, gracefulTimeout, cancellationToken);
|
||||
|
||||
foreach (var runtime in runtimes.Where(static runtime => !runtime.ExitCompletion.Task.IsCompleted))
|
||||
{
|
||||
@@ -391,7 +536,23 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
}
|
||||
|
||||
await WaitForAllProcessesAsync(forceKillTimeout, CancellationToken.None);
|
||||
return runtimes.Length;
|
||||
return runtimes.Length + transitions.Length;
|
||||
}
|
||||
|
||||
private static async Task WaitForTransitionCompletionsAsync(
|
||||
IReadOnlyCollection<SessionTransitionRuntime> transitions,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (transitions.Count == 0 || transitions.All(static item => item.Completion.Task.IsCompleted)) return;
|
||||
var completion = Task.WhenAll(transitions.Select(static item => item.Completion.Task));
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var delay = Task.Delay(timeout, timeoutCts.Token);
|
||||
if (await Task.WhenAny(completion, delay) == completion)
|
||||
{
|
||||
timeoutCts.Cancel();
|
||||
await completion;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForRuntimeCompletionsAsync(
|
||||
@@ -417,7 +578,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
private async Task WaitForAllProcessesAsync(TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while ((!_processes.IsEmpty || !_postProcessProcesses.IsEmpty || !_postProcessStates.IsEmpty) &&
|
||||
while ((!_processes.IsEmpty || !_sessionTransitions.IsEmpty || !_postProcessProcesses.IsEmpty || !_postProcessStates.IsEmpty) &&
|
||||
DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||
@@ -1020,6 +1181,215 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
return started;
|
||||
}
|
||||
|
||||
public async Task<bool> StartArtifactRepairAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_postProcessStates.ContainsKey(recordTaskId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var recordTask = await dbContext.RecordTasks
|
||||
.Include(static item => item.RecordSession)
|
||||
.Include(static item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
|
||||
if (recordTask?.Result is null || recordTask.RecordSession is null ||
|
||||
recordTask.Status is not (RecordTaskStatus.Failed or RecordTaskStatus.Processing) ||
|
||||
recordTask.Status == RecordTaskStatus.Processing &&
|
||||
recordTask.ErrorMessage?.StartsWith(ArtifactRepairMarker, StringComparison.Ordinal) != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourcePath = NormalizeAbsolutePath(recordTask.Result.FilePath);
|
||||
if (!File.Exists(sourcePath) || new FileInfo(sourcePath).Length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var settings = await settingsService.GetAsync(cancellationToken);
|
||||
var storage = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!storage.IsAvailable || storage.AvailableBytes < Math.Max(storage.RequiredBytes, new FileInfo(sourcePath).Length))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetPath = BuildRecoveredArtifactPath(sourcePath, recordTaskId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (!_postProcessStates.TryAdd(
|
||||
recordTask.Id,
|
||||
new PostProcessRuntimeEntry(
|
||||
recordTask.RecordSessionId,
|
||||
new RecordTaskRuntimeState(
|
||||
RecordTaskStatus.Processing,
|
||||
"Queued",
|
||||
0,
|
||||
$"Waiting to repair {Path.GetFileName(sourcePath)}"))))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
recordTask.MarkProcessing(
|
||||
$"{ArtifactRepairMarker} 正在生成非破坏性恢复文件:{Path.GetFileName(targetPath)}",
|
||||
now);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ClearPostProcessState(recordTask.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
_ = Task.Run(
|
||||
async () => await RunArtifactRepairAsync(recordTaskId),
|
||||
CancellationToken.None);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> ResumeArtifactRepairsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var taskIds = await dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Status == RecordTaskStatus.Processing &&
|
||||
item.ErrorMessage != null &&
|
||||
item.ErrorMessage.StartsWith(ArtifactRepairMarker))
|
||||
.OrderBy(static item => item.UpdatedAt)
|
||||
.Select(static item => item.Id)
|
||||
.Take(20)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var started = 0;
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
if (await StartArtifactRepairAsync(taskId, cancellationToken))
|
||||
{
|
||||
started++;
|
||||
}
|
||||
}
|
||||
|
||||
return started;
|
||||
}
|
||||
|
||||
private async Task RunArtifactRepairAsync(Guid recordTaskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
var recordTask = await dbContext.RecordTasks
|
||||
.Include(static item => item.RecordSession)
|
||||
.ThenInclude(static item => item!.RecordTasks)
|
||||
.Include(static item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == recordTaskId);
|
||||
if (recordTask?.Result is null || recordTask.RecordSession is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sourcePath = NormalizeAbsolutePath(recordTask.Result.FilePath);
|
||||
var targetPath = BuildRecoveredArtifactPath(sourcePath, recordTaskId);
|
||||
var originalError = recordTask.ErrorMessage;
|
||||
var settings = await settingsService.GetAsync();
|
||||
SetPostProcessState(
|
||||
recordTask.RecordSessionId,
|
||||
recordTask.Id,
|
||||
"Repairing",
|
||||
null,
|
||||
$"Repairing {Path.GetFileName(sourcePath)} without replacing the source");
|
||||
|
||||
var repair = await TryRepairArtifactFileAsync(
|
||||
settings.FfmpegPath,
|
||||
settings.MaxConcurrentFfmpegTranscodeTasks,
|
||||
settings.Mp4FinalizeTimeoutMinutes,
|
||||
recordTask.Id,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
_shutdownCts.Token);
|
||||
if (_shutdownCts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var metadata = string.IsNullOrWhiteSpace(repair.ErrorMessage)
|
||||
? await scope.ServiceProvider.GetRequiredService<IVideoMetadataService>()
|
||||
.ExtractMetadataAsync(targetPath)
|
||||
: null;
|
||||
if (metadata?.DurationSeconds is > 0 && !string.IsNullOrWhiteSpace(metadata.VideoCodec))
|
||||
{
|
||||
recordTask.MarkCompleted(now, metadata.DurationSeconds);
|
||||
recordTask.Result.Update(
|
||||
targetPath,
|
||||
new FileInfo(targetPath).Length,
|
||||
metadata.DurationSeconds,
|
||||
recordTask.Result.DanmakuFilePath,
|
||||
recordTask.Result.DanmakuMessageCount,
|
||||
RecordTaskStatus.Completed,
|
||||
null);
|
||||
recordTask.Result.ResetUploadForRecoveredArtifact();
|
||||
if (recordTask.RecordSession.RecordTasks.All(static task =>
|
||||
task.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped))
|
||||
{
|
||||
recordTask.RecordSession.MarkCompleted(now);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"Recovery",
|
||||
"录制失败产物已修复,原文件已保留。",
|
||||
$"source={sourcePath}; recovered={targetPath}; originalError={originalError}",
|
||||
recordTask.LiveRoomId,
|
||||
recordTask.RecordSessionId,
|
||||
recordTask.Id);
|
||||
SetPostProcessState(recordTask.RecordSessionId, recordTask.Id, "Completed", 100, "Recovered file is ready for manual upload");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = repair.ErrorMessage ?? "恢复输出仍无法识别有效视频流。";
|
||||
recordTask.MarkFailed($"录制产物修复失败:{error}", now, recordTask.DurationSeconds);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Recovery",
|
||||
"录制失败产物修复未成功,原文件保持不变。",
|
||||
$"source={sourcePath}; error={error}",
|
||||
recordTask.LiveRoomId,
|
||||
recordTask.RecordSessionId,
|
||||
recordTask.Id);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_shutdownCts.IsCancellationRequested)
|
||||
{
|
||||
// The persisted Processing marker is intentionally kept for restart recovery.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Artifact repair failed for task {RecordTaskId}", recordTaskId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearPostProcessState(recordTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildRecoveredArtifactPath(string sourcePath, Guid recordTaskId)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(sourcePath) ?? AppContext.BaseDirectory;
|
||||
var stem = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
return Path.Combine(directory, $"{stem}.{recordTaskId.ToString("N")[..8]}.recovered.mp4");
|
||||
}
|
||||
|
||||
private static bool NeedsInterruptedMp4Finalization(RecordTask recordTask)
|
||||
{
|
||||
if (recordTask.Status != RecordTaskStatus.Completed ||
|
||||
@@ -1081,7 +1451,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
SystemLogLevel.Info,
|
||||
"FFmpeg",
|
||||
$"ffmpeg input profile={runtime.InputOptionProfile}.",
|
||||
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; videoCodec={runtime.SelectedVideoCodec ?? "unknown"}; recoveryEncoder={runtime.RecoveryVideoEncoder.Kind}; recoveryDevice={runtime.RecoveryVideoEncoder.DevicePath ?? "default"}; attempt={runtime.RetryAttemptCount}/{MaxInSessionRetryAttempts}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}; alternateProtocolRetry={runtime.HasRetriedWithAlternateProtocol}; softwareEncoderRetry={runtime.RecoveryContext.HasRetriedWithSoftwareEncoder}",
|
||||
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; videoCodec={runtime.SelectedVideoCodec ?? "unknown"}; recoveryEncoder={runtime.RecoveryVideoEncoder.Kind}; recoveryDevice={runtime.RecoveryVideoEncoder.DevicePath ?? "default"}; attempt={runtime.RetryAttemptCount}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}; alternateProtocolRetry={runtime.HasRetriedWithAlternateProtocol}; softwareEncoderRetry={runtime.RecoveryContext.HasRetriedWithSoftwareEncoder}",
|
||||
liveRoomId: runtime.LiveRoomId,
|
||||
recordSessionId: runtime.RecordSessionId,
|
||||
recordTaskId: runtime.CurrentTaskId,
|
||||
@@ -1106,6 +1476,24 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForTransitionAsync(
|
||||
SessionTransitionRuntime transition,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var delayTask = Task.Delay(timeout, timeoutCts.Token);
|
||||
var completed = await Task.WhenAny(transition.Completion.Task, delayTask);
|
||||
if (completed != transition.Completion.Task)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
timeoutCts.Cancel();
|
||||
await transition.Completion.Task;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RequestStopAsync(
|
||||
Guid recordSessionId,
|
||||
bool markAsCompletedOnExit,
|
||||
@@ -1114,6 +1502,11 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
if (!_processes.TryGetValue(recordSessionId, out var runtime))
|
||||
{
|
||||
if (_sessionTransitions.TryGetValue(recordSessionId, out var transition))
|
||||
{
|
||||
transition.RequestStop(markAsCompletedOnExit || shutdownRequested);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
private static readonly TimeSpan PollDispatchSpacing = TimeSpan.FromMilliseconds(400);
|
||||
private static readonly TimeSpan MinimumIdleDelay = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan PerRoomPollingTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan OfflineConfirmationDelay = TimeSpan.FromSeconds(10);
|
||||
private const int MaxConcurrentLiveRoomPolls = 2;
|
||||
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
@@ -74,6 +75,7 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var settings = await settingsService.GetAsync(stoppingToken);
|
||||
var ffmpegService = scope.ServiceProvider.GetRequiredService<IFfmpegService>();
|
||||
await ffmpegService.ResumeRecoveringSessionsAsync(stoppingToken);
|
||||
var recoveredOrphanedSessions = await ffmpegService.RecoverOrphanedTerminalSessionTasksAsync(stoppingToken);
|
||||
if (recoveredOrphanedSessions > 0)
|
||||
{
|
||||
@@ -88,6 +90,9 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
// ResumePausedFinalizationsAsync internally no-ops only when space is truly
|
||||
// insufficient to remux, and it is retried every poll cycle.
|
||||
await ffmpegService.ResumePausedFinalizationsAsync(stoppingToken);
|
||||
await ffmpegService.ResumeArtifactRepairsAsync(stoppingToken);
|
||||
var shortFragmentRecovery = scope.ServiceProvider.GetRequiredService<ShortFragmentConsolidationService>();
|
||||
await shortFragmentRecovery.ResumeInterruptedMergesAsync(stoppingToken);
|
||||
|
||||
if (!settings.EnableBackgroundPolling)
|
||||
{
|
||||
@@ -237,6 +242,8 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
private static int GetEffectivePollingIntervalSeconds(int globalIntervalSeconds, int? overrideIntervalSeconds) =>
|
||||
Math.Clamp(overrideIntervalSeconds ?? globalIntervalSeconds, 10, 3600);
|
||||
|
||||
internal static bool RequiresOfflineConfirmation(bool isLive) => !isLive;
|
||||
|
||||
private async Task PollLiveRoomsAsync(
|
||||
IReadOnlyList<PollCandidate> liveRooms,
|
||||
SystemSettingsDto settings,
|
||||
@@ -359,6 +366,20 @@ public sealed class LiveRoomPollingBackgroundService : BackgroundService, ILiveR
|
||||
{
|
||||
var adapter = adapterFactory.GetByPlatform(liveRoom.Platform);
|
||||
var liveStatus = await adapter.GetLiveStatusAsync(liveRoom.RoomId, cancellationToken);
|
||||
if (RequiresOfflineConfirmation(liveStatus.IsLive))
|
||||
{
|
||||
await Task.Delay(OfflineConfirmationDelay, cancellationToken);
|
||||
var confirmation = await adapter.GetLiveStatusAsync(liveRoom.RoomId, cancellationToken);
|
||||
if (confirmation.IsLive)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Ignored a transient offline sample for live room {RoomId}; the confirmation check is live.",
|
||||
liveRoom.RoomId);
|
||||
}
|
||||
|
||||
liveStatus = confirmation;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await liveRoomStatusService.ApplySnapshotAsync(liveRoom, liveStatus, now, cancellationToken: cancellationToken);
|
||||
|
||||
@@ -10,6 +10,11 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public interface IOpenListClient
|
||||
{
|
||||
Task EnsureAuthenticatedAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
bool forceRefresh = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OpenListConnectionTestDto> TestConnectionAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
CancellationToken cancellationToken = default);
|
||||
@@ -72,16 +77,73 @@ public sealed record OpenListTaskInfo(
|
||||
string Status,
|
||||
string? Error);
|
||||
|
||||
public sealed class OpenListApiException : InvalidOperationException
|
||||
{
|
||||
public OpenListApiException(
|
||||
string operation,
|
||||
int apiCode,
|
||||
HttpStatusCode httpStatusCode,
|
||||
string apiMessage,
|
||||
TimeSpan? retryAfter = null)
|
||||
: base($"{operation} failed with code {apiCode}: {apiMessage}")
|
||||
{
|
||||
Operation = operation;
|
||||
ApiCode = apiCode;
|
||||
HttpStatusCode = httpStatusCode;
|
||||
ApiMessage = apiMessage;
|
||||
RetryAfter = retryAfter;
|
||||
}
|
||||
|
||||
public string Operation { get; }
|
||||
|
||||
public int ApiCode { get; }
|
||||
|
||||
public HttpStatusCode HttpStatusCode { get; }
|
||||
|
||||
public string ApiMessage { get; }
|
||||
|
||||
public TimeSpan? RetryAfter { get; }
|
||||
|
||||
public bool IsRateLimited =>
|
||||
HttpStatusCode == HttpStatusCode.TooManyRequests ||
|
||||
ApiCode == 429 ||
|
||||
ApiMessage.Contains("too many", StringComparison.OrdinalIgnoreCase) ||
|
||||
ApiMessage.Contains("尝试过多", StringComparison.OrdinalIgnoreCase) ||
|
||||
ApiMessage.Contains("请求过于频繁", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsAuthenticationFailure =>
|
||||
HttpStatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ||
|
||||
ApiCode is 401 or 403 ||
|
||||
Operation.Contains("login", StringComparison.OrdinalIgnoreCase) &&
|
||||
(
|
||||
ApiMessage.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
||||
ApiMessage.Contains("credential", StringComparison.OrdinalIgnoreCase) ||
|
||||
ApiMessage.Contains("用户名", StringComparison.OrdinalIgnoreCase) ||
|
||||
ApiMessage.Contains("密码", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public sealed class OpenListClient : IOpenListClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly OpenListUploadHealthState _healthState;
|
||||
private readonly ConcurrentDictionary<string, TokenCacheEntry> _tokens = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _loginGate = new(1, 1);
|
||||
|
||||
public OpenListClient(IHttpClientFactory httpClientFactory)
|
||||
public OpenListClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
OpenListUploadHealthState? healthState = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_healthState = healthState ?? new OpenListUploadHealthState();
|
||||
}
|
||||
|
||||
public async Task EnsureAuthenticatedAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
bool forceRefresh = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await GetTokenAsync(connection, forceRefresh, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<OpenListConnectionTestDto> TestConnectionAsync(
|
||||
@@ -533,6 +595,7 @@ public sealed class OpenListClient : IOpenListClient
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApiEnvelope? lastUnauthorized = null;
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
var forceRefresh = attempt > 0;
|
||||
@@ -549,10 +612,24 @@ public sealed class OpenListClient : IOpenListClient
|
||||
return envelope;
|
||||
}
|
||||
|
||||
lastUnauthorized = envelope;
|
||||
InvalidateToken(connection);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("OpenList 登录状态无效,请检查账号或密码。");
|
||||
var failure = lastUnauthorized ?? new ApiEnvelope(
|
||||
401,
|
||||
"OpenList 登录状态无效,请检查账号或密码。",
|
||||
null,
|
||||
HttpStatusCode.Unauthorized,
|
||||
null);
|
||||
var exception = new OpenListApiException(
|
||||
"OpenList authenticated request",
|
||||
failure.Code,
|
||||
failure.HttpStatusCode,
|
||||
failure.Message,
|
||||
failure.RetryAfter);
|
||||
_healthState.MarkProviderFailure(exception, DateTimeOffset.UtcNow);
|
||||
throw exception;
|
||||
}
|
||||
|
||||
private async Task<string> GetTokenAsync(
|
||||
@@ -592,6 +669,7 @@ public sealed class OpenListClient : IOpenListClient
|
||||
|
||||
var token = tokenElement.GetString()!;
|
||||
_tokens[cacheKey] = new TokenCacheEntry(token, DateTimeOffset.UtcNow.AddMinutes(20));
|
||||
_healthState.MarkHealthy();
|
||||
return token;
|
||||
}
|
||||
finally
|
||||
@@ -617,10 +695,11 @@ public sealed class OpenListClient : IOpenListClient
|
||||
|
||||
private static async Task<ApiEnvelope> ReadEnvelopeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta;
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return new ApiEnvelope((int)response.StatusCode, response.ReasonPhrase ?? "Empty response", null);
|
||||
return new ApiEnvelope((int)response.StatusCode, response.ReasonPhrase ?? "Empty response", null, response.StatusCode, retryAfter);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -636,7 +715,16 @@ public sealed class OpenListClient : IOpenListClient
|
||||
JsonElement? data = root.TryGetProperty("data", out var dataElement)
|
||||
? dataElement.Clone()
|
||||
: null;
|
||||
return new ApiEnvelope(code, message, data);
|
||||
return new ApiEnvelope(code, message, data, response.StatusCode, retryAfter);
|
||||
}
|
||||
catch (JsonException) when (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new ApiEnvelope(
|
||||
(int)response.StatusCode,
|
||||
body,
|
||||
null,
|
||||
response.StatusCode,
|
||||
retryAfter);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
@@ -644,12 +732,25 @@ public sealed class OpenListClient : IOpenListClient
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSuccess(ApiEnvelope envelope, string operation)
|
||||
private void EnsureSuccess(ApiEnvelope envelope, string operation)
|
||||
{
|
||||
if (envelope.Code != 200)
|
||||
if (envelope.Code != 200 || (int)envelope.HttpStatusCode >= 400)
|
||||
{
|
||||
throw new InvalidOperationException($"{operation} failed with code {envelope.Code}: {envelope.Message}");
|
||||
var exception = new OpenListApiException(
|
||||
operation,
|
||||
envelope.Code,
|
||||
envelope.HttpStatusCode,
|
||||
envelope.Message,
|
||||
envelope.RetryAfter);
|
||||
if (exception.IsRateLimited || exception.IsAuthenticationFailure)
|
||||
{
|
||||
_healthState.MarkProviderFailure(exception, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
throw exception;
|
||||
}
|
||||
|
||||
_healthState.MarkHealthy();
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string? value, params string[] candidates) =>
|
||||
@@ -681,5 +782,10 @@ public sealed class OpenListClient : IOpenListClient
|
||||
|
||||
private sealed record TokenCacheEntry(string Token, DateTimeOffset ExpiresAt);
|
||||
|
||||
private sealed record ApiEnvelope(int Code, string Message, JsonElement? Data);
|
||||
private sealed record ApiEnvelope(
|
||||
int Code,
|
||||
string Message,
|
||||
JsonElement? Data,
|
||||
HttpStatusCode HttpStatusCode,
|
||||
TimeSpan? RetryAfter);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public enum OpenListQueueHealthStatus
|
||||
{
|
||||
Healthy,
|
||||
RateLimited,
|
||||
AuthenticationBlocked,
|
||||
Disabled
|
||||
}
|
||||
|
||||
public sealed record OpenListQueueHealthSnapshot(
|
||||
OpenListQueueHealthStatus Status,
|
||||
string? Reason,
|
||||
DateTimeOffset? RetryAt,
|
||||
DateTimeOffset? LastErrorAt)
|
||||
{
|
||||
public bool IsPaused => Status != OpenListQueueHealthStatus.Healthy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide OpenList availability guard. Upload jobs are persisted in the database,
|
||||
/// while provider-wide authentication/rate-limit failures are deliberately kept out of
|
||||
/// individual retry budgets.
|
||||
/// </summary>
|
||||
public sealed class OpenListUploadHealthState
|
||||
{
|
||||
private static readonly TimeSpan DefaultRateLimitDelay = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan MaximumRateLimitDelay = TimeSpan.FromHours(1);
|
||||
private readonly object _gate = new();
|
||||
private OpenListQueueHealthStatus _status = OpenListQueueHealthStatus.Healthy;
|
||||
private string? _reason;
|
||||
private DateTimeOffset? _retryAt;
|
||||
private DateTimeOffset? _lastErrorAt;
|
||||
private int _consecutiveRateLimits;
|
||||
|
||||
public OpenListQueueHealthSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return new OpenListQueueHealthSnapshot(_status, _reason, _retryAt, _lastErrorAt);
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanProcess(DateTimeOffset now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _status switch
|
||||
{
|
||||
OpenListQueueHealthStatus.Healthy => true,
|
||||
OpenListQueueHealthStatus.RateLimited => _retryAt.HasValue && _retryAt <= now,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkHealthy()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_status = OpenListQueueHealthStatus.Healthy;
|
||||
_reason = null;
|
||||
_retryAt = null;
|
||||
_lastErrorAt = null;
|
||||
_consecutiveRateLimits = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkEnabled()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_status == OpenListQueueHealthStatus.Disabled)
|
||||
{
|
||||
_status = OpenListQueueHealthStatus.Healthy;
|
||||
_reason = null;
|
||||
_retryAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkDisabled(string reason)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_status = OpenListQueueHealthStatus.Disabled;
|
||||
_reason = reason;
|
||||
_retryAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkProviderFailure(OpenListApiException exception, DateTimeOffset now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lastErrorAt = now;
|
||||
_reason = exception.Message;
|
||||
if (exception.IsRateLimited)
|
||||
{
|
||||
_consecutiveRateLimits++;
|
||||
var multiplier = Math.Pow(2, Math.Clamp(_consecutiveRateLimits - 1, 0, 2));
|
||||
var calculated = TimeSpan.FromTicks((long)(DefaultRateLimitDelay.Ticks * multiplier));
|
||||
var delay = exception.RetryAfter.GetValueOrDefault(calculated);
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
delay = DefaultRateLimitDelay;
|
||||
}
|
||||
|
||||
if (delay > MaximumRateLimitDelay)
|
||||
{
|
||||
delay = MaximumRateLimitDelay;
|
||||
}
|
||||
|
||||
_status = OpenListQueueHealthStatus.RateLimited;
|
||||
_retryAt = now.Add(delay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception.IsAuthenticationFailure)
|
||||
{
|
||||
_status = OpenListQueueHealthStatus.AuthenticationBlocked;
|
||||
_retryAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public sealed class OpenListUploadQueueService
|
||||
private readonly LiveRecorderDbContext _dbContext;
|
||||
private readonly ISystemSettingsService _settingsService;
|
||||
private readonly IOpenListClient _openListClient;
|
||||
private readonly OpenListUploadHealthState _healthState;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
private readonly IVideoMetadataService _videoMetadataService;
|
||||
|
||||
@@ -41,16 +42,43 @@ public sealed class OpenListUploadQueueService
|
||||
LiveRecorderDbContext dbContext,
|
||||
ISystemSettingsService settingsService,
|
||||
IOpenListClient openListClient,
|
||||
OpenListUploadHealthState healthState,
|
||||
ISystemLogService systemLogService,
|
||||
IVideoMetadataService videoMetadataService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_settingsService = settingsService;
|
||||
_openListClient = openListClient;
|
||||
_healthState = healthState;
|
||||
_systemLogService = systemLogService;
|
||||
_videoMetadataService = videoMetadataService;
|
||||
}
|
||||
|
||||
public OpenListQueueHealthSnapshot GetHealthSnapshot() => _healthState.GetSnapshot();
|
||||
|
||||
public async Task<OpenListQueueHealthSnapshot> ResumeProviderAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _settingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableFileUpload || settings.UploadTarget != UploadTargetType.OpenList)
|
||||
{
|
||||
_healthState.MarkDisabled("OpenList 上传未启用。");
|
||||
return _healthState.GetSnapshot();
|
||||
}
|
||||
|
||||
ValidateSettings(settings.OpenListUpload);
|
||||
_healthState.MarkEnabled();
|
||||
var connection = new OpenListConnectionRequest
|
||||
{
|
||||
BaseUrl = settings.OpenListUpload.BaseUrl,
|
||||
Username = settings.OpenListUpload.Username,
|
||||
Password = settings.OpenListUpload.Password
|
||||
};
|
||||
await _openListClient.EnsureAuthenticatedAsync(connection, forceRefresh: true, cancellationToken);
|
||||
_healthState.MarkHealthy();
|
||||
return _healthState.GetSnapshot();
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadItemResultDto?> TryEnqueueAutomaticAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -83,6 +111,7 @@ public sealed class OpenListUploadQueueService
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
(item.Status == RecordTaskStatus.Completed || item.Status == RecordTaskStatus.Stopped) &&
|
||||
!item.IsHiddenArtifactSource &&
|
||||
item.Result != null &&
|
||||
item.Result.UploadStatus == RecordArtifactUploadStatus.NotUploaded &&
|
||||
item.UploadJob == null &&
|
||||
@@ -148,7 +177,7 @@ public sealed class OpenListUploadQueueService
|
||||
{
|
||||
var taskIds = await _dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Where(item => item.RecordSessionId == recordSessionId)
|
||||
.Where(item => item.RecordSessionId == recordSessionId && !item.IsHiddenArtifactSource)
|
||||
.OrderBy(static item => item.SegmentIndex)
|
||||
.ThenBy(static item => item.CreatedAt)
|
||||
.Select(static item => item.Id)
|
||||
@@ -180,9 +209,113 @@ public sealed class OpenListUploadQueueService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadItemResultDto> RetryAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _settingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableFileUpload || settings.UploadTarget != UploadTargetType.OpenList)
|
||||
{
|
||||
return Failure(recordTaskId, "OpenList 上传未启用。", "openlist");
|
||||
}
|
||||
|
||||
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 || recordTask.UploadJob is null || recordTask.IsHiddenArtifactSource)
|
||||
{
|
||||
return Failure(recordTaskId, "该任务没有可重试的上传作业。", "openlist");
|
||||
}
|
||||
|
||||
if (recordTask.UploadJob.Status == RecordArtifactUploadStatus.Failed)
|
||||
{
|
||||
return await EnqueueInternalAsync(recordTaskId, settings, cancellationToken);
|
||||
}
|
||||
|
||||
if (recordTask.UploadJob.Status != RecordArtifactUploadStatus.WaitingRetry)
|
||||
{
|
||||
return Failure(recordTaskId, "只有上传失败或等待重试的任务可以立即重试。", "openlist");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
recordTask.UploadJob.RequestImmediateRetry(now);
|
||||
recordTask.Result.MarkUploadQueued("openlist", now);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return QueuedResult(recordTaskId, recordTask.Result, recordTask.UploadJob, "已重置重试次数并立即加入队列。");
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadBatchResultDto> RetryMatchingAsync(
|
||||
RecordArtifactUploadStatus? uploadStatus,
|
||||
string? query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (uploadStatus.HasValue && uploadStatus is not (RecordArtifactUploadStatus.Failed or RecordArtifactUploadStatus.WaitingRetry))
|
||||
{
|
||||
return new RecordArtifactUploadBatchResultDto
|
||||
{
|
||||
RequestedCount = 0,
|
||||
SuccessCount = 0,
|
||||
FailedCount = 1,
|
||||
Items = [Failure(Guid.Empty, "批量重试仅支持上传失败和等待重试状态。", "openlist")]
|
||||
};
|
||||
}
|
||||
|
||||
var statuses = uploadStatus.HasValue
|
||||
? new[] { uploadStatus.Value }
|
||||
: new[] { RecordArtifactUploadStatus.Failed, RecordArtifactUploadStatus.WaitingRetry };
|
||||
var normalizedQuery = query?.Trim().ToLowerInvariant();
|
||||
var taskQuery = _dbContext.RecordResults
|
||||
.AsNoTracking()
|
||||
.Where(item => statuses.Contains(item.UploadStatus) && item.RecordTask != null);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedQuery))
|
||||
{
|
||||
var parsedTaskId = Guid.TryParse(normalizedQuery, out var taskId) ? taskId : (Guid?)null;
|
||||
taskQuery = taskQuery.Where(item =>
|
||||
parsedTaskId.HasValue && item.RecordTaskId == parsedTaskId.Value ||
|
||||
item.FilePath.ToLower().Contains(normalizedQuery) ||
|
||||
item.RemoteVideoPath != null && item.RemoteVideoPath.ToLower().Contains(normalizedQuery) ||
|
||||
item.UploadErrorMessage != null && item.UploadErrorMessage.ToLower().Contains(normalizedQuery) ||
|
||||
item.RecordTask!.LiveRoom != null &&
|
||||
((item.RecordTask.LiveRoom.Title != null && item.RecordTask.LiveRoom.Title.ToLower().Contains(normalizedQuery)) ||
|
||||
item.RecordTask.LiveRoom.RoomId.ToLower().Contains(normalizedQuery)));
|
||||
}
|
||||
|
||||
var taskIds = await taskQuery
|
||||
.OrderBy(static item => item.CreatedAt)
|
||||
.Select(static item => item.RecordTaskId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var items = new List<RecordArtifactUploadItemResultDto>(taskIds.Length);
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
items.Add(await RetryAsync(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 settings = await _settingsService.GetAsync(cancellationToken);
|
||||
if (!settings.EnableFileUpload || settings.UploadTarget != UploadTargetType.OpenList)
|
||||
{
|
||||
_healthState.MarkDisabled("OpenList 上传已在设置中关闭。");
|
||||
return false;
|
||||
}
|
||||
|
||||
_healthState.MarkEnabled();
|
||||
if (!_healthState.CanProcess(now))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var jobs = _dbContext.RecordUploadJobs
|
||||
.Include(static item => item.RecordTask)
|
||||
.ThenInclude(static item => item!.Result)
|
||||
@@ -215,6 +348,38 @@ public sealed class OpenListUploadQueueService
|
||||
}
|
||||
|
||||
var result = job.RecordTask.Result;
|
||||
var connection = new OpenListConnectionRequest
|
||||
{
|
||||
BaseUrl = job.ProviderEndpoint,
|
||||
Username = settings.OpenListUpload.Username,
|
||||
Password = settings.OpenListUpload.Password
|
||||
};
|
||||
try
|
||||
{
|
||||
await _openListClient.EnsureAuthenticatedAsync(connection, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (OpenListApiException ex) when (ex.IsRateLimited || ex.IsAuthenticationFailure)
|
||||
{
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Upload",
|
||||
"OpenList 上传队列已暂停,任务重试次数未消耗。",
|
||||
ex.Message,
|
||||
cancellationToken: cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (job.Status != RecordArtifactUploadStatus.Uploading)
|
||||
{
|
||||
job.BeginAttempt(now);
|
||||
result.MarkUploadStarted("openlist", now);
|
||||
}
|
||||
|
||||
await ScheduleRetryOrFailAsync(job, result, ex.Message, clearExternalTask: false, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
var validationError = await GetUploadValidationErrorAsync(
|
||||
job.RecordTask,
|
||||
NormalizeAbsolutePath(result.FilePath),
|
||||
@@ -225,7 +390,8 @@ public sealed class OpenListUploadQueueService
|
||||
return true;
|
||||
}
|
||||
|
||||
if (job.Status != RecordArtifactUploadStatus.Uploading)
|
||||
var attemptStartedThisRun = job.Status != RecordArtifactUploadStatus.Uploading;
|
||||
if (attemptStartedThisRun)
|
||||
{
|
||||
job.BeginAttempt(now);
|
||||
result.MarkUploadStarted("openlist", now);
|
||||
@@ -234,13 +400,6 @@ public sealed class OpenListUploadQueueService
|
||||
|
||||
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)
|
||||
@@ -251,6 +410,19 @@ public sealed class OpenListUploadQueueService
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OpenListApiException ex) when (ex.IsRateLimited || ex.IsAuthenticationFailure)
|
||||
{
|
||||
var pausedAt = DateTimeOffset.UtcNow;
|
||||
job.SuspendForProviderFailure(ex.Message, pausedAt, attemptStartedThisRun);
|
||||
result.MarkUploadWaitingRetry("openlist", ex.Message, pausedAt);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Upload",
|
||||
"OpenList 上传队列已暂停,当前任务重试次数已回退。",
|
||||
ex.Message,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ScheduleRetryOrFailAsync(job, result, ex.Message, clearExternalTask: false, cancellationToken);
|
||||
@@ -274,6 +446,11 @@ public sealed class OpenListUploadQueueService
|
||||
return Failure(recordTaskId, "录制结果尚未生成,不能上传。", "openlist");
|
||||
}
|
||||
|
||||
if (recordTask.IsHiddenArtifactSource)
|
||||
{
|
||||
return Failure(recordTaskId, "该源分片已被短片合并流程收纳,不能单独上传。", "openlist");
|
||||
}
|
||||
|
||||
var result = recordTask.Result;
|
||||
var localVideoPath = NormalizeAbsolutePath(result.FilePath);
|
||||
if (string.IsNullOrWhiteSpace(localVideoPath) || !File.Exists(localVideoPath))
|
||||
|
||||
@@ -63,6 +63,38 @@ public sealed class RecordUploadService
|
||||
return await UploadTaskInternalAsync(recordTaskId, settings, automatic: false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadItemResultDto> RetryTaskAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (settings.UploadTarget != UploadTargetType.OpenList)
|
||||
{
|
||||
return CreateFailureResult(recordTaskId, "当前上传目标不支持队列重试。");
|
||||
}
|
||||
|
||||
return await _openListUploadQueue.RetryAsync(recordTaskId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadBatchResultDto> RetryMatchingAsync(
|
||||
RetryRecordArtifactUploadsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await _systemSettingsService.GetAsync(cancellationToken);
|
||||
if (settings.UploadTarget != UploadTargetType.OpenList)
|
||||
{
|
||||
return new RecordArtifactUploadBatchResultDto
|
||||
{
|
||||
RequestedCount = 0,
|
||||
SuccessCount = 0,
|
||||
FailedCount = 1,
|
||||
Items = [CreateFailureResult(Guid.Empty, "当前上传目标不支持队列重试。")]
|
||||
};
|
||||
}
|
||||
|
||||
return await _openListUploadQueue.RetryMatchingAsync(request.UploadStatus, request.Query, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordArtifactUploadBatchResultDto> UploadSessionAsync(
|
||||
Guid recordSessionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -98,6 +130,7 @@ public sealed class RecordUploadService
|
||||
}
|
||||
|
||||
var taskIds = session.RecordTasks
|
||||
.Where(static item => !item.IsHiddenArtifactSource)
|
||||
.OrderBy(static item => item.SegmentIndex)
|
||||
.ThenBy(static item => item.CreatedAt)
|
||||
.Select(static item => item.Id)
|
||||
@@ -135,6 +168,11 @@ public sealed class RecordUploadService
|
||||
return CreateFailureResult(recordTaskId, "Recording result is not ready for upload.");
|
||||
}
|
||||
|
||||
if (recordTask.IsHiddenArtifactSource)
|
||||
{
|
||||
return CreateFailureResult(recordTaskId, "该分片已并入相邻录像或转存恢复目录,不能单独上传。");
|
||||
}
|
||||
|
||||
if (!settings.EnableFileUpload || settings.UploadTarget == UploadTargetType.None)
|
||||
{
|
||||
return CreateFailureResult(recordTaskId, "File upload is disabled or no upload target is configured.");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Abstractions.Storage;
|
||||
using LiveRecorder.Application.Common;
|
||||
@@ -19,19 +20,25 @@ public sealed class RecoveryService
|
||||
private readonly IStorageGuardService _storageGuardService;
|
||||
private readonly IFfmpegService _ffmpegService;
|
||||
private readonly RecordService _recordService;
|
||||
private readonly IVideoMetadataService _videoMetadataService;
|
||||
private readonly ISystemLogService _systemLogService;
|
||||
|
||||
public RecoveryService(
|
||||
LiveRecorderDbContext dbContext,
|
||||
ISystemSettingsService systemSettingsService,
|
||||
IStorageGuardService storageGuardService,
|
||||
IFfmpegService ffmpegService,
|
||||
RecordService recordService)
|
||||
RecordService recordService,
|
||||
IVideoMetadataService videoMetadataService,
|
||||
ISystemLogService systemLogService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_storageGuardService = storageGuardService;
|
||||
_ffmpegService = ffmpegService;
|
||||
_recordService = recordService;
|
||||
_videoMetadataService = videoMetadataService;
|
||||
_systemLogService = systemLogService;
|
||||
}
|
||||
|
||||
public async Task<RecoveryOverviewDto> GetOverviewAsync(CancellationToken cancellationToken = default)
|
||||
@@ -40,6 +47,7 @@ public sealed class RecoveryService
|
||||
var storage = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
var liveRooms = await ListRecoverableLiveRoomsAsync(cancellationToken);
|
||||
var finalizations = await ListRecoverableFinalizationsAsync(cancellationToken);
|
||||
var mergedArtifacts = await ListMergedArtifactsAsync(cancellationToken);
|
||||
|
||||
return new RecoveryOverviewDto
|
||||
{
|
||||
@@ -61,10 +69,69 @@ public sealed class RecoveryService
|
||||
RedThresholdPercent = storage.RedThresholdPercent
|
||||
},
|
||||
LiveRooms = liveRooms,
|
||||
Finalizations = finalizations
|
||||
Finalizations = finalizations,
|
||||
MergedArtifacts = mergedArtifacts
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<MergedArtifactRecordDto>> ListMergedArtifactsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sources = await _dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Result)
|
||||
.Where(item => item.IsHiddenArtifactSource)
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.Take(100)
|
||||
.ToListAsync(cancellationToken);
|
||||
var targetIds = sources.Where(item => item.MergedIntoRecordTaskId.HasValue)
|
||||
.Select(item => item.MergedIntoRecordTaskId!.Value)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var targets = await _dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Result)
|
||||
.Where(item => targetIds.Contains(item.Id))
|
||||
.ToDictionaryAsync(item => item.Id, cancellationToken);
|
||||
|
||||
return sources.Select(source =>
|
||||
{
|
||||
var sourcePath = source.Result?.FilePath ?? source.OutputFilePath;
|
||||
var parent = string.IsNullOrWhiteSpace(sourcePath) ? null : Path.GetDirectoryName(Path.GetFullPath(sourcePath));
|
||||
var recoveryBase = parent is null ? null : Path.Combine(parent, ".liverecorder-recovery", source.RecordSessionId.ToString("N"));
|
||||
var manifest = FindRecoveryManifest(recoveryBase, source.Id);
|
||||
return new MergedArtifactRecordDto
|
||||
{
|
||||
SourceRecordTaskId = source.Id,
|
||||
RecordSessionId = source.RecordSessionId,
|
||||
MergedIntoRecordTaskId = source.MergedIntoRecordTaskId,
|
||||
SourceVideoPath = sourcePath,
|
||||
MergedVideoPath = source.MergedIntoRecordTaskId.HasValue && targets.TryGetValue(source.MergedIntoRecordTaskId.Value, out var target)
|
||||
? target.Result?.FilePath
|
||||
: null,
|
||||
RecoveryDirectory = manifest is null ? recoveryBase : Path.GetDirectoryName(manifest),
|
||||
ManifestPath = manifest,
|
||||
SourceDurationSeconds = source.Result?.DurationSeconds ?? source.DurationSeconds,
|
||||
CreatedAt = source.UpdatedAt
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static string? FindRecoveryManifest(string? recoveryBase, Guid recordTaskId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(recoveryBase) || !Directory.Exists(recoveryBase)) return null;
|
||||
try
|
||||
{
|
||||
foreach (var path in Directory.EnumerateFiles(recoveryBase, "manifest.json", SearchOption.AllDirectories).Take(100))
|
||||
{
|
||||
if (File.ReadAllText(path).Contains(recordTaskId.ToString(), StringComparison.OrdinalIgnoreCase)) return path;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<RecoveryActionResultDto> RetryLiveRoomAsync(Guid liveRoomId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var room = await _dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
|
||||
@@ -225,6 +292,211 @@ public sealed class RecoveryService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RecordingFailureListResponse> ListRecordingFailuresAsync(
|
||||
string? failureKind,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tasks = await _dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Include(static item => item.LiveRoom)
|
||||
.Include(static item => item.Result)
|
||||
.Where(item => item.Status == RecordTaskStatus.Failed ||
|
||||
item.Status == RecordTaskStatus.Processing &&
|
||||
item.ErrorMessage != null &&
|
||||
item.ErrorMessage.StartsWith("[artifact-repair]"))
|
||||
.OrderByDescending(static item => item.UpdatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var items = tasks.Select(MapRecordingFailure).AsEnumerable();
|
||||
if (!string.IsNullOrWhiteSpace(failureKind))
|
||||
{
|
||||
items = items.Where(item => item.FailureKind.Equals(failureKind.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var materialized = items.ToList();
|
||||
return new RecordingFailureListResponse
|
||||
{
|
||||
TotalCount = materialized.Count,
|
||||
Items = materialized
|
||||
.Skip(Math.Max(0, skip))
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RecoveryActionResultDto> AcceptRecordingArtifactAsync(
|
||||
Guid recordTaskId,
|
||||
bool confirmShortArtifact,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var task = await _dbContext.RecordTasks
|
||||
.Include(static item => item.RecordSession)
|
||||
.ThenInclude(static item => item!.RecordTasks)
|
||||
.Include(static item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
|
||||
if (task?.Result is null || task.Status != RecordTaskStatus.Failed)
|
||||
{
|
||||
return FailureResult("该任务不是可认领的录制失败产物。");
|
||||
}
|
||||
|
||||
var path = NormalizeAbsolutePath(task.Result.FilePath);
|
||||
if (!File.Exists(path) || new FileInfo(path).Length <= 0)
|
||||
{
|
||||
return FailureResult("本地媒体文件不存在或为空,无法认领。");
|
||||
}
|
||||
|
||||
var metadata = await _videoMetadataService.ExtractMetadataAsync(path, cancellationToken);
|
||||
if (metadata is null || string.IsNullOrWhiteSpace(metadata.VideoCodec) || metadata.DurationSeconds is not > 0)
|
||||
{
|
||||
return FailureResult("媒体仍无法读取,请先使用非破坏修复。");
|
||||
}
|
||||
|
||||
if (metadata.DurationSeconds < 5 && !confirmShortArtifact)
|
||||
{
|
||||
return FailureResult($"媒体只有 {metadata.DurationSeconds:0.###} 秒,需要确认短分片后才能认领。");
|
||||
}
|
||||
|
||||
var originalError = task.ErrorMessage;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
task.MarkCompleted(task.EndedAt ?? now, metadata.DurationSeconds);
|
||||
task.Result.Update(
|
||||
path,
|
||||
new FileInfo(path).Length,
|
||||
metadata.DurationSeconds,
|
||||
task.Result.DanmakuFilePath,
|
||||
task.Result.DanmakuMessageCount,
|
||||
RecordTaskStatus.Completed,
|
||||
null);
|
||||
task.Result.ResetUploadForRecoveredArtifact();
|
||||
if (task.RecordSession is not null && task.RecordSession.RecordTasks.All(static item =>
|
||||
item.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped))
|
||||
{
|
||||
task.RecordSession.MarkCompleted(now);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"Recovery",
|
||||
"录制失败产物已由管理员确认有效,等待手动上传。",
|
||||
$"path={path}; duration={metadata.DurationSeconds:0.###}; originalError={originalError}",
|
||||
task.LiveRoomId,
|
||||
task.RecordSessionId,
|
||||
task.Id,
|
||||
cancellationToken);
|
||||
return new RecoveryActionResultDto
|
||||
{
|
||||
RequestedCount = 1,
|
||||
SuccessCount = 1,
|
||||
FailedCount = 0,
|
||||
Messages = ["分片已认领为有效产物,可前往上传任务页面手动上传。"]
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RecoveryActionResultDto> RepairRecordingArtifactAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var started = await _ffmpegService.StartArtifactRepairAsync(recordTaskId, cancellationToken);
|
||||
return new RecoveryActionResultDto
|
||||
{
|
||||
RequestedCount = 1,
|
||||
SuccessCount = started ? 1 : 0,
|
||||
FailedCount = started ? 0 : 1,
|
||||
Messages =
|
||||
[
|
||||
started
|
||||
? "非破坏修复已排队;原文件不会被覆盖或删除。"
|
||||
: "无法启动修复:任务可能正在处理、文件缺失或可用空间不足。"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static RecordingFailureItemDto MapRecordingFailure(RecordTask task)
|
||||
{
|
||||
var result = task.Result;
|
||||
var path = result?.FilePath;
|
||||
var normalizedPath = string.IsNullOrWhiteSpace(path) ? null : NormalizeAbsolutePath(path);
|
||||
var fileExists = normalizedPath is not null && File.Exists(normalizedPath) && new FileInfo(normalizedPath).Length > 0;
|
||||
var classification = ClassifyRecordingFailure(task, fileExists);
|
||||
var isRepairing = task.Status == RecordTaskStatus.Processing &&
|
||||
task.ErrorMessage?.StartsWith("[artifact-repair]", StringComparison.Ordinal) == true;
|
||||
return new RecordingFailureItemDto
|
||||
{
|
||||
RecordTaskId = task.Id,
|
||||
RecordSessionId = task.RecordSessionId,
|
||||
LiveRoomId = task.LiveRoomId,
|
||||
LiveRoomTitle = task.LiveRoom?.Title ?? task.LiveRoom?.AnchorName ?? task.LiveRoom?.RoomId ?? "未知直播间",
|
||||
RoomId = task.LiveRoom?.RoomId ?? "-",
|
||||
PlatformName = task.LiveRoom?.Platform.ToString() ?? "Unknown",
|
||||
SegmentIndex = task.SegmentIndex,
|
||||
FailureKind = classification.Kind,
|
||||
FailureLabel = classification.Label,
|
||||
RecommendedAction = classification.Action,
|
||||
ErrorMessage = task.ErrorMessage ?? result?.ErrorMessage,
|
||||
FilePath = normalizedPath,
|
||||
FileSizeBytes = result?.FileSizeBytes,
|
||||
DurationSeconds = result?.DurationSeconds ?? task.DurationSeconds,
|
||||
FileExists = fileExists,
|
||||
CanAccept = !isRepairing && fileExists && classification.Kind is "ReadableFragment" or "TooShort" or "Unknown",
|
||||
CanRepair = !isRepairing && fileExists && classification.Kind is "UnreadableMedia" or "FinalizationFailed" or "Unknown",
|
||||
CanRetryRoom = !isRepairing && task.LiveRoom?.AvailabilityStatus == LiveRoomAvailabilityStatus.Live,
|
||||
IsRepairing = isRepairing,
|
||||
CreatedAt = task.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Kind, string Label, string Action) ClassifyRecordingFailure(RecordTask task, bool fileExists)
|
||||
{
|
||||
var error = task.ErrorMessage ?? task.Result?.ErrorMessage ?? string.Empty;
|
||||
if (task.Status == RecordTaskStatus.Processing && error.StartsWith("[artifact-repair]", StringComparison.Ordinal))
|
||||
{
|
||||
return ("Repairing", "正在修复", "等待修复完成,应用重启后会自动续排。");
|
||||
}
|
||||
|
||||
if (!fileExists)
|
||||
{
|
||||
return ("MissingMedia", "文件缺失", "历史媒体文件不存在,无法恢复;若直播仍在线可重新开录。");
|
||||
}
|
||||
|
||||
if (error.Contains("offline", StringComparison.OrdinalIgnoreCase) || error.Contains("已离线", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("SourceOffline", "直播已离线", "历史时段无法补录;直播重新在线后可重新开录。");
|
||||
}
|
||||
|
||||
if (error.Contains("播放地址", StringComparison.OrdinalIgnoreCase) || error.Contains("stream url", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("StreamUrlUnavailable", "未取得播放地址", "等待下一次巡检刷新播放地址,在线时可手动重试开录。");
|
||||
}
|
||||
|
||||
if (error.Contains("short fragment", StringComparison.OrdinalIgnoreCase) ||
|
||||
task.Result?.DurationSeconds is > 0 and < 5)
|
||||
{
|
||||
return ("TooShort", "短分片", "确认内容有价值后可人工认领;不足 5 秒需要二次确认。");
|
||||
}
|
||||
|
||||
if (error.Contains("ffprobe", StringComparison.OrdinalIgnoreCase) ||
|
||||
error.Contains("does not contain a video", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("UnreadableMedia", "媒体不可读", "尝试生成新的恢复文件;原文件保持不变。");
|
||||
}
|
||||
|
||||
if (error.Contains("finaliz", StringComparison.OrdinalIgnoreCase) ||
|
||||
error.Contains("mux", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("FinalizationFailed", "封装或收尾失败", "先重新封装,失败后再转码修复。");
|
||||
}
|
||||
|
||||
if (task.Result?.DurationSeconds is > 0)
|
||||
{
|
||||
return ("ReadableFragment", "异常退出分片", "重新校验媒体;确认有效后转为待上传。");
|
||||
}
|
||||
|
||||
return ("Unknown", "待检测", "可先尝试校验认领;无法读取时再执行非破坏修复。");
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<RecoverableLiveRoomDto>> ListRecoverableLiveRoomsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var activeLiveRoomIds = await _dbContext.RecordSessions
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed class ShortFragmentConsolidationService
|
||||
{
|
||||
internal const double MinimumStandaloneDurationSeconds = 5;
|
||||
private static readonly TimeSpan ProcessTimeout = TimeSpan.FromMinutes(20);
|
||||
private readonly LiveRecorderDbContext _dbContext;
|
||||
private readonly ISystemSettingsService _settingsService;
|
||||
private readonly IVideoMetadataService _metadataService;
|
||||
private readonly ILogger<ShortFragmentConsolidationService> _logger;
|
||||
|
||||
public ShortFragmentConsolidationService(
|
||||
LiveRecorderDbContext dbContext,
|
||||
ISystemSettingsService settingsService,
|
||||
IVideoMetadataService metadataService,
|
||||
ILogger<ShortFragmentConsolidationService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_settingsService = settingsService;
|
||||
_metadataService = metadataService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<FragmentDispatchDecision> PrepareDispatchAsync(Guid recordTaskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _dbContext.RecordTasks
|
||||
.Include(item => item.RecordSession)
|
||||
.Include(item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
|
||||
if (task?.RecordSession is null || task.Result is null)
|
||||
{
|
||||
return FragmentDispatchDecision.Wait("录制结果仍在写入。", TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
if (task.IsHiddenArtifactSource)
|
||||
{
|
||||
return FragmentDispatchDecision.Skip("该分片已并入相邻分片。", task.MergedIntoRecordTaskId);
|
||||
}
|
||||
|
||||
var tasks = await _dbContext.RecordTasks
|
||||
.Include(item => item.Result)
|
||||
.Where(item => item.RecordSessionId == task.RecordSessionId && !item.IsHiddenArtifactSource)
|
||||
.OrderBy(item => item.SegmentIndex)
|
||||
.ThenBy(item => item.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
var index = tasks.FindIndex(item => item.Id == recordTaskId);
|
||||
if (index < 0)
|
||||
{
|
||||
return FragmentDispatchDecision.Skip("该分片已被恢复流程收纳。", task.MergedIntoRecordTaskId);
|
||||
}
|
||||
|
||||
var next = index + 1 < tasks.Count ? tasks[index + 1] : null;
|
||||
if (next is not null && IsActive(next.Status))
|
||||
{
|
||||
var runtime = next.StartedAt.HasValue ? DateTimeOffset.UtcNow - next.StartedAt.Value : TimeSpan.Zero;
|
||||
if (runtime < TimeSpan.FromSeconds(MinimumStandaloneDurationSeconds) || IsShort(task))
|
||||
{
|
||||
return FragmentDispatchDecision.Wait("等待下一分片完成短片判定。", TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
else if (next is null && IsActive(task.RecordSession.Status))
|
||||
{
|
||||
return FragmentDispatchDecision.Wait("等待下一分片,避免短片被提前上传。", TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
var group = ResolveMergeGroup(tasks, index);
|
||||
if (group.Count == 1 && !IsShort(task))
|
||||
{
|
||||
return FragmentDispatchDecision.Ready;
|
||||
}
|
||||
|
||||
var totalDuration = group.Sum(item => item.Result?.DurationSeconds ?? 0);
|
||||
var target = group.FirstOrDefault(item => !IsShort(item));
|
||||
if (target is null)
|
||||
{
|
||||
if (IsActive(task.RecordSession.Status))
|
||||
{
|
||||
return FragmentDispatchDecision.Wait("短分片正在等待相邻有效分片。", TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
if (totalDuration < MinimumStandaloneDurationSeconds)
|
||||
{
|
||||
await MoveRecoveryOnlyAsync(group, cancellationToken);
|
||||
return FragmentDispatchDecision.Skip("本次会话仅产生不足 5 秒的可读媒体,已转存到恢复目录。", null);
|
||||
}
|
||||
|
||||
target = group[^1];
|
||||
}
|
||||
|
||||
var merged = await MergeAsync(task.RecordSession, target, group, cancellationToken);
|
||||
return merged
|
||||
? target.Id == task.Id
|
||||
? FragmentDispatchDecision.Ready
|
||||
: FragmentDispatchDecision.Skip("该分片已并入相邻分片。", target.Id)
|
||||
: FragmentDispatchDecision.Wait("短分片合并失败,原文件已保留,将自动重试。", TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
public async Task<int> ResumeInterruptedMergesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sources = await _dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Result)
|
||||
.Where(item => item.IsHiddenArtifactSource && item.Result != null)
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.Take(100)
|
||||
.ToListAsync(cancellationToken);
|
||||
var manifests = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(Path.GetFullPath(source.Result!.FilePath));
|
||||
var recoveryBase = parent is null ? null : Path.Combine(parent, ".liverecorder-recovery", source.RecordSessionId.ToString("N"));
|
||||
if (recoveryBase is null || !Directory.Exists(recoveryBase)) continue;
|
||||
foreach (var path in Directory.EnumerateFiles(recoveryBase, "manifest.json", SearchOption.AllDirectories).Take(100)) manifests.Add(path);
|
||||
}
|
||||
|
||||
var resumed = 0;
|
||||
foreach (var path in manifests)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = JsonSerializer.Deserialize<MergeRecoveryManifest>(await File.ReadAllTextAsync(path, cancellationToken));
|
||||
if (manifest is null || manifest.Phase == "completed" || string.IsNullOrWhiteSpace(manifest.MergedPath)) continue;
|
||||
var targetPath = await _dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Id == manifest.TargetRecordTaskId && item.Result != null)
|
||||
.Select(item => item.Result!.FilePath)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (!File.Exists(manifest.MergedPath) || !string.Equals(Path.GetFullPath(targetPath ?? string.Empty), Path.GetFullPath(manifest.MergedPath), StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
foreach (var source in manifest.Sources)
|
||||
{
|
||||
CopyToRecovery(source.VideoPath, manifest.RecoveryDirectory);
|
||||
CopyToRecovery(source.DanmakuPath, manifest.RecoveryDirectory);
|
||||
DeleteOriginal(source.VideoPath, manifest.MergedPath);
|
||||
DeleteOriginal(source.DanmakuPath, Path.ChangeExtension(manifest.MergedPath, ".xml"));
|
||||
}
|
||||
await WriteManifestAsync(manifest.RecoveryDirectory, manifest with { Phase = "completed" }, cancellationToken);
|
||||
resumed++;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unable to resume short-fragment recovery manifest {ManifestPath}", path);
|
||||
}
|
||||
}
|
||||
return resumed;
|
||||
}
|
||||
|
||||
private static List<RecordTask> ResolveMergeGroup(IReadOnlyList<RecordTask> tasks, int index)
|
||||
{
|
||||
var durations = tasks.Select(item => IsReadableTerminal(item) ? item.Result!.DurationSeconds : null).ToArray();
|
||||
var (start, end) = ResolveMergeWindow(durations, index);
|
||||
return tasks.Skip(start).Take(end - start + 1).Where(IsReadableTerminal).ToList();
|
||||
}
|
||||
|
||||
internal static (int Start, int End) ResolveMergeWindow(IReadOnlyList<double?> durations, int index)
|
||||
{
|
||||
if (durations.Count == 0 || index < 0 || index >= durations.Count) return (-1, -1);
|
||||
var start = index;
|
||||
var end = index;
|
||||
while (start > 0 && durations[start - 1] is > 0 and < MinimumStandaloneDurationSeconds) start--;
|
||||
while (end + 1 < durations.Count && durations[end + 1] is > 0 and < MinimumStandaloneDurationSeconds) end++;
|
||||
if (durations[index] is > 0 and < MinimumStandaloneDurationSeconds)
|
||||
{
|
||||
if (start > 0 && durations[start - 1] is >= MinimumStandaloneDurationSeconds) start--;
|
||||
else if (end + 1 < durations.Count && durations[end + 1] is >= MinimumStandaloneDurationSeconds) end++;
|
||||
}
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
private async Task<bool> MergeAsync(
|
||||
RecordSession session,
|
||||
RecordTask target,
|
||||
IReadOnlyList<RecordTask> group,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sources = group.Select(item => item.Result!.FilePath).Where(File.Exists).ToArray();
|
||||
if (sources.Length != group.Count || sources.Length < 2) return false;
|
||||
|
||||
var mergeId = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}";
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(target.Result!.FilePath))!;
|
||||
var extension = Path.GetExtension(target.Result.FilePath);
|
||||
var mergedPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(target.Result.FilePath)}-merged-{mergeId[..14]}{extension}");
|
||||
var stagingPath = mergedPath + ".partial.mp4";
|
||||
var recoveryDirectory = Path.Combine(directory, ".liverecorder-recovery", session.Id.ToString("N"), mergeId);
|
||||
Directory.CreateDirectory(recoveryDirectory);
|
||||
|
||||
var manifest = new MergeRecoveryManifest(
|
||||
mergeId,
|
||||
session.Id,
|
||||
target.Id,
|
||||
"staging",
|
||||
mergedPath,
|
||||
recoveryDirectory,
|
||||
group.Select(item => new MergeRecoverySource(item.Id, item.Result!.FilePath, item.Result.DanmakuFilePath, item.Result.DurationSeconds ?? 0)).ToArray(),
|
||||
DateTimeOffset.UtcNow);
|
||||
await WriteManifestAsync(recoveryDirectory, manifest, cancellationToken);
|
||||
|
||||
var settings = await _settingsService.GetAsync(cancellationToken);
|
||||
var concatFile = Path.Combine(recoveryDirectory, "concat.txt");
|
||||
await File.WriteAllLinesAsync(concatFile, sources.Select(path => $"file '{EscapeConcatPath(Path.GetFullPath(path))}'"), cancellationToken);
|
||||
|
||||
var copied = await RunFfmpegAsync(settings.FfmpegPath,
|
||||
["-hide_banner", "-loglevel", "warning", "-f", "concat", "-safe", "0", "-i", concatFile, "-map", "0", "-c", "copy", "-movflags", "+faststart", "-y", stagingPath],
|
||||
cancellationToken);
|
||||
if (!copied)
|
||||
{
|
||||
copied = await RunFfmpegAsync(settings.FfmpegPath,
|
||||
["-hide_banner", "-loglevel", "warning", "-f", "concat", "-safe", "0", "-i", concatFile, "-map", "0:v:0", "-map", "0:a?", "-c:v", "libx264", "-preset", "fast", "-crf", "20", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", "-y", stagingPath],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var metadata = copied ? await _metadataService.ExtractMetadataAsync(stagingPath, cancellationToken) : null;
|
||||
if (metadata?.DurationSeconds is null or <= 0 || string.IsNullOrWhiteSpace(metadata.VideoCodec))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
File.Move(stagingPath, mergedPath, overwrite: false);
|
||||
var mergedDanmakuPath = await MergeDanmakuAsync(group, target, mergedPath, cancellationToken);
|
||||
|
||||
foreach (var source in group)
|
||||
{
|
||||
CopyToRecovery(source.Result!.FilePath, recoveryDirectory);
|
||||
CopyToRecovery(source.Result.DanmakuFilePath, recoveryDirectory);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
target.Result.Update(
|
||||
mergedPath,
|
||||
new FileInfo(mergedPath).Length,
|
||||
metadata.DurationSeconds,
|
||||
mergedDanmakuPath,
|
||||
group.Sum(item => item.Result!.DanmakuMessageCount),
|
||||
RecordTaskStatus.Completed,
|
||||
null);
|
||||
target.MarkCompleted(now, metadata.DurationSeconds);
|
||||
|
||||
foreach (var source in group.Where(item => item.Id != target.Id))
|
||||
{
|
||||
source.MarkMergedSource(target.Id, now);
|
||||
var dispatch = await _dbContext.RecordCompletionDispatches.FirstOrDefaultAsync(item => item.RecordTaskId == source.Id, cancellationToken);
|
||||
dispatch?.MarkSkipped($"Merged into {target.Id}", now);
|
||||
}
|
||||
|
||||
if (!await _dbContext.RecordCompletionDispatches.AnyAsync(item => item.RecordTaskId == target.Id, cancellationToken))
|
||||
{
|
||||
await _dbContext.RecordCompletionDispatches.AddAsync(new RecordCompletionDispatch(target.Id, now), cancellationToken);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (var source in manifest.Sources)
|
||||
{
|
||||
DeleteOriginal(source.VideoPath, mergedPath);
|
||||
DeleteOriginal(source.DanmakuPath, mergedDanmakuPath);
|
||||
}
|
||||
|
||||
await WriteManifestAsync(recoveryDirectory, manifest with { Phase = "completed" }, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task MoveRecoveryOnlyAsync(IReadOnlyList<RecordTask> group, CancellationToken cancellationToken)
|
||||
{
|
||||
if (group.Count == 0) return;
|
||||
var sessionId = group[0].RecordSessionId;
|
||||
var mergeId = $"recovery-only-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}";
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(group[0].Result!.FilePath))!;
|
||||
var recoveryDirectory = Path.Combine(directory, ".liverecorder-recovery", sessionId.ToString("N"), mergeId);
|
||||
Directory.CreateDirectory(recoveryDirectory);
|
||||
var manifest = new MergeRecoveryManifest(
|
||||
mergeId,
|
||||
sessionId,
|
||||
group[0].Id,
|
||||
"staging",
|
||||
string.Empty,
|
||||
recoveryDirectory,
|
||||
group.Select(item => new MergeRecoverySource(item.Id, item.Result!.FilePath, item.Result.DanmakuFilePath, item.Result.DurationSeconds ?? 0)).ToArray(),
|
||||
DateTimeOffset.UtcNow);
|
||||
await WriteManifestAsync(recoveryDirectory, manifest, cancellationToken);
|
||||
foreach (var item in group)
|
||||
{
|
||||
CopyToRecovery(item.Result!.FilePath, recoveryDirectory);
|
||||
CopyToRecovery(item.Result.DanmakuFilePath, recoveryDirectory);
|
||||
item.MarkRecoveryOnly(DateTimeOffset.UtcNow);
|
||||
var dispatch = await _dbContext.RecordCompletionDispatches.FirstOrDefaultAsync(d => d.RecordTaskId == item.Id, cancellationToken);
|
||||
dispatch?.MarkSkipped("Recovery-only short fragment", DateTimeOffset.UtcNow);
|
||||
}
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var item in group)
|
||||
{
|
||||
DeleteOriginal(item.Result!.FilePath, null);
|
||||
DeleteOriginal(item.Result.DanmakuFilePath, null);
|
||||
}
|
||||
await WriteManifestAsync(recoveryDirectory, manifest with { Phase = "completed" }, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<string?> MergeDanmakuAsync(
|
||||
IReadOnlyList<RecordTask> group,
|
||||
RecordTask target,
|
||||
string mergedVideoPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var available = group.Where(item => !string.IsNullOrWhiteSpace(item.Result?.DanmakuFilePath) && File.Exists(item.Result.DanmakuFilePath)).ToList();
|
||||
if (available.Count == 0) return null;
|
||||
|
||||
var root = new XElement("i",
|
||||
new XAttribute("recordSessionId", target.RecordSessionId),
|
||||
new XAttribute("recordTaskId", target.Id),
|
||||
new XAttribute("merged", true));
|
||||
var cumulative = 0d;
|
||||
foreach (var item in group)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.Result?.DanmakuFilePath) && File.Exists(item.Result.DanmakuFilePath))
|
||||
{
|
||||
var source = await XDocument.LoadAsync(File.OpenRead(item.Result.DanmakuFilePath), LoadOptions.None, cancellationToken);
|
||||
foreach (var element in source.Root?.Elements() ?? [])
|
||||
{
|
||||
root.Add(AdjustDanmakuElementOffsets(element, cumulative));
|
||||
}
|
||||
}
|
||||
cumulative += item.Result?.DurationSeconds ?? 0;
|
||||
}
|
||||
|
||||
var output = Path.ChangeExtension(mergedVideoPath, ".xml");
|
||||
await using var stream = File.Create(output);
|
||||
await new XDocument(new XDeclaration("1.0", "UTF-8", null), root).SaveAsync(stream, SaveOptions.None, cancellationToken);
|
||||
return output;
|
||||
}
|
||||
|
||||
internal static XElement AdjustDanmakuElementOffsets(XElement element, double cumulativeSeconds)
|
||||
{
|
||||
var clone = new XElement(element);
|
||||
if (clone.Name.LocalName == "d" && clone.Attribute("p") is { } p)
|
||||
{
|
||||
var parts = p.Value.Split(',');
|
||||
if (parts.Length > 0 && double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var offset))
|
||||
{
|
||||
parts[0] = (offset + cumulativeSeconds).ToString("F1", CultureInfo.InvariantCulture);
|
||||
p.Value = string.Join(',', parts);
|
||||
}
|
||||
}
|
||||
else if (clone.Attribute("offset") is { } offsetAttribute &&
|
||||
double.TryParse(offsetAttribute.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var offset))
|
||||
{
|
||||
offsetAttribute.Value = (offset + cumulativeSeconds).ToString("F1", CultureInfo.InvariantCulture);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
private async Task<bool> RunFfmpegAsync(string path, IReadOnlyList<string> arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
using var process = new Process { StartInfo = new ProcessStartInfo(path) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true, CreateNoWindow = true } };
|
||||
foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument);
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(ProcessTimeout);
|
||||
var stderr = process.StandardError.ReadToEndAsync(timeout.Token);
|
||||
var stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
await Task.WhenAll(stderr, stdout);
|
||||
if (process.ExitCode == 0) return true;
|
||||
_logger.LogWarning("Short fragment merge ffmpeg failed: {Error}", await stderr);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Short fragment merge process failed");
|
||||
try { if (!process.HasExited) process.Kill(true); } catch { }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsShort(RecordTask task) =>
|
||||
IsReadableTerminal(task) && task.Result!.DurationSeconds!.Value < MinimumStandaloneDurationSeconds;
|
||||
private static bool IsReadableTerminal(RecordTask task) =>
|
||||
task.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped &&
|
||||
task.Result?.DurationSeconds is > 0 && File.Exists(task.Result.FilePath);
|
||||
private static bool IsActive(RecordTaskStatus status) => status is RecordTaskStatus.Pending or RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping or RecordTaskStatus.Processing;
|
||||
private static bool IsActive(RecordSessionStatus status) => status is RecordSessionStatus.Pending or RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||
private static string EscapeConcatPath(string path) => path.Replace("'", "'\\''", StringComparison.Ordinal);
|
||||
private static void CopyToRecovery(string? source, string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source) || !File.Exists(source)) return;
|
||||
var destination = Path.Combine(directory, Path.GetFileName(source));
|
||||
if (!File.Exists(destination)) File.Copy(source, destination);
|
||||
}
|
||||
private static void DeleteOriginal(string? source, string? preserved)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source) || string.Equals(Path.GetFullPath(source), preserved is null ? null : Path.GetFullPath(preserved), StringComparison.OrdinalIgnoreCase)) return;
|
||||
if (File.Exists(source)) File.Delete(source);
|
||||
}
|
||||
private static async Task WriteManifestAsync(string directory, MergeRecoveryManifest manifest, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(directory, "manifest.json");
|
||||
var temp = path + ".tmp";
|
||||
await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
|
||||
File.Move(temp, path, true);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record FragmentDispatchDecision(bool CanDispatch, bool SkipDispatch, string? Reason, TimeSpan RetryDelay, Guid? MergedIntoRecordTaskId)
|
||||
{
|
||||
public static readonly FragmentDispatchDecision Ready = new(true, false, null, TimeSpan.Zero, null);
|
||||
public static FragmentDispatchDecision Wait(string reason, TimeSpan delay) => new(false, false, reason, delay, null);
|
||||
public static FragmentDispatchDecision Skip(string reason, Guid? target) => new(false, true, reason, TimeSpan.Zero, target);
|
||||
}
|
||||
|
||||
internal sealed record MergeRecoveryManifest(string MergeId, Guid RecordSessionId, Guid TargetRecordTaskId, string Phase, string MergedPath, string RecoveryDirectory, IReadOnlyList<MergeRecoverySource> Sources, DateTimeOffset CreatedAt);
|
||||
internal sealed record MergeRecoverySource(Guid RecordTaskId, string VideoPath, string? DanmakuPath, double DurationSeconds);
|
||||
@@ -19,6 +19,7 @@ public sealed class RecordTasksController : ControllerBase
|
||||
private readonly IRecordMediaService _recordMediaService;
|
||||
private readonly IDanmakuService _danmakuService;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
private readonly OpenListUploadQueueService _openListUploadQueue;
|
||||
|
||||
public RecordTasksController(
|
||||
RecordService recordService,
|
||||
@@ -26,7 +27,8 @@ public sealed class RecordTasksController : ControllerBase
|
||||
IRecordResultRepository recordResultRepository,
|
||||
IRecordMediaService recordMediaService,
|
||||
IDanmakuService danmakuService,
|
||||
LinkGenerator linkGenerator)
|
||||
LinkGenerator linkGenerator,
|
||||
OpenListUploadQueueService openListUploadQueue)
|
||||
{
|
||||
_recordService = recordService;
|
||||
_recordUploadService = recordUploadService;
|
||||
@@ -34,6 +36,7 @@ public sealed class RecordTasksController : ControllerBase
|
||||
_recordMediaService = recordMediaService;
|
||||
_danmakuService = danmakuService;
|
||||
_linkGenerator = linkGenerator;
|
||||
_openListUploadQueue = openListUploadQueue;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -43,6 +46,7 @@ public sealed class RecordTasksController : ControllerBase
|
||||
[HttpGet("upload-status")]
|
||||
public async Task<ActionResult<UploadTaskListResponse>> ListUploadStatus(
|
||||
[FromQuery] int? uploadStatus,
|
||||
[FromQuery] string? query,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -51,15 +55,27 @@ public sealed class RecordTasksController : ControllerBase
|
||||
? (RecordArtifactUploadStatus)uploadStatus.Value
|
||||
: (RecordArtifactUploadStatus?)null;
|
||||
|
||||
var items = await _recordResultRepository.ListUploadStatusAsync(filter, skip, take, cancellationToken);
|
||||
var totalCount = await _recordResultRepository.CountUploadStatusAsync(filter, cancellationToken);
|
||||
var items = await _recordResultRepository.ListUploadStatusAsync(filter, query, skip, take, cancellationToken);
|
||||
var totalCount = await _recordResultRepository.CountUploadStatusAsync(filter, query, cancellationToken);
|
||||
var notUploadedCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken);
|
||||
var failedArtifactCount = await _recordResultRepository.CountFailedArtifactAsync(cancellationToken);
|
||||
var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, 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);
|
||||
var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, null, cancellationToken);
|
||||
var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, null, cancellationToken);
|
||||
var queuedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Queued, null, cancellationToken);
|
||||
var uploadingCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Uploading, null, cancellationToken);
|
||||
var waitingRetryCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.WaitingRetry, null, cancellationToken);
|
||||
var hasQuery = !string.IsNullOrWhiteSpace(query);
|
||||
var matchingFailedCount = filter is null or RecordArtifactUploadStatus.Failed
|
||||
? hasQuery
|
||||
? await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, query, cancellationToken)
|
||||
: failedCount
|
||||
: 0;
|
||||
var matchingWaitingRetryCount = filter is null or RecordArtifactUploadStatus.WaitingRetry
|
||||
? hasQuery
|
||||
? await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.WaitingRetry, query, cancellationToken)
|
||||
: waitingRetryCount
|
||||
: 0;
|
||||
var health = _openListUploadQueue.GetHealthSnapshot();
|
||||
|
||||
return Ok(new UploadTaskListResponse
|
||||
{
|
||||
@@ -71,7 +87,16 @@ public sealed class RecordTasksController : ControllerBase
|
||||
FailedCount = failedCount,
|
||||
QueuedCount = queuedCount,
|
||||
UploadingCount = uploadingCount,
|
||||
WaitingRetryCount = waitingRetryCount
|
||||
WaitingRetryCount = waitingRetryCount,
|
||||
MatchingRetryableCount = matchingFailedCount + matchingWaitingRetryCount,
|
||||
QueueHealth = new UploadQueueHealthDto
|
||||
{
|
||||
State = health.Status.ToString(),
|
||||
IsPaused = health.IsPaused,
|
||||
Reason = health.Reason,
|
||||
RetryAt = health.RetryAt,
|
||||
LastErrorAt = health.LastErrorAt
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,6 +205,22 @@ public sealed class RecordTasksController : ControllerBase
|
||||
: Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/upload/retry")]
|
||||
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> RetryUpload(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _recordUploadService.RetryTaskAsync(id, cancellationToken);
|
||||
return result.Success ? Accepted(result) : BadRequest(result);
|
||||
}
|
||||
|
||||
[HttpPost("upload/retry")]
|
||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> RetryUploads(
|
||||
[FromBody] RetryRecordArtifactUploadsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _recordUploadService.RetryMatchingAsync(request, cancellationToken);
|
||||
return result.FailedCount > 0 && result.SuccessCount == 0 ? BadRequest(result) : Accepted(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/danmaku")]
|
||||
public async Task<ActionResult<DanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -34,4 +34,25 @@ public sealed class RecoveryController : ControllerBase
|
||||
[HttpPost("finalizations/resume-all")]
|
||||
public async Task<ActionResult<RecoveryActionResultDto>> ResumeAllFinalizations(CancellationToken cancellationToken) =>
|
||||
Ok(await _recoveryService.ResumeAllFinalizationsAsync(cancellationToken));
|
||||
|
||||
[HttpGet("recording-failures")]
|
||||
public async Task<ActionResult<RecordingFailureListResponse>> ListRecordingFailures(
|
||||
[FromQuery] string? failureKind,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 20,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Ok(await _recoveryService.ListRecordingFailuresAsync(failureKind, skip, take, cancellationToken));
|
||||
|
||||
[HttpPost("recording-failures/{taskId:guid}/accept")]
|
||||
public async Task<ActionResult<RecoveryActionResultDto>> AcceptRecordingFailure(
|
||||
Guid taskId,
|
||||
[FromBody] AcceptRecordingArtifactRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recoveryService.AcceptRecordingArtifactAsync(taskId, request.ConfirmShortArtifact, cancellationToken));
|
||||
|
||||
[HttpPost("recording-failures/{taskId:guid}/repair")]
|
||||
public async Task<ActionResult<RecoveryActionResultDto>> RepairRecordingFailure(
|
||||
Guid taskId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _recoveryService.RepairRecordingArtifactAsync(taskId, cancellationToken));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LiveRecorder.WebApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/upload-queue")]
|
||||
public sealed class UploadQueueController : ControllerBase
|
||||
{
|
||||
private readonly OpenListUploadQueueService _queue;
|
||||
|
||||
public UploadQueueController(OpenListUploadQueueService queue)
|
||||
{
|
||||
_queue = queue;
|
||||
}
|
||||
|
||||
[HttpPost("openlist/resume")]
|
||||
public async Task<ActionResult<UploadQueueHealthDto>> ResumeOpenList(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var health = await _queue.ResumeProviderAsync(cancellationToken);
|
||||
return Ok(Map(health));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
message = ex.Message,
|
||||
queueHealth = Map(_queue.GetHealthSnapshot())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static UploadQueueHealthDto Map(OpenListQueueHealthSnapshot health) => new()
|
||||
{
|
||||
State = health.Status.ToString(),
|
||||
IsPaused = health.IsPaused,
|
||||
Reason = health.Reason,
|
||||
RetryAt = health.RetryAt,
|
||||
LastErrorAt = health.LastErrorAt
|
||||
};
|
||||
}
|
||||
@@ -211,7 +211,9 @@ builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||
builder.Services.AddScoped<PlatformHttpRequestService>();
|
||||
builder.Services.AddScoped<RecordUploadService>();
|
||||
builder.Services.AddScoped<CompletionDispatchService>();
|
||||
builder.Services.AddScoped<ShortFragmentConsolidationService>();
|
||||
builder.Services.AddScoped<OpenListUploadQueueService>();
|
||||
builder.Services.AddSingleton<OpenListUploadHealthState>();
|
||||
builder.Services.AddSingleton<IOpenListClient, OpenListClient>();
|
||||
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
|
||||
builder.Services.AddScoped<IVideoMetadataService, FfmpegVideoMetadataService>();
|
||||
|
||||
@@ -103,6 +103,16 @@ public sealed class FfmpegFailureClassificationTests
|
||||
FfmpegService.IsMeaningfulUnexpectedExitArtifact(durationSeconds, fileSizeBytes));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(59, false)]
|
||||
[InlineData(60, true)]
|
||||
[InlineData(3600, true)]
|
||||
public void StableRuntime_ResetsInSessionRecoveryBudget(int seconds, bool expected)
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-08-04T20:00:00+08:00");
|
||||
Assert.Equal(expected, FfmpegService.CanResetInSessionRetryBudget(now.AddSeconds(-seconds), now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://example.test/live.flv", "flv", true)]
|
||||
[InlineData("https://example.test/live.m3u8", "hls", false)]
|
||||
|
||||
@@ -299,6 +299,105 @@ public sealed class OpenListUploadTests
|
||||
Assert.Equal(now.AddSeconds(3), job.VerificationStartedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UploadJob_ImmediateRetryResetsBudgetButPreservesExternalTask()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-08-04T10:00:00+08:00");
|
||||
var job = new RecordUploadJob(
|
||||
Guid.NewGuid(),
|
||||
"https://openlist.example.com",
|
||||
"/source/video.mp4",
|
||||
"/archive/video.mp4",
|
||||
100,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
now);
|
||||
job.BeginAttempt(now.AddSeconds(1));
|
||||
job.TrackExternalTask("copy-1", "copy", 30, now.AddSeconds(2));
|
||||
job.ScheduleRetry("temporary", now.AddMinutes(5), now.AddSeconds(3), clearExternalTask: false);
|
||||
|
||||
job.RequestImmediateRetry(now.AddSeconds(4));
|
||||
|
||||
Assert.Equal(RecordArtifactUploadStatus.Queued, job.Status);
|
||||
Assert.Equal(0, job.AttemptCount);
|
||||
Assert.Null(job.NextAttemptAt);
|
||||
Assert.Equal("copy-1", job.ExternalTaskId);
|
||||
Assert.Equal(30, job.ProgressPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginRateLimit_OpensProviderCircuit()
|
||||
{
|
||||
var state = new OpenListUploadHealthState();
|
||||
var loginCount = 0;
|
||||
var handler = new StubHttpMessageHandler(_ =>
|
||||
{
|
||||
loginCount++;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"{\"code\":429,\"message\":\"错误账号密码尝试过多\",\"data\":null}",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
});
|
||||
});
|
||||
var client = new OpenListClient(new StubHttpClientFactory(handler), state);
|
||||
|
||||
var error = await Assert.ThrowsAsync<OpenListApiException>(() =>
|
||||
client.EnsureAuthenticatedAsync(Connection()));
|
||||
|
||||
Assert.True(error.IsRateLimited);
|
||||
Assert.Equal(1, loginCount);
|
||||
var snapshot = state.GetSnapshot();
|
||||
Assert.Equal(OpenListQueueHealthStatus.RateLimited, snapshot.Status);
|
||||
Assert.True(snapshot.RetryAt > DateTimeOffset.UtcNow);
|
||||
Assert.False(state.CanProcess(DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonJsonRateLimit_StillOpensProviderCircuit()
|
||||
{
|
||||
var state = new OpenListUploadHealthState();
|
||||
var handler = new StubHttpMessageHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
Content = new StringContent("rate limited by reverse proxy", Encoding.UTF8, "text/plain")
|
||||
}));
|
||||
var client = new OpenListClient(new StubHttpClientFactory(handler), state);
|
||||
|
||||
var error = await Assert.ThrowsAsync<OpenListApiException>(() =>
|
||||
client.EnsureAuthenticatedAsync(Connection()));
|
||||
|
||||
Assert.True(error.IsRateLimited);
|
||||
Assert.Equal(OpenListQueueHealthStatus.RateLimited, state.GetSnapshot().Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UploadJob_ProviderPauseRollsBackOnlyTheCurrentAttempt()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-08-04T10:00:00+08:00");
|
||||
var job = new RecordUploadJob(
|
||||
Guid.NewGuid(),
|
||||
"https://openlist.example.com",
|
||||
"/source/video.mp4",
|
||||
"/archive/video.mp4",
|
||||
100,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
now);
|
||||
job.BeginAttempt(now.AddSeconds(1));
|
||||
|
||||
job.SuspendForProviderFailure("rate limited", now.AddSeconds(2), rollbackAttempt: true);
|
||||
|
||||
Assert.Equal(RecordArtifactUploadStatus.WaitingRetry, job.Status);
|
||||
Assert.Equal(0, job.AttemptCount);
|
||||
Assert.Null(job.NextAttemptAt);
|
||||
Assert.Equal("rate limited", job.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queue_MapsOutputRelativePathAndTreatsMatchingTargetAsIdempotentSuccess()
|
||||
{
|
||||
@@ -648,7 +747,13 @@ public sealed class OpenListUploadTests
|
||||
}
|
||||
|
||||
private OpenListUploadQueueService CreateQueue(LiveRecorderDbContext context) =>
|
||||
new(context, _settingsService, OpenList, new NullSystemLogService(), _videoMetadataService);
|
||||
new(
|
||||
context,
|
||||
_settingsService,
|
||||
OpenList,
|
||||
new OpenListUploadHealthState(),
|
||||
new NullSystemLogService(),
|
||||
_videoMetadataService);
|
||||
}
|
||||
|
||||
private sealed class FixedVideoMetadataService : IVideoMetadataService
|
||||
@@ -723,6 +828,11 @@ public sealed class OpenListUploadTests
|
||||
|
||||
public OpenListCopyResult NextCopyResult { get; set; } = new([]);
|
||||
|
||||
public Task EnsureAuthenticatedAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
bool forceRefresh = false,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task<OpenListConnectionTestDto> TestConnectionAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
@@ -141,6 +141,7 @@ public sealed class RecordSessionRepositoryTests
|
||||
Assert.Equal(30, firstPage.TotalCount);
|
||||
Assert.Equal(10, firstPage.Items.Count);
|
||||
Assert.Equal(oldActive.Id, firstPage.Items[0].Id);
|
||||
Assert.Equal("分页主播", firstPage.Items[0].LiveRoom?.AnchorName);
|
||||
Assert.Equal(29, completedPage.TotalCount);
|
||||
Assert.DoesNotContain(completedPage.Items, item => item.Id == oldActive.Id);
|
||||
Assert.Single(searchPage.Items);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class RecordingContinuityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(true, false, true)]
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(false, false, false)]
|
||||
public void SessionTransition_IsTreatedAsActive(bool process, bool transition, bool expected) =>
|
||||
Assert.Equal(expected, FfmpegService.IsSessionRuntimeActive(process, transition));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 5)]
|
||||
[InlineData(2, 15)]
|
||||
[InlineData(3, 30)]
|
||||
[InlineData(4, 60)]
|
||||
[InlineData(5, 120)]
|
||||
[InlineData(50, 120)]
|
||||
public void RuntimeRecoveryBackoff_IsBoundedAtTwoMinutes(int attempt, int seconds) =>
|
||||
Assert.Equal(TimeSpan.FromSeconds(seconds), FfmpegService.GetRuntimeRecoveryDelay(attempt));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false, false)]
|
||||
[InlineData(0.0, false, false)]
|
||||
[InlineData(0.1, true, false)]
|
||||
[InlineData(4.99, true, false)]
|
||||
[InlineData(5.0, true, true)]
|
||||
public void MediaDuration_SeparatesReadableFromStandalone(double? duration, bool readable, bool standalone)
|
||||
{
|
||||
Assert.Equal(readable, FfmpegService.IsReadableMediaDuration(duration));
|
||||
Assert.Equal(standalone, FfmpegService.IsStandaloneMediaDuration(duration));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OfflineSample_RequiresConfirmation_ButLiveDoesNot()
|
||||
{
|
||||
Assert.True(LiveRoomPollingBackgroundService.RequiresOfflineConfirmation(false));
|
||||
Assert.False(LiveRoomPollingBackgroundService.RequiresOfflineConfirmation(true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, RecordTaskStatus.Starting, true)]
|
||||
[InlineData(false, RecordTaskStatus.Running, true)]
|
||||
[InlineData(true, RecordTaskStatus.Running, false)]
|
||||
[InlineData(false, RecordTaskStatus.Completed, false)]
|
||||
public void ArtifactlessRecovery_ReusesOnlyActiveTask(bool hasMedia, RecordTaskStatus status, bool expected) =>
|
||||
Assert.Equal(expected, FfmpegService.ShouldReuseRecoveryTask(hasMedia, status));
|
||||
|
||||
[Fact]
|
||||
public void SessionRecoveryMarker_KeepsSessionActive()
|
||||
{
|
||||
var session = new RecordSession(Guid.NewGuid(), "origin", RecordOutputFormat.Mp4, RecordSaveMode.Segmented, DateTimeOffset.UtcNow);
|
||||
session.MarkRecovering("[runtime-recovery] attempt=4; curl EOF", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(RecordSessionStatus.Starting, session.Status);
|
||||
Assert.StartsWith("[runtime-recovery]", session.ErrorMessage);
|
||||
Assert.Null(session.EndedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergedSource_PointsAtTargetAndBecomesHidden()
|
||||
{
|
||||
var source = new RecordTask(Guid.NewGuid(), Guid.NewGuid(), 2, "origin", RecordOutputFormat.Mp4, DateTimeOffset.UtcNow);
|
||||
var targetId = Guid.NewGuid();
|
||||
source.MarkMergedSource(targetId, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.True(source.IsHiddenArtifactSource);
|
||||
Assert.Equal(targetId, source.MergedIntoRecordTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeWindow_FirstShortFragment_MergesForward()
|
||||
{
|
||||
var window = ShortFragmentConsolidationService.ResolveMergeWindow([1.2, 60], 0);
|
||||
Assert.Equal((0, 1), window);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeWindow_TrailingShortFragment_MergesBackward()
|
||||
{
|
||||
var window = ShortFragmentConsolidationService.ResolveMergeWindow([60, 1.2], 0);
|
||||
Assert.Equal((0, 1), window);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeWindow_ConsecutiveShortFragments_AreAccumulated()
|
||||
{
|
||||
var window = ShortFragmentConsolidationService.ResolveMergeWindow([1.2, 2.4, 60], 1);
|
||||
Assert.Equal((0, 2), window);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DanmakuMerge_ShiftsRelativeOffset_ButPreservesAbsoluteTimestamp()
|
||||
{
|
||||
var chat = XElement.Parse("<d p=\"1.5,1,25,16777215,1720000000000,0,1,0\">hi</d>");
|
||||
var shiftedChat = ShortFragmentConsolidationService.AdjustDanmakuElementOffsets(chat, 4.25);
|
||||
Assert.StartsWith("5.8,", shiftedChat.Attribute("p")!.Value);
|
||||
Assert.Contains("1720000000000", shiftedChat.Attribute("p")!.Value);
|
||||
|
||||
var eventElement = XElement.Parse("<event ts=\"1720000000123\" offset=\"2.0\" />");
|
||||
var shiftedEvent = ShortFragmentConsolidationService.AdjustDanmakuElementOffsets(eventElement, 4.25);
|
||||
Assert.Equal("6.2", shiftedEvent.Attribute("offset")!.Value);
|
||||
Assert.Equal("1720000000123", shiftedEvent.Attribute("ts")!.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Models.Logs;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class RecoveryServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _temporaryDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"liverecorder-artifact-recovery-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptShortArtifact_RequiresConfirmationThenLeavesItForManualUpload()
|
||||
{
|
||||
Directory.CreateDirectory(_temporaryDirectory);
|
||||
var mediaPath = Path.Combine(_temporaryDirectory, "short-fragment.mp4");
|
||||
await File.WriteAllBytesAsync(mediaPath, [1, 2, 3, 4]);
|
||||
await using var context = CreateContext();
|
||||
var taskId = await SeedFailedTaskAsync(context, mediaPath, durationSeconds: 3);
|
||||
var service = CreateService(context, new VideoMetadata(3, 1920, 1080, "h264", "aac", 30, 2_000_000));
|
||||
|
||||
var rejected = await service.AcceptRecordingArtifactAsync(taskId, confirmShortArtifact: false);
|
||||
|
||||
Assert.Equal(0, rejected.SuccessCount);
|
||||
Assert.Contains("需要确认短分片", rejected.Messages.Single());
|
||||
Assert.Equal(RecordTaskStatus.Failed, (await context.RecordTasks.FindAsync(taskId))!.Status);
|
||||
|
||||
var accepted = await service.AcceptRecordingArtifactAsync(taskId, confirmShortArtifact: true);
|
||||
|
||||
Assert.Equal(1, accepted.SuccessCount);
|
||||
var task = await context.RecordTasks.Include(item => item.Result).SingleAsync(item => item.Id == taskId);
|
||||
Assert.Equal(RecordTaskStatus.Completed, task.Status);
|
||||
Assert.Equal(RecordArtifactUploadStatus.NotUploaded, task.Result!.UploadStatus);
|
||||
Assert.Null(task.Result.LastUploadProvider);
|
||||
Assert.Null(task.Result.LastUploadedAt);
|
||||
Assert.Null(task.Result.UploadErrorMessage);
|
||||
Assert.True(File.Exists(mediaPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFailures_IdentifiesMissingMediaAsUnrecoverable()
|
||||
{
|
||||
Directory.CreateDirectory(_temporaryDirectory);
|
||||
var missingPath = Path.Combine(_temporaryDirectory, "missing.mp4");
|
||||
await using var context = CreateContext();
|
||||
await SeedFailedTaskAsync(context, missingPath, durationSeconds: 120);
|
||||
var service = CreateService(context, metadata: null);
|
||||
|
||||
var response = await service.ListRecordingFailuresAsync("MissingMedia", 0, 20);
|
||||
|
||||
var item = Assert.Single(response.Items);
|
||||
Assert.Equal("MissingMedia", item.FailureKind);
|
||||
Assert.False(item.FileExists);
|
||||
Assert.False(item.CanAccept);
|
||||
Assert.False(item.CanRepair);
|
||||
}
|
||||
|
||||
private static LiveRecorderDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
|
||||
.UseInMemoryDatabase($"artifact-recovery-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
return new LiveRecorderDbContext(options);
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedFailedTaskAsync(
|
||||
LiveRecorderDbContext context,
|
||||
string mediaPath,
|
||||
double durationSeconds)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var room = new LiveRoom(
|
||||
LivePlatformType.Douyin,
|
||||
"https://live.example/recovery",
|
||||
"recovery-room",
|
||||
"https://live.example/recovery",
|
||||
now.AddHours(-1));
|
||||
room.UpdateMetadata("恢复测试直播间", "恢复主播", null, null, null, now.AddHours(-1));
|
||||
var session = new RecordSession(
|
||||
room.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
now.AddMinutes(-10));
|
||||
session.MarkFailed("ffmpeg exited unexpectedly", now);
|
||||
var task = new RecordTask(
|
||||
room.Id,
|
||||
session.Id,
|
||||
1,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
now.AddMinutes(-10));
|
||||
task.MarkStarting("https://stream.example/recovery", mediaPath, now.AddMinutes(-10));
|
||||
task.MarkFailed("ffmpeg exited unexpectedly", now, durationSeconds);
|
||||
var result = new RecordResult(
|
||||
task.Id,
|
||||
mediaPath,
|
||||
File.Exists(mediaPath) ? new FileInfo(mediaPath).Length : null,
|
||||
durationSeconds,
|
||||
null,
|
||||
0,
|
||||
RecordTaskStatus.Failed,
|
||||
task.ErrorMessage,
|
||||
now);
|
||||
result.MarkUploadFailed("openlist", "previous upload error", now);
|
||||
|
||||
context.AddRange(room, session, task, result);
|
||||
await context.SaveChangesAsync();
|
||||
return task.Id;
|
||||
}
|
||||
|
||||
private static RecoveryService CreateService(LiveRecorderDbContext context, VideoMetadata? metadata) =>
|
||||
new(
|
||||
context,
|
||||
systemSettingsService: null!,
|
||||
storageGuardService: null!,
|
||||
ffmpegService: null!,
|
||||
recordService: null!,
|
||||
videoMetadataService: new FixedVideoMetadataService(metadata),
|
||||
systemLogService: new NullSystemLogService());
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_temporaryDirectory))
|
||||
{
|
||||
Directory.Delete(_temporaryDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedVideoMetadataService(VideoMetadata? metadata) : IVideoMetadataService
|
||||
{
|
||||
public Task<VideoMetadata?> ExtractMetadataAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(metadata);
|
||||
|
||||
public Task<string?> GenerateThumbnailAsync(
|
||||
string filePath,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
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>>([]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user