* 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
207 lines
6.9 KiB
TypeScript
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,
|
|
}
|
|
}
|