feat: improve recording recovery and upload workflow

This commit is contained in:
2026-08-10 11:14:34 +08:00
parent e0d3969e46
commit 19d358b312
58 changed files with 5979 additions and 586 deletions
+29
View File
@@ -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"
}
]
}
]
}
}
+1 -1
View File
@@ -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
```
+1 -1
View File
@@ -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
View File
@@ -1,5 +1,5 @@
appname=liverecorder
version=1.2.13
version=1.2.19
display_name=Live Recorder
desc=原生直播录制系统,使用独立 PostgreSQL 共享服务,支持分片录制、弹幕采集与 OpenList 自动上传
platform=x86
+173 -117
View File
@@ -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 -1
View File
@@ -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>
+46 -43
View File
@@ -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>
+3 -1
View File
@@ -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
View File
@@ -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; }
+56
View File
@@ -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;
+8 -3
View File
@@ -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 }">
+218 -98
View File
@@ -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>
+24 -4
View File
@@ -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%;
}
+98 -101
View File
@@ -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>
+130 -3
View File
@@ -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;
+75 -4
View File
@@ -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;
+361 -8
View File
@@ -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>
+124 -46
View File
@@ -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));
+304 -10
View File
@@ -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
+1 -1
View File
@@ -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}"
+49
View File
@@ -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;
@@ -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");
}
}
@@ -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
};
}
+2
View File
@@ -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)]
+111 -1
View File
@@ -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>>([]);
}
}