Files
biliup/app/lib/use-dashboard.ts
T
Noah 5ce14df240 feat(ui): 录制中房间显示封面、头像与实时码率(直播预览阶段一) (#1709)
* feat(downloader): 录制写盘字节计数与实时速率,/v1/streamers 新增 live_bytes_per_sec

各下载器在写出点往 `ByteCounter`(Arc<AtomicU64>)累加,写盘路径上只多一次
`fetch_add`,不加锁、不 await、不改任何控制流:

- stream-gears/httpflv:`FlvFile::write_tag` 按 11 + 数据 + 4 字节计
- stream-gears/hls:`download_to_file` 每个 chunk 落盘后计
- mesio:条目进入修复管线前按 `FlvData::size()` / `HlsData::size()` 计
  (writer 来自外部 crate,拿不到逐条写出量;修复只增删极少量 tag)
- ffmpeg / streamlink:子进程自己落盘,改为每秒读一次 `.part` 长度做差分

`DownloadTask` 持有 `RateMeter`:1 秒采样、10 秒滑动窗口取首尾差值。窗口明显长于
GOP,避免 httpflv 按关键帧批量落盘造成的抖动;采样任务随 `execute` 启停。

`GET /v1/streamers` 每项新增 `live_bytes_per_sec`(字节/秒):未录制、采样不足、
或下载器不经本进程写盘(边录边传、yt-dlp)时为 null。

Part of #1708

* feat(live): 解析主播头像、补斗鱼封面,新增录制中封面/头像的同源代理端点

`LiveStream` 新增 `avatar_url: Option<String>`:
- B 站:`getInfoByRoom` 同一次响应里的 `anchor_info.base_info.face`
- 抖音:房间对象 `owner.avatar_thumb.url_list[0]`(web / H5 两个接口都有)
- 斗鱼:betard 的 `owner_avatar`,缺失时退回 `avatar.{middle,big,small}`;
  封面 `room_pic` 一并补上(此前写死空串)
- 虎牙:`gameLiveInfo` / `liveData` 的 `avatar180`,与 screenshot 一样升到 https
- 其余平台为 None,不改行为

`DownloadTask` 在内存里保存当前录制的封面与头像地址(每次 check_stream 刷新,
随任务消亡,不落库、不加迁移)。`GET /v1/streamers` 每项新增 `live_cover_url` /
`live_avatar_url`(未录制为 null)。

新增 `GET /v1/streamers/{id}/cover` 与 `/avatar`:页面直接引用图片 CDN 会带上 biliup
的 Referer 被 B 站等拒绝(403),改由服务端以直播间页面地址作 Referer 拉取后转发,
按图片 URL 做 5 分钟内存缓存(上限 64 条、单图 4MB)。只按主播 id 取 worker 里已有的
地址,不接受任意 URL;上游不是图片时不转发。路由注册在 `router()` 内,`--auth` 时
与其它 /v1 接口同一道登录校验。

Part of #1708

* feat(ui): 录制中卡片显示直播间封面、主播头像与实时写盘速率

`StreamerCard`(主页「进行中」与直播管理网格共用)在状态为录制中时:
- 顶部加 16:9 封面缩略图,懒加载,图片走 `/v1/streamers/{id}/cover` 同源代理;
  加载中显示占位底色,加载失败整块消失,卡片回到原有样式
- 名称前加 22px 头像圆图(`/v1/streamers/{id}/avatar`),失败则不占位
- 时长右侧加写盘速率,按 B/s、KB/s、MB/s 自动换单位;后端未采样(null)显示「—」

图片地址带原始 URL 的短哈希作查询参数,换一场直播封面变了就不会沿用旧缓存。
未开播 / 暂停的卡片不变。直播管理列表视图新增「码率」列,主播名前带头像。

不引入新依赖:头像与封面用 next/image(项目已配置 unoptimized),速率格式化与
代理地址拼接放在 `use-dashboard.ts`。

Part of #1708

* perf(live): B 站封面/头像经代理时改取 CDN 缩略图

实测 B 站 `user_cover` 原图可达 11520×8640、4 MB,卡片上只显示成 16:9 缩略图和 22px
头像,原样转发既慢又占缓存。对 `*.hdslb.com` 的地址借 CDN 的 `@宽w_高h_1c` 后缀取
640×360 / 128×128 的裁切图(同一张图从 4 MB 降到约 25 KB);已带后缀或带 query 的
地址、以及其它平台的地址原样使用。

Part of #1708

* refactor(downloader): ffmpeg / streamlink 写盘速率改为解析 stderr 进度行,不再读盘

review 意见:每秒 stat 一次 `.part` 长度会额外占磁盘 IO。改为让子进程自己报告:

- ffmpeg 加 `-progress pipe:2 -nostats`,把 key=value 进度块打到 stderr(不受
  `-loglevel quiet` 影响),`total_size=N` 就是本进程累计写出字节
- streamlink 加 `--progress force`,stderr 不是终端时也按周期打
  `[download] Written 12.34 MiB to <file> (5s @ 2.50 MiB/s)`

都在既有的 `spawn_log` stderr 读取协程里顺带解析(`SubprocessProgress`),进度行降为
debug 日志不再刷 info;累计值只加增量、回退时重置基准。解析不出来只是没有速率,
不影响录制。删掉 `FileSizeProbe`。

Part of #1708

* feat(live): 主播头像开播时下载一次存到 data/avatar/,接口直接读本地文件

review 意见:头像几乎不变,不必每次经代理拉取。

- 新增 `common/live_image.rs`:图片抓取(带 Referer、大小与类型校验、B 站缩略图后缀)
  从 `api/live_media.rs` 挪到这里共用;新增 `AvatarStore`,按主播 id 存
  `data/avatar/<id>.<ext>` + `<id>.json`(记录来源地址与 Content-Type),先写临时文件
  再改名,同一时刻只允许一个下载写盘
- 开播进入录制流程时后台下载一次;后续 check_stream 拿到的地址变了才重新下载;
  下载失败保留旧文件、只记日志
- `GET /v1/streamers/{id}/avatar` 改为读本地文件(`Cache-Control: max-age=3600`);
  正在录制而本地还没有时现场下载一次再返回,失败退回旧文件;不在录制时只读本地,
  没有就 404。服务重启后凭 `<id>.json` 直接复用,不重复下载
- 封面仍走内存缓存的代理(每场直播都会换,落盘意义不大)

Part of #1708

* fix(downloader): streamlink 进度行打在 stdout,stdout / stderr 都解析

实测 `--output` 到文件时 streamlink 把控制台输出(含 `[download] Written …`)
打在 stdout 而不是 stderr,只解析 stderr 会得到恒为 0 的速率。两条流共用一个
进度跟踪器,避免重复累加。

Part of #1708
2026-09-22 06:34:11 +00:00

207 lines
6.9 KiB
TypeScript

'use client'
import useSWR from 'swr'
import { API_BASE, fetcher, LiveStreamerEntity, StreamerInfo, FileList } from './api-streamer'
import { platformName } from './status'
/**
* 控制台数据聚合 hook —— 纯前端实现,零后端改动。
*
* 数据源(全部为现有后端接口):
* - /v1/streamers 主播与实时状态(轮询快:4s)
* - /v1/streamer-info 直播标题 / 开始时间(轮询慢:30s)
* - /v1/videos 录制文件列表(轮询慢:30s,用于文件总量 / 今日新增 / 事件流)
* - /v1/status 服务版本等(轮询慢:30s,仅用于可达性)
*
* 设计说明:后端没有"错误/磁盘占用"等指标,因此控制台不展示虚构数据,
* KPI 全部来自上述接口的真实聚合。
*/
/** 直播中(后端 WorkerStatus Debug 字符串,已验证与 WorkerStatus 枚举一致) */
export const LIVE_STATUS = 'Working'
export const PAUSE_STATUS = 'Pause'
export interface DashboardEvent {
/** Unix 秒 */
ts: number
kind: 'start' | 'file'
/** 主文本 */
text: string
/** 右侧小标签(平台 / 大小) */
sub?: string
}
export function formatSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(1)}G`
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(0)}M`
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}K`
return `${bytes}B`
}
export function timeAgo(tsSec: number): string {
const diff = Math.floor(Date.now() / 1000 - tsSec)
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
return `${Math.floor(diff / 86400)} 天前`
}
/** 已录制时长(秒) → "1h 12min" 样式 */
export function formatDuration(sec: number): string {
if (sec < 60) return `${Math.max(0, Math.floor(sec))}s`
if (sec < 3600) return `${Math.floor(sec / 60)}min`
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}min`
}
/**
* 写盘速率(字节/秒) → "1.2 MB/s" / "680 KB/s"。
* 后端没有采样(null / undefined)时返回 null,由调用方显示「—」。
*/
export function formatRate(bytesPerSec: number | null | undefined): string | null {
if (bytesPerSec === null || bytesPerSec === undefined || !Number.isFinite(bytesPerSec)) return null
const b = Math.max(0, bytesPerSec)
if (b >= 1 << 20) return `${(b / (1 << 20)).toFixed(b >= 10 << 20 ? 0 : 1)} MB/s`
if (b >= 1 << 10) return `${(b / (1 << 10)).toFixed(0)} KB/s`
return `${Math.round(b)} B/s`
}
/**
* 录制中直播间的封面 / 头像同源代理地址。
* 带上原始地址的短哈希作查询参数:封面换了(新一场直播)浏览器就不会沿用旧缓存。
*/
export function liveImageUrl(
id: number,
kind: 'cover' | 'avatar',
sourceUrl: string | null | undefined
): string | null {
if (!sourceUrl) return null
let h = 5381
for (let i = 0; i < sourceUrl.length; i++) h = ((h << 5) + h + sourceUrl.charCodeAt(i)) | 0
return `${API_BASE}/v1/streamers/${id}/${kind}?v=${(h >>> 0).toString(36)}`
}
const DAY = 86400
const EVENT_WINDOW = DAY // 事件流只展示最近 24h
/**
* 轮询间隔。/v1/streamer-info 是全表查询、/v1/videos 每次扫一遍工作目录,
* 长期运行的实例上这两个接口不便宜,所以只有主播状态走较快的节奏。
*/
export const STREAMERS_REFRESH_MS = 10000
export const SLOW_REFRESH_MS = 60000
export function useDashboard() {
const { data: streamers, error: e1 } = useSWR<LiveStreamerEntity[]>(
'/v1/streamers',
fetcher,
{ refreshInterval: STREAMERS_REFRESH_MS, revalidateOnFocus: true }
)
const { data: infos, error: e2 } = useSWR<StreamerInfo[]>(
'/v1/streamer-info',
fetcher,
{ refreshInterval: SLOW_REFRESH_MS }
)
const { data: videos, error: e3 } = useSWR<FileList[]>(
'/v1/videos',
fetcher,
{ refreshInterval: SLOW_REFRESH_MS }
)
const { data: status, error: e4 } = useSWR(
'/v1/status',
fetcher,
{ refreshInterval: SLOW_REFRESH_MS }
)
// ---- KPI:主播与任务状态 ----
const list = streamers ?? []
const total = list.length
const recording = list.filter((s) => s.status === LIVE_STATUS).length
const pending = list.filter((s) => s.upload_status === 'Pending').length
const uploading = list.filter((s) => s.upload_status === LIVE_STATUS).length
// ---- KPI:录制文件聚合(来自 /v1/videos) ----
// "今日"按本地时区的自然日 0 点起算
const nowLocal = new Date()
const todayStart = Math.floor(
new Date(nowLocal.getFullYear(), nowLocal.getMonth(), nowLocal.getDate()).getTime() / 1000
)
let totalSize = 0
let todaySize = 0
for (const v of videos ?? []) {
totalSize += v.size || 0
if ((v.updateTime || 0) >= todayStart) todaySize += v.size || 0
}
// ---- url → 最新 StreamerInfo(标题 / 最近开始时间) ----
const infoByUrl = new Map<string, StreamerInfo>()
for (const i of infos ?? []) {
if (!i.url) continue
const cur = infoByUrl.get(i.url)
if (!cur || i.date > cur.date) infoByUrl.set(i.url, i)
}
// ---- 事件流:前端合成,零后端改动 ----
// 1) 开始录制:streamer-info 里 24h 内的直播开始时间(按 url 合并取最新)
// 2) 文件生成:/v1/videos 里 24h 内生成的录制文件
const events: DashboardEvent[] = []
const now = Math.floor(Date.now() / 1000)
for (const i of Array.from(infoByUrl.values())) {
if (i.date && now - i.date <= EVENT_WINDOW) {
events.push({
ts: i.date,
kind: 'start',
text: `${i.name || i.url} 开始录制`,
sub: i.url ? platformName(i.url) : undefined,
})
}
}
for (const v of videos ?? []) {
const t = v.updateTime || 0
if (t && now - t <= EVENT_WINDOW) {
events.push({
ts: t,
kind: 'file',
text: v.name || '录制文件',
sub: formatSize(v.size || 0),
})
}
}
events.sort((a, b) => b.ts - a.ts)
// ---- 错误与可达性 ----
// 只有四个接口全部失败才判定为「后端不可达」;单个接口失败(尤其是序列化整份配置的
// /v1/status)只降级对应区块,不能把主播卡片整页替换成断连提示。
const hasAnyResponse =
streamers !== undefined || infos !== undefined || videos !== undefined || status !== undefined
const hasAnyError = !!e1 || !!e2 || !!e3 || !!e4
const allFailed = !!e1 && !!e2 && !!e3 && !!e4
const loading = !hasAnyResponse && !hasAnyError
const connectError = allFailed || (!hasAnyResponse && hasAnyError)
const streamersFailed = !!e1
const infosFailed = !!e2
const videosFailed = !!e3
const statusFailed = !!e4
return {
streamers,
infos,
videos,
version: (status as { version?: string } | undefined)?.version,
// KPI
total,
recording,
pending,
uploading,
totalSize,
todaySize,
infoByUrl,
events: events.slice(0, 12),
// 状态
loading,
connectError,
streamersFailed,
infosFailed,
videosFailed,
statusFailed,
}
}