* 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
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
'use client'
|
|
import React, { useState } from 'react'
|
|
import Image from 'next/image'
|
|
import { LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer'
|
|
import { streamerStatusMeta, uploadStatusTag, platformName } from '@/app/lib/status'
|
|
import { timeAgo, formatDuration, formatRate, liveImageUrl } from '@/app/lib/use-dashboard'
|
|
import styles from './streamer-card.module.scss'
|
|
|
|
export interface StreamerCardProps {
|
|
streamer: LiveStreamerEntity
|
|
/** 匹配 url 的最新 streamer-info(标题 / 最近开始时间) */
|
|
info?: StreamerInfo
|
|
/** 直播管理页的批量选择 */
|
|
selectable?: boolean
|
|
selected?: boolean
|
|
onToggleSelect?: (id: number) => void
|
|
/** 卡片底部操作区(编辑 / 暂停 / 删除等) */
|
|
actions?: React.ReactNode
|
|
}
|
|
|
|
/** 平台品牌色:用于右上角平台 chip,打破卡片单调并提供平台识别 */
|
|
const PLAT_COLORS: Record<string, string> = {
|
|
哔哩哔哩: '#fb7299',
|
|
虎牙直播: '#ff6a00',
|
|
斗鱼直播: '#ff6b35',
|
|
抖音直播: '#22c1c3',
|
|
YouTube: '#ff0000',
|
|
Twitch: '#9146ff',
|
|
CC直播: '#3aac5f',
|
|
快手直播: '#ff4906',
|
|
}
|
|
const DEFAULT_PLAT_COLOR = '#6b6c75'
|
|
|
|
/** 平台色 → 10% 淡底(hex + alpha 后缀) */
|
|
function platTint(color: string): string {
|
|
return color + '1a'
|
|
}
|
|
|
|
/**
|
|
* 录制中的直播间封面缩略图(16:9,懒加载)。
|
|
* 加载失败就整块消失,卡片回到没有封面时的样式;加载中显示占位底色。
|
|
* 调用方用 key={src} 挂载,封面地址变化时状态自然重置。
|
|
*/
|
|
export function LiveCover({ src, alt }: { src: string; alt: string }) {
|
|
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
|
if (state === 'error') return null
|
|
return (
|
|
<figure className={styles.cover} data-state={state}>
|
|
<Image
|
|
src={src}
|
|
alt={alt}
|
|
fill
|
|
sizes="(max-width: 600px) 100vw, 400px"
|
|
loading="lazy"
|
|
onLoad={() => setState('ok')}
|
|
onError={() => setState('error')}
|
|
/>
|
|
</figure>
|
|
)
|
|
}
|
|
|
|
/** 录制中的主播头像小圆图;加载失败则不占位。 */
|
|
export function LiveAvatar({ src }: { src: string }) {
|
|
const [failed, setFailed] = useState(false)
|
|
if (failed) return null
|
|
return (
|
|
<Image
|
|
className={styles.avatar}
|
|
src={src}
|
|
alt=""
|
|
aria-hidden="true"
|
|
width={22}
|
|
height={22}
|
|
loading="lazy"
|
|
onError={() => setFailed(true)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 全局统一的直播间卡片:
|
|
* 状态徽章 + 平台色 chip + [录制中:封面 / 头像 / 写盘速率] + 名称 + 实时时长(直播中)/ 最近录制(未开播)
|
|
* + 标题 + URL + 上传状态。主页与直播管理页共用。
|
|
*/
|
|
export default function StreamerCard({
|
|
streamer,
|
|
info,
|
|
selectable,
|
|
selected,
|
|
onToggleSelect,
|
|
actions,
|
|
}: StreamerCardProps) {
|
|
const meta = streamerStatusMeta(streamer.status)
|
|
const live = streamer.status === 'Working'
|
|
const paused = streamer.status === 'Pause'
|
|
const name = streamer.remark || streamer.url
|
|
|
|
// 实时时长:直播中时由"直播开始时间"计算
|
|
let duration: string | null = null
|
|
if (live && info?.date) {
|
|
duration = formatDuration(Date.now() / 1000 - info.date)
|
|
}
|
|
const lastRec = info?.date && !live ? timeAgo(info.date) : null
|
|
|
|
// 录制中才有封面 / 头像 / 速率;图片走同源代理,避开图片 CDN 的 Referer 校验
|
|
const coverSrc = live ? liveImageUrl(streamer.id, 'cover', streamer.live_cover_url) : null
|
|
const avatarSrc = live ? liveImageUrl(streamer.id, 'avatar', streamer.live_avatar_url) : null
|
|
const rate = live ? formatRate(streamer.live_bytes_per_sec) : null
|
|
|
|
const plat = platformName(streamer.url)
|
|
const platColor = PLAT_COLORS[plat] ?? DEFAULT_PLAT_COLOR
|
|
|
|
const cardCls = [
|
|
styles.card,
|
|
live ? styles.rec : '',
|
|
paused ? styles.paused : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
return (
|
|
<article className={cardCls} data-id={streamer.id}>
|
|
{/* 头部:状态徽章(左)+ 平台 chip(右) */}
|
|
<div className={styles.topRow}>
|
|
<span className={styles.left}>
|
|
{selectable ? (
|
|
<input
|
|
type="checkbox"
|
|
className={styles.chk}
|
|
checked={!!selected}
|
|
onChange={() => onToggleSelect?.(streamer.id)}
|
|
aria-label={`选择 ${name}`}
|
|
/>
|
|
) : null}
|
|
<span className={`${styles.badge} ${styles[meta.cls]}`}>
|
|
<span className={styles.bdot} />
|
|
{meta.label}
|
|
</span>
|
|
</span>
|
|
<span
|
|
className={styles.platChip}
|
|
style={{ backgroundColor: platTint(platColor), color: platColor }}
|
|
title={streamer.url}
|
|
>
|
|
<span className={styles.platDot} style={{ backgroundColor: platColor }} />
|
|
{plat}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 录制中:直播间封面 */}
|
|
{coverSrc ? <LiveCover key={coverSrc} src={coverSrc} alt={`${name} 的直播间封面`} /> : null}
|
|
|
|
{/* 名称(录制中带头像)+ 实时时长与写盘速率 / 最近录制 */}
|
|
<div className={styles.nameRow}>
|
|
<div className={styles.nameWrap}>
|
|
{avatarSrc ? <LiveAvatar key={avatarSrc} src={avatarSrc} /> : null}
|
|
<div className={styles.name} title={name}>
|
|
{name}
|
|
</div>
|
|
</div>
|
|
{live ? (
|
|
<span className={styles.liveStats}>
|
|
{duration ? <span className={styles.liveInfo}>{duration}</span> : null}
|
|
<span
|
|
className={styles.rate}
|
|
title={rate ? '写盘速率(最近 10 秒平均)' : '写盘速率:尚无采样'}
|
|
aria-label={rate ? `写盘速率 ${rate}` : '写盘速率尚无采样'}
|
|
>
|
|
{rate ?? '—'}
|
|
</span>
|
|
</span>
|
|
) : lastRec ? (
|
|
<span className={styles.recInfo}>{lastRec}</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* 标题行:始终渲染(空字符串占位),保证所有卡片等高、标题基线对齐 */}
|
|
<div className={styles.title} title={info?.title || ''}>
|
|
{info?.title || ''}
|
|
</div>
|
|
<a
|
|
className={styles.url}
|
|
href={streamer.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={streamer.url}
|
|
>
|
|
{streamer.url}
|
|
</a>
|
|
<div className={styles.meta}>{uploadStatusTag(streamer.upload_status)}</div>
|
|
{actions ? <div className={styles.actions}>{actions}</div> : null}
|
|
</article>
|
|
)
|
|
}
|