diff --git a/README.md b/README.md
index 735a660..90d1f92 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@
* 开箱即用,多种安装方式,提供可视化 WebUi 界面
* 多主播录制/上传,24X7 无人值守运行,高自定义元信息
* 录制同时抓取弹幕,输出 XML 弹幕文件
+* 正在录制的直播间可在 WebUI 内直接预览:复用正在写盘的那一路流(不另外拉流),带封面、实时弹幕与写盘码率曲线,支持多路同屏监视
* 作为自动化流程中的命令行工具封装使用
* 边录边传 0 落盘
diff --git a/app/(app)/page.module.scss b/app/(app)/page.module.scss
index 642aead..d48d1e4 100644
--- a/app/(app)/page.module.scss
+++ b/app/(app)/page.module.scss
@@ -136,6 +136,12 @@
margin-left: 6px;
color: var(--semi-color-text-1);
}
+/* 区块头右侧:码率图开关 + 「直播管理 →」 */
+.secTools {
+ display: inline-flex;
+ align-items: center;
+ gap: 14px;
+}
.secLink {
font-size: 13px;
font-weight: 600;
diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx
index f06d367..eab0cdf 100644
--- a/app/(app)/page.tsx
+++ b/app/(app)/page.tsx
@@ -8,6 +8,7 @@ import {
} from '@/app/lib/use-dashboard'
import { formatVersion } from '@/app/lib/status'
import StreamerCard from '../ui/StreamerCard'
+import { CardRateSwitch } from '../ui/LiveRateChart'
import EventTimeline from '../ui/EventTimeline'
import BackgroundSetter from './components/BackgroundSetter'
import styles from './page.module.scss'
@@ -123,9 +124,12 @@ export default function Home() {
进行中{live.length}
-
- 直播管理 →
-
+
+ {live.length > 0 ? : null}
+
+ 直播管理 →
+
+
{d.streamersFailed ? (
主播列表加载失败,无法显示卡片。
diff --git a/app/(app)/streamers/page.module.scss b/app/(app)/streamers/page.module.scss
index c8386be..58e2b96 100644
--- a/app/(app)/streamers/page.module.scss
+++ b/app/(app)/streamers/page.module.scss
@@ -68,6 +68,11 @@
color: var(--semi-color-primary);
font-weight: 600;
}
+/* 工具栏「码率图」开关(网格视图) */
+.rateSwitch {
+ margin-right: 4px;
+}
+
.toolbarSpacer {
flex: 1;
}
diff --git a/app/(app)/streamers/page.tsx b/app/(app)/streamers/page.tsx
index 69be4df..1fe63e1 100644
--- a/app/(app)/streamers/page.tsx
+++ b/app/(app)/streamers/page.tsx
@@ -41,6 +41,7 @@ import {
SLOW_REFRESH_MS,
} from '@/app/lib/use-dashboard'
import StreamerCard, { LiveAvatar } from '@/app/ui/StreamerCard'
+import { CardRateSwitch } from '@/app/ui/LiveRateChart'
import PageHeader from '../components/PageHeader'
import styles from './page.module.scss'
@@ -309,6 +310,7 @@ export default function StreamersPage() {
))}
+ {layout === 'grid' ? : null}
)}
+ {rateChart ? (
+
+ ) : null}
diff --git a/app/ui/LivePreview.tsx b/app/ui/LivePreview.tsx
index 1cb5acf..8f0a495 100644
--- a/app/ui/LivePreview.tsx
+++ b/app/ui/LivePreview.tsx
@@ -2,7 +2,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import { Button, Modal, Switch, Tag, Toast, Tooltip, Typography } from '@douyinfe/semi-ui'
-import { IconPlay, IconRefresh } from '@douyinfe/semi-icons'
+import { IconChevronDown, IconPlay, IconRefresh } from '@douyinfe/semi-icons'
import type { ButtonProps } from '@douyinfe/semi-ui/lib/es/button'
import { LiveStreamerEntity } from '@/app/lib/api-streamer'
import { platformName } from '@/app/lib/status'
@@ -19,6 +19,7 @@ import {
} from '@/app/lib/use-dashboard'
import { useBoolPref } from '@/app/lib/use-local-pref'
import { type DanmakuFeed, useDanmakuFeed } from '@/app/lib/danmaku-feed'
+import { LiveRateChart, LiveRateSummary, MODAL_RATE_WINDOW_MS } from './LiveRateChart'
import styles from './live-preview.module.scss'
const Players = dynamic(() => import('@/app/ui/Player'), { ssr: false })
@@ -28,6 +29,8 @@ const MAX_AUTO_RECONNECT = 3
const RECONNECT_DELAY_MS = 2000
/** 弹层弹幕开关记在本地,默认开;监视器另有自己的开关(默认关) */
const MODAL_DANMAKU_KEY = 'biliup.preview.danmaku'
+/** 弹层底部的码率折线默认展开,折叠状态记在本地 */
+const MODAL_RATE_CHART_KEY = 'biliup.preview.rateChart'
type Phase = 'connecting' | 'playing' | 'reconnecting' | 'ended' | 'error'
@@ -330,6 +333,7 @@ export function LivePreviewModal({
const [danmakuPref, setDanmakuPref] = useBoolPref(MODAL_DANMAKU_KEY, true)
const danmakuOn = visible && danmakuAvailable && danmakuPref
const danmakuFeed = useDanmakuFeed([streamer.id], danmakuOn)
+ const [chartOpen, setChartOpen] = useBoolPref(MODAL_RATE_CHART_KEY, true)
const transport = usePreviewTransport()
const notifiedRef = useRef(false)
useEffect(() => {
@@ -396,6 +400,25 @@ export function LivePreviewModal({
{visible ? (
) : null}
+ {/* 写盘速率折线:每秒轮询瘦端点 /v1/live-rates,最近 3 分钟;折叠时不轮询、不加载 uPlot */}
+
+
+ {visible && chartOpen ? (
+
+
+
+ ) : null}
+
{transport === 'direct'
diff --git a/app/ui/LiveRateChart.tsx b/app/ui/LiveRateChart.tsx
new file mode 100644
index 0000000..4188f0e
--- /dev/null
+++ b/app/ui/LiveRateChart.tsx
@@ -0,0 +1,111 @@
+'use client'
+import React, { useMemo } from 'react'
+import dynamic from 'next/dynamic'
+import { Switch, Tooltip, Typography } from '@douyinfe/semi-ui'
+import type { RateChartVariant } from './RateChart'
+import { formatInUnit, rateUnit, readRateSeries, useLiveRates } from '@/app/lib/use-live-rates'
+import { useBoolPref } from '@/app/lib/use-local-pref'
+import styles from './live-rate-chart.module.scss'
+
+/**
+ * 码率折线的懒加载入口。uPlot(约 22 KB gzip)与 `RateChart` 打在独立 chunk 里,
+ * 只有页面上真的出现了一张图才下载;首屏包不含它。
+ */
+const RateChart = dynamic(() => import('./RateChart'), { ssr: false, loading: () => null })
+
+/** 弹层折线的窗口:与浏览器内保留的历史一致 */
+export const MODAL_RATE_WINDOW_MS = 3 * 60 * 1000
+/** 卡片 / 监视器 sparkline 的窗口 */
+export const SPARK_RATE_WINDOW_MS = 60 * 1000
+
+/** 控制台 / 直播管理页卡片 sparkline 的开关(默认关),两页共用一个偏好 */
+export const CARD_RATE_CHART_KEY = 'biliup.cards.rateChart'
+
+export function useCardRateChart(): [boolean, (v: boolean) => void] {
+ return useBoolPref(CARD_RATE_CHART_KEY, false)
+}
+
+/** 工具栏里的「码率图」开关:开启后录制中卡片的封面上叠一条最近 60 秒的写盘速率折线。 */
+export function CardRateSwitch({ className }: { className?: string }) {
+ const [on, setOn] = useCardRateChart()
+ const { Text } = Typography
+ return (
+
+
+
+ 码率图
+
+ setOn(!!v)} aria-label="码率图" />
+
+
+ )
+}
+
+export function LiveRateChart({
+ id,
+ windowMs,
+ variant = 'full',
+ height,
+ label,
+ className,
+ overlay = false,
+}: {
+ id: number
+ windowMs: number
+ variant?: RateChartVariant
+ height?: number
+ label?: string
+ className?: string
+ /** 叠在封面 / 画面上:读数条放在图区内部而不是上方 */
+ overlay?: boolean
+}) {
+ const h = height ?? (variant === 'sparkline' ? 36 : 160)
+ // 外层定高:chunk 到达前后布局不跳
+ return (
+
+
+
+ )
+}
+
+/** 弹层折线标题里的「当前 / 峰值 / 均值」,与图共用同一份采样与同一个单位。 */
+export function LiveRateSummary({ id, windowMs }: { id: number; windowMs: number }) {
+ const snap = useLiveRates()
+ const series = useMemo(() => readRateSeries(id, windowMs, snap.receivedAt), [id, windowMs, snap])
+ const current = snap.latest.get(id) ?? null
+ if (!snap.latest.has(id) || series.latest === null) {
+ return (
+
+ —
+
+ )
+ }
+ const unit = rateUnit(series.max)
+ let sum = 0
+ let n = 0
+ for (const v of series.ys) {
+ if (v !== null) {
+ sum += v
+ n += 1
+ }
+ }
+ const avg = n > 0 ? sum / n : 0
+ return (
+
+
+ 当前 {current === null ? '—' : formatInUnit(current, unit)}
+
+
+ 峰值 {formatInUnit(series.max, unit)}
+
+
+ 均值 {formatInUnit(avg, unit)}
+
+
+ )
+}
diff --git a/app/ui/RateChart.tsx b/app/ui/RateChart.tsx
new file mode 100644
index 0000000..06ca4da
--- /dev/null
+++ b/app/ui/RateChart.tsx
@@ -0,0 +1,264 @@
+'use client'
+import React, { useEffect, useMemo, useRef } from 'react'
+import uPlot from 'uplot'
+import 'uplot/dist/uPlot.min.css'
+import { formatInUnit, rateUnit, readRateSeries, useLiveRates, type RateSeries } from '@/app/lib/use-live-rates'
+import styles from './rate-chart.module.scss'
+
+/**
+ * 写盘速率折线(uPlot)。本模块整体懒加载(消费方用 `next/dynamic` 引入),uPlot 与它的 CSS
+ * 只在第一次真正要画图时才下载,不进首屏。
+ *
+ * 两种形态:
+ * - `full`:弹层底部的折线,带时间轴与 Y 轴(单位按窗口最大值自动选 KB/s / MB/s),hover 读数;
+ * - `sparkline`:卡片 / 监视器小窗里的一条线,不画坐标轴,hover 显示数值与时刻。
+ *
+ * 数据来自 `useLiveRates()` 共享的采样历史;uPlot 实例在 effect 里创建 / 销毁,渲染期不碰 ref。
+ * 配色在每次绘制时从 Semi 的 CSS 变量读取,切换深浅色不用重建实例。uPlot 本身没有动画,
+ * 因此 `prefers-reduced-motion` 下没有需要关的东西;样式里也不加过渡。
+ * 没有任何采样(未录制 / 断流 / 下载器不计字节)时只显示「—」占位,不画空图。
+ */
+
+export type RateChartVariant = 'full' | 'sparkline'
+
+interface Props {
+ /** 直播间 id */
+ id: number
+ /** 展示的时间窗口(毫秒) */
+ windowMs: number
+ variant?: RateChartVariant
+ /** 图高(CSS px);宽随容器 */
+ height?: number
+ className?: string
+ /** 可访问名称前缀 */
+ label?: string
+ /** 叠在封面 / 画面上:读数条放图区内部,占位「—」不画虚线框 */
+ overlay?: boolean
+}
+
+const FONT = '11px system-ui, -apple-system, "Segoe UI", sans-serif'
+
+/** 读 Semi 主题变量。`--semi-green-5` 是 `r,g,b` 三元组,方便拼半透明填充。 */
+function themeColor(name: string, fallback: string): string {
+ if (typeof document === 'undefined') return fallback
+ const v = getComputedStyle(document.body).getPropertyValue(name).trim()
+ return v || fallback
+}
+const lineColor = () => `rgba(${themeColor('--semi-green-5', '59,179,70')}, 1)`
+const fillColor = () => `rgba(${themeColor('--semi-green-5', '59,179,70')}, 0.16)`
+const gridColor = () => themeColor('--semi-color-border', 'rgba(28,31,35,0.08)')
+const axisColor = () => themeColor('--semi-color-text-2', 'rgba(28,31,35,0.62)')
+
+function pad2(n: number): string {
+ return n < 10 ? `0${n}` : String(n)
+}
+function hhmmss(sec: number): string {
+ const d = new Date(sec * 1000)
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
+}
+
+function seriesMax(u: uPlot): number {
+ let max = 0
+ for (const v of u.data[1] ?? []) if (v !== null && v !== undefined && v > max) max = v
+ return max
+}
+
+/** Y 轴从 0 起,顶上留 15% 余量;全 0 时给 1 免得刻度退化 */
+function yRange(u: uPlot): uPlot.Range.MinMax {
+ const max = seriesMax(u)
+ return [0, max > 0 ? max * 1.15 : 1]
+}
+
+/** 横轴始终是「最新一点往前 windowSec 秒」:只有几个点时曲线从右端往左长,不会先铺满再压缩 */
+function xRange(windowSec: number) {
+ return (u: uPlot): uPlot.Range.MinMax => {
+ const xs = u.data[0]
+ const end = xs && xs.length > 0 ? xs[xs.length - 1] : Date.now() / 1000
+ return [end - windowSec, end]
+ }
+}
+
+function buildOptions(variant: RateChartVariant, width: number, height: number, windowMs: number): uPlot.Options {
+ const spark = variant === 'sparkline'
+ return {
+ width,
+ height,
+ // 图例用自己的读数条替代;uPlot 的表格图例会撑开高度
+ legend: { show: false },
+ // 只允许 hover 读数,不做框选缩放(缩放对实时滚动窗口没有意义)
+ cursor: {
+ x: !spark,
+ y: false,
+ drag: { x: false, y: false, setScale: false },
+ points: { show: true, size: spark ? 6 : 8, fill: lineColor, stroke: lineColor },
+ lock: false,
+ },
+ select: { show: false, left: 0, top: 0, width: 0, height: 0 },
+ scales: {
+ x: { time: true, range: xRange(windowMs / 1000) },
+ y: { range: yRange },
+ },
+ series: [
+ {},
+ {
+ label: '写盘速率',
+ stroke: lineColor,
+ fill: spark ? fillColor : undefined,
+ width: spark ? 1.5 : 2,
+ // 断口(null)不连线:录制中断的那段就是空白
+ spanGaps: false,
+ points: { show: false },
+ },
+ ],
+ axes: spark
+ ? [{ show: false }, { show: false }]
+ : [
+ {
+ stroke: axisColor,
+ grid: { stroke: gridColor, width: 1 },
+ ticks: { stroke: gridColor, width: 1, size: 4 },
+ font: FONT,
+ size: 22,
+ gap: 4,
+ values: (_u, splits) => splits.map(hhmmss),
+ },
+ {
+ stroke: axisColor,
+ grid: { stroke: gridColor, width: 1 },
+ ticks: { show: false },
+ font: FONT,
+ size: 58,
+ gap: 6,
+ // 四格等分、0 在底
+ splits: (_u, _axisIdx, _scaleMin, scaleMax) => [0, 1, 2, 3, 4].map((k) => (scaleMax / 4) * k),
+ values: (u, splits) => {
+ const unit = rateUnit(seriesMax(u))
+ return splits.map((s) => (s === 0 ? '0' : formatInUnit(s, unit)))
+ },
+ },
+ ],
+ }
+}
+
+/**
+ * 真正持有 uPlot 实例的部分;外层已确认窗口内有采样才挂它。
+ * 创建 / 销毁与数据更新分成两个 effect:StrictMode 双次执行时第一份实例在清理里销毁,
+ * 不会留下多余的 canvas。
+ */
+function Plot({
+ id,
+ windowMs,
+ variant,
+ height,
+ series,
+ overlay,
+}: {
+ id: number
+ windowMs: number
+ variant: RateChartVariant
+ height: number
+ series: RateSeries
+ overlay: boolean
+}) {
+ const hostRef = useRef(null)
+ const readoutRef = useRef(null)
+ const plotRef = useRef(null)
+
+ useEffect(() => {
+ const host = hostRef.current
+ const readout = readoutRef.current
+ if (!host) return
+ const width = Math.max(1, Math.floor(host.clientWidth))
+ const opts = buildOptions(variant, width, height, windowMs)
+ // hover 读数:直接写 DOM,不走 React 状态(mousemove 频率高,不值得重渲染)
+ opts.hooks = {
+ setCursor: [
+ (u) => {
+ if (!readout) return
+ const idx = u.cursor.idx
+ if (idx === null || idx === undefined) {
+ readout.hidden = true
+ return
+ }
+ const x = u.data[0][idx]
+ const y = u.data[1][idx]
+ const text = y === null || y === undefined ? '—' : formatInUnit(y, rateUnit(seriesMax(u)))
+ readout.textContent = `${hhmmss(x)} · ${text}`
+ readout.hidden = false
+ // 跟着光标左右走,不出图区
+ const plotLeft = u.bbox.left / (window.devicePixelRatio || 1)
+ const left = plotLeft + Math.min(Math.max(0, u.cursor.left ?? 0), Math.max(0, u.bbox.width / (window.devicePixelRatio || 1) - readout.offsetWidth))
+ readout.style.transform = `translateX(${Math.round(left)}px)`
+ },
+ ],
+ }
+ // 初始数据直接从共享历史读(import 异步、props 可能已经过时),随后的更新走下面那个 effect
+ const initial = readRateSeries(id, windowMs, Date.now())
+ const plot = new uPlot(opts, [initial.xs, initial.ys], host)
+ plotRef.current = plot
+ const ro = new ResizeObserver(() => {
+ const w = Math.floor(host.clientWidth)
+ if (w > 0 && w !== plot.width) plot.setSize({ width: w, height })
+ })
+ ro.observe(host)
+ return () => {
+ ro.disconnect()
+ plot.destroy()
+ plotRef.current = null
+ if (readout) readout.hidden = true
+ }
+ }, [id, windowMs, variant, height])
+
+ // 新一帧到达:只换数据,不重建实例
+ useEffect(() => {
+ plotRef.current?.setData([series.xs, series.ys])
+ }, [series])
+
+ return (
+
+
+
+ )
+}
+
+export default function RateChart({ id, windowMs, variant = 'full', height, className, label, overlay = false }: Props) {
+ const snap = useLiveRates()
+ // 环形缓冲只随快照版本变化,快照引用就是重新读序列的触发键;`receivedAt` 让渲染期的读取保持纯粹
+ const series = useMemo(() => readRateSeries(id, windowMs, snap.receivedAt), [id, windowMs, snap])
+ const h = height ?? (variant === 'sparkline' ? 36 : 160)
+ const unit = rateUnit(series.max)
+ const wrapCls = [styles.chart, className].filter(Boolean).join(' ')
+
+ // 房间不在最近一帧里(停录 / 下播 / 接口不可用)或窗口内从没有过采样(yt-dlp 等不计字节)→ 只给「—」
+ const recording = snap.latest.has(id)
+ if (!recording || series.latest === null) {
+ return (
+
+ —
+
+ )
+ }
+
+ const current = formatInUnit(series.latest, unit)
+ return (
+
+ )
+}
diff --git a/app/ui/StreamerCard.tsx b/app/ui/StreamerCard.tsx
index 18404bb..52161df 100644
--- a/app/ui/StreamerCard.tsx
+++ b/app/ui/StreamerCard.tsx
@@ -12,6 +12,7 @@ import {
canPreview,
} from '@/app/lib/use-dashboard'
import { LivePreviewButton, LivePreviewModal } from './LivePreview'
+import { LiveRateChart, SPARK_RATE_WINDOW_MS, useCardRateChart } from './LiveRateChart'
import styles from './streamer-card.module.scss'
export interface StreamerCardProps {
@@ -54,10 +55,16 @@ export function LiveCover({
src,
alt,
onPreview,
+ overlay,
+ onStateChange,
}: {
src: string
alt: string
onPreview?: () => void
+ /** 叠在封面底部的一层(码率 sparkline);点击会冒泡到封面本身的 onClick */
+ overlay?: React.ReactNode
+ /** 加载结果回调:父组件据此决定 overlay 放不放得下(加载失败整块消失) */
+ onStateChange?: (state: 'ok' | 'error') => void
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
if (state === 'error') return null
@@ -87,8 +94,14 @@ export function LiveCover({
fill
sizes="(max-width: 600px) 100vw, 400px"
loading="lazy"
- onLoad={() => setState('ok')}
- onError={() => setState('error')}
+ onLoad={() => {
+ setState('ok')
+ onStateChange?.('ok')
+ }}
+ onError={() => {
+ setState('error')
+ onStateChange?.('error')
+ }}
/>
{clickable ? (
@@ -97,6 +110,7 @@ export function LiveCover({
) : null}
+ {overlay ? {overlay}
: null}
)
}
@@ -156,6 +170,23 @@ export default function StreamerCard({
const avatarSrc = live ? liveImageUrl(streamer.id, 'avatar', streamer.live_avatar_url) : null
const rate = live ? formatRate(streamer.live_bytes_per_sec) : null
+ // 码率 sparkline(页面工具栏「码率图」开关,默认关):有封面就叠在封面底部,封面没有 / 加载失败就放名称行下
+ const [rateChartOn] = useCardRateChart()
+ const [coverFailed, setCoverFailed] = useState(false)
+ const coverShown = !!coverSrc && !coverFailed
+ const sparkline =
+ live && rateChartOn ? (
+
+ ) : null
+
const plat = platformName(streamer.url)
const platColor = PLAT_COLORS[plat] ?? DEFAULT_PLAT_COLOR
@@ -203,6 +234,8 @@ export default function StreamerCard({
src={coverSrc}
alt={`${name} 的直播间封面`}
onPreview={previewable ? () => setPreviewOpen(true) : undefined}
+ overlay={coverShown ? sparkline : null}
+ onStateChange={(s) => setCoverFailed(s === 'error')}
/>
) : null}
@@ -231,6 +264,9 @@ export default function StreamerCard({
) : null}
+ {/* 开了码率图但没有封面可叠:sparkline 放这一行 */}
+ {sparkline && !coverShown ? sparkline : null}
+
{/* 标题行:始终渲染(空字符串占位),保证所有卡片等高、标题基线对齐 */}
{info?.title || ''}
diff --git a/app/ui/live-monitor.module.scss b/app/ui/live-monitor.module.scss
index f88b24f..5637471 100644
--- a/app/ui/live-monitor.module.scss
+++ b/app/ui/live-monitor.module.scss
@@ -155,6 +155,12 @@
color: rgba(255, 255, 255, 0.85);
}
+/* 小窗下的 60 s 码率 sparkline(工具栏「码率图」开关,默认关) */
+.tileSpark {
+ padding: 6px 12px 0;
+ box-sizing: content-box;
+}
+
.tileFoot {
padding: 7px 12px 9px;
font-size: 12px;
diff --git a/app/ui/live-preview.module.scss b/app/ui/live-preview.module.scss
index d5f0c0c..bb2d50a 100644
--- a/app/ui/live-preview.module.scss
+++ b/app/ui/live-preview.module.scss
@@ -126,6 +126,62 @@
padding: 10px 24px 14px;
}
+/* ===== 弹层底部:写盘速率折线(可折叠) ===== */
+.rateSection {
+ border-top: 1px solid var(--semi-color-border);
+}
+.rateHead {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 24px;
+ border: 0;
+ background: transparent;
+ color: var(--semi-color-text-1);
+ font: inherit;
+ font-size: 12.5px;
+ text-align: left;
+ cursor: pointer;
+}
+.rateHead:hover {
+ background: var(--semi-color-fill-0);
+}
+.rateHead:focus-visible {
+ outline: 2px solid var(--semi-color-primary);
+ outline-offset: -2px;
+}
+.rateTitle {
+ font-weight: 600;
+ white-space: nowrap;
+ margin-right: auto;
+}
+.rateChevron {
+ flex-shrink: 0;
+ color: var(--semi-color-text-2);
+ transition: transform 0.15s ease;
+}
+.rateSection[data-open='false'] .rateChevron {
+ transform: rotate(-90deg);
+}
+.rateBody {
+ padding: 4px 24px 8px;
+}
+@media (max-width: 600px) {
+ .rateHead {
+ flex-wrap: wrap;
+ padding: 8px 16px;
+ }
+ .rateBody {
+ padding: 4px 12px 8px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .rateChevron {
+ transition: none;
+ }
+}
+
/* disabled 的按钮不响应鼠标,包一层让 Tooltip 仍能弹出 */
.disabledWrap {
display: inline-flex;
diff --git a/app/ui/live-rate-chart.module.scss b/app/ui/live-rate-chart.module.scss
new file mode 100644
index 0000000..ffbddff
--- /dev/null
+++ b/app/ui/live-rate-chart.module.scss
@@ -0,0 +1,31 @@
+/* 码率折线的懒加载壳与统计条 */
+
+.slot {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+}
+
+.switchRow {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.summary {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 12px;
+ font-size: 12px;
+ color: var(--semi-color-text-2);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.summary b {
+ font-weight: 600;
+ color: var(--semi-color-text-0);
+}
+.summary[data-state='empty'] {
+ color: var(--semi-color-text-3);
+}
diff --git a/app/ui/rate-chart.module.scss b/app/ui/rate-chart.module.scss
new file mode 100644
index 0000000..1d43553
--- /dev/null
+++ b/app/ui/rate-chart.module.scss
@@ -0,0 +1,74 @@
+/* 写盘速率折线(uPlot):弹层折线 + 卡片 / 监视器 sparkline 共用。不加过渡动画。 */
+
+.chart {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+}
+
+/* uPlot 挂载点:宽随容器、高由组件内联指定。不裁剪,读数条要能浮在 sparkline 上方 */
+.plotHost {
+ position: relative;
+ width: 100%;
+}
+.plotHost :global(.uplot) {
+ font-family: inherit;
+}
+/* 光标线与点用主题色 */
+.plotHost :global(.u-cursor-x) {
+ border-right: 1px dashed var(--semi-color-text-3);
+}
+
+/* hover 读数:贴在图区顶部,随光标横向移动 */
+.readout {
+ position: absolute;
+ top: 2px;
+ left: 0;
+ z-index: 2;
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 11px;
+ line-height: 16px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ color: var(--semi-color-text-0);
+ background: var(--semi-color-bg-3);
+ border: 1px solid var(--semi-color-border);
+ box-shadow: 0 1px 4px rgba(var(--semi-grey-9), 0.12);
+ pointer-events: none;
+}
+.plotHost[data-variant='sparkline'] .readout {
+ top: -20px;
+}
+/* 叠在封面上时读数条放图区内部(封面容器会裁掉外面的部分) */
+.plotHost[data-overlay] .readout {
+ top: 2px;
+}
+
+/* 无采样:只显示「—」 */
+.chart[data-state='empty'],
+.chart[data-state='error'] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--semi-color-text-3);
+ font-variant-numeric: tabular-nums;
+}
+.chart[data-variant='full'][data-state='empty'],
+.chart[data-variant='full'][data-state='error'] {
+ border: 1px dashed var(--semi-color-border);
+ border-radius: 8px;
+ font-size: 13px;
+}
+.chart[data-variant='sparkline'][data-state='empty'],
+.chart[data-variant='sparkline'][data-state='error'] {
+ font-size: 12px;
+}
+/* 叠在封面上的占位:白字、不加边框 */
+.chart[data-overlay][data-state='empty'],
+.chart[data-overlay][data-state='error'] {
+ color: rgba(255, 255, 255, 0.75);
+}
+.placeholder {
+ user-select: none;
+}
diff --git a/app/ui/streamer-card.module.scss b/app/ui/streamer-card.module.scss
index 3060434..f3dde06 100644
--- a/app/ui/streamer-card.module.scss
+++ b/app/ui/streamer-card.module.scss
@@ -258,6 +258,25 @@
align-self: center;
}
+/* 直播中:最近 60 秒写盘速率 sparkline(无坐标轴,hover 显示数值);没有封面可叠时放名称行下 */
+.spark {
+ margin: 2px 0 6px;
+}
+
+/* 叠在封面底部的 sparkline:深色渐变垫底让绿线在任何封面上都可读;点击仍冒泡到封面打开预览 */
+.coverOverlay {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 10px 6px 2px;
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.35) 60%, rgba(0, 0, 0, 0));
+ pointer-events: none;
+}
+.coverOverlay > * {
+ pointer-events: auto;
+}
+
/* 未开播:最近录制时间 */
.recInfo {
font-size: 12px;
diff --git a/crates/biliup-cli/src/server/api/live_rates.rs b/crates/biliup-cli/src/server/api/live_rates.rs
new file mode 100644
index 0000000..74fc501
--- /dev/null
+++ b/crates/biliup-cli/src/server/api/live_rates.rs
@@ -0,0 +1,436 @@
+//! 正在录制的直播间的写盘速率,供前端画码率曲线:
+//!
+//! - `GET /v1/ws/live-rates`:WebSocket,连上后每秒推一帧(JSON 数组),前端首选。
+//! WebSocket 不占浏览器对同一主机 HTTP/1.1 的六个并发连接(监视器 4 路视频 + 1 条弹幕 SSE
+//! 已经用掉五个,再挂一条 SSE 会把列表轮询饿死),与日志页的 `/v1/ws/logs` 是同一套机制。
+//! - `GET /v1/live-rates`:同一帧的一次性 HTTP 版本,供 WebSocket 连不上(反代没放行 upgrade)时
+//! 每秒轮询回退,也方便 curl 核对。
+//!
+//! `/v1/streamers` 每次要查全表、拼齐每个主播的全部字段,10 s 一次的节奏不适合调快;
+//! 这里只在内存里扫一遍 worker,对每个 `Working` 的房间读一次 [`RateMeter`] 的窗口均值——
+//! 与 `/v1/streamers` 的 `live_bytes_per_sec` 是同一个数,同一秒读到的值相同。
+//! 不落库、不新开采样任务、服务端不存历史:曲线的历史由浏览器自己累积(最近 3 分钟)。
+//!
+//! 两个路由都注册在 `router()` 里,`--auth` 时与 `/v1/streamers` 同一道登录校验。
+//!
+//! [`RateMeter`]: crate::server::common::throughput::RateMeter
+
+use crate::server::api::ws::websocket_origin_allowed;
+use crate::server::core::download_manager::DownloadManager;
+use crate::server::infrastructure::context::{Worker, WorkerStatus};
+use axum::Json;
+use axum::extract::State;
+use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
+use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
+use axum::response::{IntoResponse, Response};
+use serde::{Deserialize, Serialize};
+use std::sync::{Arc, OnceLock};
+use std::time::Duration;
+use tokio::sync::{OwnedSemaphorePermit, Semaphore};
+use tokio::time::{MissedTickBehavior, interval};
+use tracing::debug;
+
+/// WebSocket 推送间隔;后端 `RateMeter` 本身 1 s 采一次,再快没有新信息。
+pub const PUSH_INTERVAL: Duration = Duration::from_secs(1);
+/// 进程内同时允许的码率 WebSocket 连接数(每个标签页一条)。
+pub const MAX_RATE_CONNECTIONS: usize = 16;
+
+fn rate_connection_limit() -> &'static Arc
{
+ static LIMIT: OnceLock> = OnceLock::new();
+ LIMIT.get_or_init(|| Arc::new(Semaphore::new(MAX_RATE_CONNECTIONS)))
+}
+
+fn acquire_rate_permit(limiter: Arc) -> Option {
+ limiter.try_acquire_owned().ok()
+}
+
+/// 一个正在录制的直播间在某一秒的写盘速率。
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
+pub struct LiveRateFrame {
+ /// 直播间 id(`livestreamers.id`,与 `/v1/streamers` 一致)
+ pub id: i64,
+ /// 最近一个滑动窗口内的写盘速率(字节/秒);尚无采样或下载器不经本进程写盘时为 `null`
+ pub bytes_per_sec: Option,
+ /// 服务端读数的时刻,Unix 毫秒;同一次响应里所有房间相同
+ pub ts: i64,
+}
+
+/// 从 worker 列表里挑出正在录制的房间,按 id 升序给出各自的速率。
+pub fn live_rate_frames(workers: &[Arc], ts: i64) -> Vec {
+ let mut frames: Vec = workers
+ .iter()
+ .filter_map(|worker| {
+ let status = worker.downloader_status.read().unwrap();
+ match &*status {
+ WorkerStatus::Working(task) => Some(LiveRateFrame {
+ id: worker.id(),
+ bytes_per_sec: task.bytes_per_sec(),
+ ts,
+ }),
+ _ => None,
+ }
+ })
+ .collect();
+ frames.sort_by_key(|frame| frame.id);
+ frames
+}
+
+/// 当前这一秒的一帧:所有录制中房间的速率。
+async fn current_frames(managers: &DownloadManager) -> Vec {
+ let workers = managers.get_rooms().await;
+ live_rate_frames(&workers, chrono::Utc::now().timestamp_millis())
+}
+
+/// `GET /v1/live-rates`
+pub async fn get_live_rates(State(managers): State>) -> Response {
+ let mut response = Json(current_frames(&managers).await).into_response();
+ response
+ .headers_mut()
+ .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
+ response
+}
+
+/// `GET /v1/ws/live-rates`:WebSocket,连上后立刻发一帧,之后每秒一帧(与 `/v1/live-rates` 同一形状的
+/// JSON 数组)。客户端不需要发任何东西;Ping 回 Pong,Close 即结束。Origin 校验与连接数上限
+/// 与 `/v1/ws/logs` 同款。
+pub async fn ws_live_rates(
+ ws: WebSocketUpgrade,
+ State(managers): State>,
+ headers: HeaderMap,
+) -> Response {
+ if !websocket_origin_allowed(&headers) {
+ return (StatusCode::FORBIDDEN, "WebSocket Origin 不受信任").into_response();
+ }
+ let Some(permit) = acquire_rate_permit(rate_connection_limit().clone()) else {
+ return (
+ StatusCode::TOO_MANY_REQUESTS,
+ format!("码率推送连接数已达上限(进程内最多 {MAX_RATE_CONNECTIONS} 条)"),
+ )
+ .into_response();
+ };
+ ws.on_upgrade(move |socket| async move {
+ let _permit = permit;
+ push_live_rates(socket, managers).await;
+ })
+}
+
+async fn push_live_rates(mut ws: WebSocket, managers: Arc) {
+ let mut tick = interval(PUSH_INTERVAL);
+ tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
+ debug!("开始码率推送");
+ loop {
+ tokio::select! {
+ maybe_msg = ws.recv() => {
+ match maybe_msg {
+ Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
+ Some(Ok(Message::Ping(payload))) => {
+ if ws.send(Message::Pong(payload)).await.is_err() {
+ break;
+ }
+ }
+ Some(Ok(_)) => {}
+ }
+ }
+ _ = tick.tick() => {
+ let frames = current_frames(&managers).await;
+ let Ok(json) = serde_json::to_string(&frames) else { continue };
+ if ws.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ }
+ }
+ }
+ let _ = ws.send(Message::Close(None)).await;
+ debug!("码率推送结束");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::server::common::download::DownloadTask;
+ use crate::server::config::Config;
+ use crate::server::core::downloader::{DownloaderRuntime, DownloaderType};
+ use crate::server::infrastructure::connection_pool::ConnectionManager;
+ use crate::server::infrastructure::context::Stage;
+ use crate::server::infrastructure::models::live_streamer::LiveStreamer;
+ use biliup::downloader::live::{DownloaderHint, LiveStream};
+ use std::collections::HashMap;
+ use std::sync::RwLock;
+ use std::time::{Duration, Instant};
+
+ fn streamer(id: i64) -> LiveStreamer {
+ LiveStreamer {
+ id,
+ url: format!("https://live.bilibili.com/{id}"),
+ remark: format!("room {id}"),
+ filename_prefix: None,
+ time_range: None,
+ upload_streamers_id: None,
+ format: None,
+ override_cfg: None,
+ preprocessor: None,
+ segment_processor: None,
+ downloaded_processor: None,
+ postprocessor: None,
+ opt_args: None,
+ excluded_keywords: None,
+ }
+ }
+
+ fn live_stream() -> LiveStream {
+ LiveStream {
+ name: "room".into(),
+ url: "https://live.bilibili.com/1".into(),
+ title: "t".into(),
+ date: chrono::Utc::now(),
+ live_cover_url: String::new(),
+ avatar_url: None,
+ raw_stream_url: "https://cdn.example/live.flv".into(),
+ platform: "bilibili".into(),
+ stream_headers: HashMap::new(),
+ suffix: "flv".into(),
+ danmaku: None,
+ downloader_hint: DownloaderHint::StreamGears,
+ runtime_options: None,
+ }
+ }
+
+ fn idle_worker(id: i64) -> Arc {
+ Arc::new(Worker::new(
+ streamer(id),
+ None,
+ Arc::new(RwLock::new(Config::default())),
+ Default::default(),
+ ))
+ }
+
+ async fn working_worker(
+ id: i64,
+ downloader: DownloaderType,
+ ) -> (Arc, Arc) {
+ let worker = idle_worker(id);
+ let task = Arc::new(DownloadTask::new(
+ DownloaderRuntime::from_type(downloader),
+ &live_stream(),
+ ));
+ worker
+ .change_status(Stage::Download, WorkerStatus::Working(task.clone()))
+ .await;
+ (worker, task)
+ }
+
+ /// 只报告正在录制的房间;刚开录、还没有两次采样时给 `null` 而不是 0。
+ #[tokio::test]
+ async fn only_recording_rooms_are_reported_sorted_by_id() {
+ let (working_b, _) = working_worker(7, DownloaderType::StreamGears).await;
+ let (working_a, _) = working_worker(3, DownloaderType::StreamGears).await;
+ let workers = vec![idle_worker(1), working_b, working_a, idle_worker(9)];
+
+ let frames = live_rate_frames(&workers, 1_700_000_000_123);
+ assert_eq!(
+ frames,
+ vec![
+ LiveRateFrame {
+ id: 3,
+ bytes_per_sec: None,
+ ts: 1_700_000_000_123,
+ },
+ LiveRateFrame {
+ id: 7,
+ bytes_per_sec: None,
+ ts: 1_700_000_000_123,
+ },
+ ]
+ );
+ }
+
+ /// 曲线上的点与卡片数字必须是同一个数:都来自同一个 `RateMeter` 的同一次读数。
+ #[tokio::test]
+ async fn the_rate_is_the_same_number_the_streamer_list_reports() {
+ let (worker, task) = working_worker(1, DownloaderType::StreamGears).await;
+ let meter = task.rate_meter();
+ let t0 = Instant::now() - Duration::from_secs(2);
+ meter.sample_at(t0);
+ meter.counter().add(2_000_000);
+ meter.sample_at(t0 + Duration::from_secs(2));
+
+ let frames = live_rate_frames(&[worker], 0);
+ assert_eq!(frames.len(), 1);
+ assert_eq!(frames[0].bytes_per_sec, Some(1_000_000));
+ assert_eq!(frames[0].bytes_per_sec, task.bytes_per_sec());
+ }
+
+ /// 字节不经过本进程的下载器(边录边传 / yt-dlp)与列表一样报 `null`。
+ #[tokio::test]
+ async fn downloaders_without_a_counter_report_null() {
+ let (worker, task) = working_worker(1, DownloaderType::YtDlp).await;
+ task.rate_meter().counter().add(4096);
+ task.rate_meter().sample();
+ let frames = live_rate_frames(&[worker], 0);
+ assert_eq!(frames[0].bytes_per_sec, None);
+ }
+
+ /// 前端按这三个键读:`id` / `bytes_per_sec` / `ts`。
+ #[test]
+ fn the_frame_serialises_to_the_shape_the_frontend_reads() {
+ let json = serde_json::to_value(LiveRateFrame {
+ id: 5,
+ bytes_per_sec: Some(655_360),
+ ts: 1_700_000_000_000,
+ })
+ .unwrap();
+ assert_eq!(
+ json,
+ serde_json::json!({ "id": 5, "bytes_per_sec": 655_360, "ts": 1_700_000_000_000_i64 })
+ );
+ let json = serde_json::to_value(LiveRateFrame {
+ id: 5,
+ bytes_per_sec: None,
+ ts: 0,
+ })
+ .unwrap();
+ assert_eq!(json["bytes_per_sec"], serde_json::Value::Null);
+ }
+
+ /// 手写一个最小的 WebSocket 客户端(握手 + 读服务端未掩码的帧),不为测试引新依赖。
+ mod ws_client {
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+ use tokio::net::TcpStream;
+
+ pub async fn handshake(addr: &str, path: &str, origin: &str) -> (TcpStream, u16) {
+ let mut stream = TcpStream::connect(addr).await.unwrap();
+ let request = format!(
+ "GET {path} HTTP/1.1\r\nHost: {addr}\r\nOrigin: {origin}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n"
+ );
+ stream.write_all(request.as_bytes()).await.unwrap();
+ // 逐字节读到响应头结束
+ let mut head = Vec::new();
+ let mut byte = [0u8; 1];
+ while !head.ends_with(b"\r\n\r\n") {
+ stream.read_exact(&mut byte).await.unwrap();
+ head.push(byte[0]);
+ }
+ let status: u16 = std::str::from_utf8(&head)
+ .unwrap()
+ .split_whitespace()
+ .nth(1)
+ .unwrap()
+ .parse()
+ .unwrap();
+ (stream, status)
+ }
+
+ /// 读一帧文本(服务端发出的帧不带掩码);Close 帧返回 None。
+ pub async fn read_text(stream: &mut TcpStream) -> Option {
+ let mut header = [0u8; 2];
+ stream.read_exact(&mut header).await.unwrap();
+ let opcode = header[0] & 0x0f;
+ let mut len = (header[1] & 0x7f) as u64;
+ if len == 126 {
+ let mut ext = [0u8; 2];
+ stream.read_exact(&mut ext).await.unwrap();
+ len = u16::from_be_bytes(ext) as u64;
+ } else if len == 127 {
+ let mut ext = [0u8; 8];
+ stream.read_exact(&mut ext).await.unwrap();
+ len = u64::from_be_bytes(ext);
+ }
+ let mut payload = vec![0u8; len as usize];
+ stream.read_exact(&mut payload).await.unwrap();
+ match opcode {
+ 0x1 => Some(String::from_utf8(payload).unwrap()),
+ 0x8 => None,
+ other => panic!("unexpected opcode {other}"),
+ }
+ }
+ }
+
+ async fn serve(managers: Arc) -> String {
+ use axum::Router;
+ use axum::routing::get;
+ let app = Router::new()
+ .route("/v1/ws/live-rates", get(ws_live_rates))
+ .route("/v1/live-rates", get(get_live_rates))
+ .with_state(managers);
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap().to_string();
+ tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+ addr
+ }
+
+ /// WebSocket:连上立刻一帧、之后每秒一帧,形状与 HTTP 版一致;Origin 与 Host 不同源时 403。
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn the_websocket_pushes_one_frame_per_second_in_the_same_shape() {
+ let dir = tempfile::tempdir().unwrap();
+ let db = dir.path().join("data.sqlite3");
+ let pool = ConnectionManager::new_pool(db.to_str().unwrap())
+ .await
+ .unwrap();
+ let managers = Arc::new(DownloadManager::new(1, 0, pool));
+ let addr = serve(managers).await;
+
+ let (_stream, status) =
+ ws_client::handshake(&addr, "/v1/ws/live-rates", "https://attacker.example").await;
+ assert_eq!(status, 403, "Origin 与 Host 不同源应被拒绝");
+
+ let (mut stream, status) =
+ ws_client::handshake(&addr, "/v1/ws/live-rates", &format!("http://{addr}")).await;
+ assert_eq!(status, 101);
+ let t0 = Instant::now();
+ let first = ws_client::read_text(&mut stream).await.unwrap();
+ assert!(t0.elapsed() < Duration::from_millis(500), "第一帧应立刻到");
+ let frames: Vec = serde_json::from_str(&first).unwrap();
+ assert!(frames.is_empty(), "没有录制中的房间时是空数组");
+ let second = ws_client::read_text(&mut stream).await.unwrap();
+ let elapsed = t0.elapsed();
+ assert!(
+ elapsed >= Duration::from_millis(800) && elapsed < Duration::from_millis(1800),
+ "第二帧应在约 1 s 后到,实际 {elapsed:?}"
+ );
+ let _: Vec = serde_json::from_str(&second).unwrap();
+ }
+
+ /// 码率 WebSocket 连接数有上限,断开后释放。
+ /// 用独立的信号量而不是进程级那个:同一测试二进制里的 WebSocket 测试会并发占着一个许可。
+ #[test]
+ fn websocket_rate_connections_are_bounded() {
+ let limiter = Arc::new(Semaphore::new(MAX_RATE_CONNECTIONS));
+ let permits: Vec<_> = (0..MAX_RATE_CONNECTIONS)
+ .map(|_| acquire_rate_permit(limiter.clone()).unwrap())
+ .collect();
+ assert!(acquire_rate_permit(limiter.clone()).is_none());
+ drop(permits);
+ assert!(acquire_rate_permit(limiter).is_some());
+ }
+
+ /// 处理函数:空列表也是合法响应(`[]`),且禁止缓存——每秒都要拿到新读数。
+ #[tokio::test]
+ async fn the_endpoint_returns_an_uncacheable_json_array() {
+ let dir = tempfile::tempdir().unwrap();
+ let db = dir.path().join("data.sqlite3");
+ let pool = ConnectionManager::new_pool(db.to_str().unwrap())
+ .await
+ .unwrap();
+ let managers = Arc::new(DownloadManager::new(1, 0, pool));
+
+ let response = get_live_rates(State(managers)).await;
+ assert_eq!(response.status(), axum::http::StatusCode::OK);
+ assert_eq!(
+ response.headers().get(header::CACHE_CONTROL).unwrap(),
+ "no-store"
+ );
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .unwrap()
+ .to_str()
+ .unwrap(),
+ "application/json"
+ );
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .unwrap();
+ assert_eq!(&body[..], b"[]");
+ }
+}
diff --git a/crates/biliup-cli/src/server/api/mod.rs b/crates/biliup-cli/src/server/api/mod.rs
index 2e6714a..f1b7678 100644
--- a/crates/biliup-cli/src/server/api/mod.rs
+++ b/crates/biliup-cli/src/server/api/mod.rs
@@ -8,6 +8,8 @@ pub mod endpoints;
pub mod live_media;
/// 直播预览:把正在录制的流旁路给页面内播放器
pub mod live_preview;
+/// 录制中直播间的写盘速率(每秒轮询的瘦端点,供码率曲线)
+pub mod live_rates;
/// 单页应用静态文件处理
pub mod spa;
pub mod ws;
diff --git a/crates/biliup-cli/src/server/api/ws.rs b/crates/biliup-cli/src/server/api/ws.rs
index 78dd29a..3aa8a01 100644
--- a/crates/biliup-cli/src/server/api/ws.rs
+++ b/crates/biliup-cli/src/server/api/ws.rs
@@ -48,7 +48,8 @@ fn acquire_log_permit(limiter: Arc) -> Option {
limiter.try_acquire_owned().ok()
}
-fn websocket_origin_allowed(headers: &HeaderMap) -> bool {
+/// WebSocket 握手的 Origin 校验:与 Host 同源,或是本地开发前端。日志与码率两个 WS 共用。
+pub(crate) fn websocket_origin_allowed(headers: &HeaderMap) -> bool {
let Some(origin) = headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
diff --git a/crates/biliup-cli/src/server/common/download.rs b/crates/biliup-cli/src/server/common/download.rs
index 627b72d..486f8fb 100644
--- a/crates/biliup-cli/src/server/common/download.rs
+++ b/crates/biliup-cli/src/server/common/download.rs
@@ -232,6 +232,12 @@ impl DownloadTask {
}
}
+ /// 本任务的写盘速率表(测试里用来喂采样)。
+ #[cfg(test)]
+ pub(crate) fn rate_meter(&self) -> &RateMeter {
+ &self.meter
+ }
+
/// 当前直播间的封面与头像地址。
pub fn live_media(&self) -> LiveMedia {
self.media.read().unwrap().clone()
diff --git a/crates/biliup-cli/src/server/common/throughput.rs b/crates/biliup-cli/src/server/common/throughput.rs
index 747ef51..0099764 100644
--- a/crates/biliup-cli/src/server/common/throughput.rs
+++ b/crates/biliup-cli/src/server/common/throughput.rs
@@ -46,7 +46,7 @@ impl RateMeter {
self.sample_at(Instant::now());
}
- fn sample_at(&self, now: Instant) {
+ pub(crate) fn sample_at(&self, now: Instant) {
let total = self.counter.total();
let mut samples = self.samples.lock().unwrap();
samples.push_back((now, total));
diff --git a/crates/biliup-cli/src/server/router.rs b/crates/biliup-cli/src/server/router.rs
index 4c95474..c6afd48 100644
--- a/crates/biliup-cli/src/server/router.rs
+++ b/crates/biliup-cli/src/server/router.rs
@@ -13,6 +13,7 @@ use crate::server::api::live_media::{get_live_avatar, get_live_cover};
use crate::server::api::live_preview::{
get_live_danmaku, get_live_danmaku_multi, get_live_stream, get_live_url,
};
+use crate::server::api::live_rates::{get_live_rates, ws_live_rates};
use crate::server::infrastructure::service_register::ServiceRegister;
use axum::Router;
use axum::body::Body;
@@ -47,6 +48,10 @@ pub fn router(service_register: ServiceRegister) -> Router<()> {
// 预览播放器的实时弹幕(SSE),同样在登录校验之内;监视器多路复用一条
.route("/v1/streamers/{id}/danmaku", get(get_live_danmaku))
.route("/v1/danmaku", get(get_live_danmaku_multi))
+ // 录制中各房间的写盘速率(只读内存):WebSocket 每秒推一帧画码率曲线,HTTP 版供回退 / curl;
+ // 不要为此调快 /v1/streamers
+ .route("/v1/ws/live-rates", get(ws_live_rates))
+ .route("/v1/live-rates", get(get_live_rates))
// 配置管理路由
.route(
"/v1/configuration",
diff --git a/package-lock.json b/package-lock.json
index b01395d..bbf874c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,8 @@
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-json-tree": "^0.20.0",
- "swr": "^2.5.1"
+ "swr": "^2.5.1",
+ "uplot": "^1.6.32"
},
"devDependencies": {
"@types/node": "^26.6.2",
@@ -9373,6 +9374,12 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/uplot": {
+ "version": "1.6.32",
+ "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
+ "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
+ "license": "MIT"
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
diff --git a/package.json b/package.json
index 2822a4f..cd4d46c 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,8 @@
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-json-tree": "^0.20.0",
- "swr": "^2.5.1"
+ "swr": "^2.5.1",
+ "uplot": "^1.6.32"
},
"devDependencies": {
"@types/node": "^26.6.2",