feat: 直播预览阶段二——内置播放器复用录制流(卡片弹层 + 历史页监视器) (#1712)

* feat(downloader): 直播预览 hub 与写盘旁路(httpflv / hls / mesio)

每路录制任务一个 PreviewHub(寿命 = 整场 execute(),跨分段与断流重试),下载器开始拉流时
attach 一个写入端,在写盘点旁边把同一份媒体字节 push 给它:httpflv 在关键帧写出循环、
stream-gears/hls 在分片 chunk 循环、mesio 在条目进入修复管线前。fMP4 分片流与 HEVC 序列头
标为不可预览;ffmpeg / streamlink / yt-dlp / 边录边传明确不可预览并给出原因。

写入端热路径只做三件事:GOP 缓冲追加、try_recv 订阅请求、broadcast::send。没有 .await、
没有锁等待、没有 ?;订阅请求由写入端在下一次推送时回应,快照(文件头 + 序列头 + 当前 GOP)
与随后的实时分块严格衔接。GOP 快照上限 8 MB,广播缓冲 256 槽 × 64 KB 且无订阅者时不占用,
每路订阅者数由信号量限制;慢订阅者收到 Lagged 后由端点断开让其重连。

Part of #1708

* feat(api): GET /v1/streamers/{id}/live 与列表的 preview 字段

新增 chunked 视频流端点:先发关键帧对齐的快照再接实时分块,Content-Type 按容器为
video/x-flv 或 video/mp2t,Cache-Control: no-store。房间不存在 / 未录制 404,下载器不能
旁路 415(带原因),连接数超限 429(每路 4、进程 16),尚未开始拉流或正在重连 503 +
Retry-After。慢客户端 Lagged 或写入端换代时结束响应,由播放器重连。注册在 router() 内,
--auth 时与 /v1/streamers 同一道登录校验。

GET /v1/streamers 每项新增 preview: { available, format: "flv" | "mpegts" | null, reason },
未录制为 null。

Part of #1708

* feat(preview): fMP4 分片流也能预览——init segment 快照、按分片广播、解出 codecs

mesio 的 HLS 会话拿到 fMP4 分片时不再标为不可预览:init segment(ftyp + moov)作为文件头
进快照,每个 moof + mdat 分片作为一个关键帧分块广播,新订阅者拿到 init + 最近一个完整分片,
端点 Content-Type 为 video/mp4,列表字段 preview.format 为 "fmp4"。

从 init segment 的 moov/trak/…/stsd 解出 RFC 6381 编码串(avc1.PPCCLL、hvc1.…、mp4a.40.N、
av01 / vp09 / ac-3 / ec-3 / opus / flac)放进 preview.codecs,供前端 MediaSource.isTypeSupported()
校验后 addSourceBuffer;解析只在 attach 后收到 init 时做一次,不在逐分块路径上。
stream-gears 的 HLS 路径没有下载 #EXT-X-MAP 初始化分片,遇到非 TS 分片时标为不可预览并说明原因。

按 ForgQi 在 #1708 的要求把 fMP4 纳入阶段二。

Part of #1708

* feat(ui): 直播间卡片的「预览」弹层——复用录制流的内置播放器

Player.tsx 加 isLive / muted / autoplay / type / codecs 与 onEnded / onError:flv 与 mpegts 走
mpegts.js(直播模式:不攒缓冲、追帧、自动清理 SourceBuffer),fmp4 走一个轻量 MediaSource
播放器(fetch ReadableStream → appendBuffer 串行队列,首个缓冲对齐播放位置,落后 > 3 s 追到
末尾,定期 remove 30 s 之前的缓冲),都以 Artplayer customType 挂载保持 UI 一致;
type 未知时用一次 GET 读 Content-Type 再起播。MediaSource.isTypeSupported 不通过时报错并显示
封面 + 原因,不转圈。

StreamerCard 录制中加「预览」图标按钮(不可预览时禁用,tooltip 显示后端给的原因),封面可点
即打开预览;弹层(LivePreview.tsx)关闭即卸载播放器、断开连接。服务端断开(掉队 / 换直链)
自动重连 3 次,之后交给用户手动重试;不可恢复错误 Toast 提示一次。零新依赖。

Part of #1708

* feat(ui): 历史记录页「实时监视」Tab——多路同屏监视器

<LiveMonitor /> 是独立组件:所有正在录制的直播间以小窗网格展示,可预览且在同屏路数内的
直接播放(默认静音),超出的显示封面、点击替换最早开始的一路(先进先出),下载器不能旁路
的显示封面与原因。同屏路数默认 DEFAULT_MONITOR_TILES = 4(可选 1/2/4/6/9,localStorage 记住),
每路小窗占对应直播间的一个预览连接。正在播放的列表由「用户选择 + 当前可预览房间 + 路数」
纯推导,不在 effect 里 setState。

历史记录页改为两个 Tab:「录制文件」(原表格)与「实时监视」,keepDOM=false 切走即卸载全部
播放器。要把监视器挪到别的页面只需换挂载点。

Part of #1708

* test(preview): 让旁路测试不依赖调度时机

rolling 测试先把订阅请求 poll 进写入端队列再开始写盘,源数据按 2 ms 一块限速送入而不是瞬间灌完
(瞬间灌完时订阅者必然掉队,那是慢订阅者测试覆盖的场景);慢订阅者测试只断言
「收到 + 被告知跳过 = 全部」与写入端不等待,不再对读取任务在负载下的吞吐做假设。

* fix(ui): mpegts.js 的 MSE 解码失败给出可读提示

典型来源是 TS 的 PES 没按访问单元对齐(如 Twitch 的 HLS),mpegts.js 拆出的帧不完整、浏览器拒绝
解码;原文 "Failed to execute 'appendBuffer'…" 对用户没有信息量,改为说明原因并强调录制不受影响。

* fix(preview): fMP4 与 TS 的快照从关键帧起,而不是从分段边界起

B 站 hls_fmp4 的分段(约 1 s)各含多个 moof/mdat 分片、且分段边界不对齐关键帧(实测 10 次冷启动
只有 1 次首帧是 I 帧):写入端现在把每个分段按 moof/mdat 切开,读 init 里的视频轨 ID 与
trex 默认 flags、trun 的 first_sample_flags / 首样本 flags / tfhd 默认 flags,视频首帧为同步样本的
分片才作为 GOP 起点。TS 分片起点则嗅探第一个视频 PES 的 NAL 类型(IDR/SPS → 起点,只有普通片
→ 追加到上一 GOP,说不准 → 按起点处理)。FLV 路径本来就按关键帧 tag 切,未变。

单测覆盖:分片切分(前导 styp、非同步 / 同步 / trex 默认 / 无视频轨)、跨分段边界的快照、
TS 嗅探(跨包的 PES、音频 PES 在前、非 TS 输入);另有 #[ignore] 的 B 站真实抓样核对。

* feat(danmaku): 预览播放器的实时弹幕——录制中的弹幕客户端旁路一份到 SSE

danmaku crate 的 RecorderConfig 新增可选的 live_tx(tokio broadcast):每解出一条事件,在写 XML
之前 send 一份(不等待,无订阅者即丢弃),XML 录制路径不变。DownloadTask 按 stream.danmaku
是否存在建一条 256 槽的广播,传给 danmaku_client;新增 GET /v1/streamers/{id}/danmaku(SSE,
每条 event 为 danmaku / gift / super_chat / guard_buy,data 为 {kind, text, name, color, ts},
进场消息不进;掉队跳过不断开;进程上限 32 路,超出 429;无弹幕客户端的平台 415)。
/v1/streamers 的 preview 字段新增 danmaku: bool,前端据此启用 / 禁用弹幕开关。

Part of #1708

* feat(ui): 预览弹层装载实时弹幕(开关控制),监视器默认关

Player.tsx 新增 danmaku: { url, enabled }:装 artplayer-plugin-danmuku(新依赖,Artplayer 官方弹幕
插件,5.3.0),开关打开时用 EventSource 连 /v1/streamers/{id}/danmaku,每条事件 emit 进弹幕层
(普通弹幕滚动、礼物 / 醒目留言 / 上舰置顶描边),关闭时断开 SSE 并隐藏弹幕层,不重建播放器。
弹层标题栏加「弹幕」Switch,默认开、记在 localStorage(biliup.preview.danmaku);平台没有弹幕
客户端时禁用并 tooltip 说明。监视器工具栏加同名开关,默认关(biliup.monitor.danmaku),
对全部小窗生效。localStorage 偏好统一走 useSyncExternalStore(app/lib/use-local-pref.ts),
同屏路数也改用它。

Part of #1708

* fix(preview): FLV 广播缓冲扩到 1024 槽,装得下 CDN 的整 GOP 突发

斗鱼 CDN 把整个 GOP(~4 s、~320 个 tag、~2 MB)一次突发送达,httpflv 解析器 1 ms 内推完;
刚建立的订阅者还没读到第一个实时分块,256 槽的缓冲就已被覆盖,收到 Lagged 被断开,重连后
又赶上下一个突发——冷启动 10 次只有 1 次播起来。FLV 分块是单个 tag,小而多,缓冲按格式分开:
FLV 1024 槽(实测均值 ~10 KB → ~10 MB),TS / fMP4 沿用 256 槽(分块大而少,16 MB 上限)。
回归测试:请求在关键帧回应后紧跟 600 个 tag 同步推完,订阅者仍逐个读到。

Part of #1708

* fix(ui): 弹幕开关禁用时说明是平台不支持还是未开对应 *_danmaku 配置

* feat(danmaku): 多个直播间的实时弹幕可复用一条 SSE(GET /v1/danmaku?ids=…)

浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个:监视器同屏 N 路视频已占 N 个,弹幕若每路再开
一条 SSE,N=4 时就把列表轮询也饿死。新增 /v1/danmaku?ids=1,3,9 把若干路合成一条,事件 JSON
带 id 供前端分发;没有弹幕 / 未录制的 id 静默跳过,一个都没有时 404;一条最多 16 路,占 1 个
连接许可。原 /v1/streamers/{id}/danmaku 保留,走同一实现(事件同样带 id)。

Part of #1708

* feat(ui): 弹幕连接由页面持有并复用,监视器路数按 HTTP 协议限制

浏览器对同一主机 HTTP/1.1 只有 6 个并发连接,监视器 4 路视频 + 每路一条弹幕 SSE 就把列表轮询
饿死;9 路时也只有 6 路能播(实测)。改为 DanmakuFeed:页面(弹层 / 监视器)持有一条
/v1/danmaku?ids=… 连接,各播放器按 id 订阅;构造无副作用、open() 后有订阅者才连,StrictMode
下重复构造无害。同屏路数选项在 HTTP/1.1 下为 1 / 2 / 4,页面经 h2 / h3 加载时放开 6 / 9。
小窗弹幕字号 15px,弹层 22px。

Part of #1708

* fix(ui): 解码失败后关闭弹层必须断开拉流连接

<video>.error 非空时 mpegts.js destroy() 里清 SourceBuffer 的 remove() 抛 InvalidStateError,
后面的 transmuxer.close() 跑不到,连接留在服务端占着该路的预览许可直到刷新页面——真实 Twitch
TS 房间(mpegts.js 放不了)连开 4 次弹层第 4 次就 429。销毁前先把 MediaSource 从元素上摘下
(load() 使其 closed,mpegts.js 对 closed 的 MediaSource 跳过清理),destroy 仍抛则直接关
内部 transmuxer 兜底。

Part of #1708

* fix(ui): mpegts.js 换用可中止的自定义 loader,弹层关闭一定断开拉流

上一提交把 MediaSource 从元素上摘下只避免了 destroy() 抛异常,连接仍未断开:mpegts.js 自带的
FetchStreamLoader 在 Chrome 上处于 buffering 状态时 abort() 不调 AbortController.abort(),
而是等下一个 read() 回来再 reader.cancel()——解码出错后读取链已断,那一次永远等不到,连接留在
服务端占着该路许可(真实 Twitch TS 房间:关闭弹层后用 4 条 curl 探,1 条 429;再开一次 2 条 429,
直到浏览器退出才释放)。改为 customLoader:每次 open() 一个 AbortController,abort() 直接调它,
其余与原 loader 一致(直播不需要 Range)。

Part of #1708

* fix(ui): 忽略 Artplayer 在实例销毁后的自动重连,杜绝孤儿拉流连接

真正的泄漏来源:Artplayer 收到 video:error 后每隔 1 s 重设 art.url 最多 5 次(RECONNECT_TIME_MAX),
不检查自己是否已 destroy。弹层在解码失败时立刻卸载播放器,1 s 后 Artplayer 仍在已销毁的实例上
重设 url → customType 再建一个 mpegts.js 实例、再拉一路流,没有任何人持有它——每次打开泄漏
最多 5 条连接,直到刷新页面。mpegts / fMP4 两个 customType 入口对 art.isDestroy 直接返回;
fMP4 入口同时复用上一次的 dispose(同一实例重设 url 时先清掉旧的 MediaSource)。
mock 上用循环发送的 Twitch 坏样本复现:修前每开一次多 1 条连接(1→2→3),修后开关 4 次始终 0。

Part of #1708

* fix(ui): 自动播放重试前确认播放器还活着

* fix(preview): 最后一个订阅者走后释放广播缓冲

tokio broadcast 的缓冲只在被新数据覆盖时丢旧分块,而没有订阅者时写入端不再 send——最后一个
订阅者走后,缓冲里最后 N 个分块就一直挂着(FLV 1024 槽约 10 MB / 路;7 路真实短测中 RSS 在
一轮预览后从 95 MB 停在 117 MB)。写入端发现订阅者归零且缓冲里有数据时换一条新通道,旧缓冲
整个释放;之后的 subscribe() 拿新通道,对订阅者透明。

Part of #1708

* feat(preview): 直播预览取流方式配置(relay / direct)与浏览器直连的能力判定、直链端点

后端与浏览器不在一台机器时,浏览器直接向 CDN 拉一路能省服务器出口带宽(ForgQi,#1712 review 2)。
全局配置新增 preview_transport:relay(默认,复用录制流经 biliup 中转)/ direct(浏览器直连 CDN),
JSON 配置里可选字段,不需要迁移。/v1/streamers 的 preview 新增 direct: { capable, reason },按
review 1 实测的平台表判定:B 站 / 抖音 / 虎牙 FLV 能直连;斗鱼一 token 一连接、直连会挤掉录制;
Twitch CDN 无 ACAO;HLS(TS / fMP4)本版本回落——mpegts.js 放不了 m3u8,直连需 hls.js;其它平台
未验证按不能。新增 GET /v1/streamers/{id}/live-url:当前录制中那条流的直链、容器、平台与过期时间
估计(B 站 expires / 抖音 expire 十进制 Unix 秒,虎牙 wsTime 十六进制;斗鱼的相对秒数不算),
Cache-Control: no-store,与 /live 同在登录校验之内。DownloadTask 随每次 check_stream 刷新直链。

Part of #1708

* feat(ui): 直播预览取流方式——全局设置下拉,direct 模式下浏览器直连 CDN、失败自动回落中转

空间配置 → 全局设置新增「直播预览取流方式(preview_transport)」:经 biliup 中转(默认)/ 浏览器直连
CDN,文案说明两者区别与适用场景。LivePreviewPlayer 按(房间, 配置, 能力)派生取流方式:direct 且
平台能直连时先 GET /v1/streamers/{id}/live-url 拿直链(https 页面下 http 直链换 https),mpegts.js
以 cors / 不带凭据拉 CDN;出错或断开时重取一次直链再播,仍失败回落中转;平台不能直连的房间在
direct 模式下直接走中转。播放器左上角角标:「直连 CDN」或「已回落中转:<原因>」。监视器小窗共用
同一组件。异步结果与回落都带 sourceKey,配置切换时不在 effect 里同步 setState。

Part of #1708

* fix(ui): 直连模式下弹层脚注说明取流方式

* feat(preview): 直连模式向平台另取一条直链,不复用录制那条;斗鱼由此也能直连

ForgQi(#1712):走直连就不要复用链接,直接获取新链接。/v1/streamers/{id}/live-url 改为对该房间的
平台插件再调一次 check_stream,把新拿到的 raw_stream_url(新 token)给浏览器;平台返回未开播或
出错时 503,前端回落中转。实测新 token 的斗鱼 / 虎牙直链各读 25 s 稳定,同时录制那条连接无重试
——review 1 观察到的「斗鱼一 token 一连接」「虎牙第二条连接只给 GOP 缓存即断」都是同一直链两条
连接的问题,另取一条就绕开了。能力表:B 站 / 抖音 / 斗鱼 / 虎牙 FLV 能直连;Twitch 无 ACAO、HLS 需
hls.js 仍回落。Monitor 新增 PluginFor 消息按房间 URL 取插件。

Part of #1708

* fix(ui): 直连播够 60 s 后再断按直链到期处理,重取直链继续直连

斗鱼 CDN 的 token 每 5 分钟切一次连接(录制那条同样如此,日志里每 5:01 一次 StreamEnded → 重连),
B 站直链 1 h 过期。之前直连只允许重取一次,第二次到期就回落中转;现在区分「刚起播就失败」
(重取一次仍失败 → 回落)与「播了一阵才断」(视为到期,照常再取)。

Part of #1708

* feat(preview): HLS 直连——直链是 m3u8 时走 hls.js(按需加载),B 站 hls_fmp4 等也能直连

ForgQi(#1712):HLS 直连也支持一下。新依赖 hls.js ^1.7(动态 import,只在直连模式打开 HLS 房间时
才下载那一个 chunk,首屏包不变)。live-url 的 format 按新直链后缀给 flv / hls;direct_capability
只看平台的跨域放行,不再因容器排除——B 站 .m4s 分片的 CDN 同样 ACAO: *。Player 新增 hls 分支:
跟到最后 3 个分片、后向缓冲 30 s、分片请求不带凭据;清单 / 分片 404、CORS、解码等致命错误报给
LivePreviewPlayer 走重取直链 / 回落中转,清单变为非直播视为断开。Twitch 仍因无 ACAO 回落。

Part of #1708

* feat(preview): live-url 同一房间 5 秒去抖,播放失败后的重取带 fresh=1 绕过

每次打开预览都向平台 API 取一条新直链(带 cookie 时消耗登录态配额,B 站接口有频控)。同一房间
5 s 内的重复请求(StrictMode 双调用、连点、同一房间多个小窗)复用上一次结果并标 cached: true,
不再打平台;前端播放失败后的重取传 ?fresh=1 一定拿新 token(抖音某节点 404、直链到期都靠它)。
缓存插入时顺手清掉一分钟前的条目,表不会长。

Part of #1708
This commit is contained in:
Noah authored and GitHub committed 2026-09-22 16:25:00 +00:00
1 parent 20fe058b15
commit 072604eed5
39 files changed
+5684 -92

No files matched your search

Generated
+1
View File
@@ -484,6 +484,7 @@ dependencies = [
"dialoguer",
"dirs",
"error-stack",
"flv",
"flv-fix",
"futures",
"glob",
+9
View File
@@ -0,0 +1,9 @@
/* 历史记录页:Tab 面板不裁剪内容(Semi Tabs 面板默认 overflow hidden 会吃掉小窗阴影与浮层) */
.tabs :global(.semi-tabs-content),
.tabs :global(.semi-tabs-pane) {
overflow: visible;
}
.tabs :global(.semi-tabs-bar) {
margin-bottom: 16px;
}
+35 -11
View File
@@ -1,5 +1,5 @@
'use client'
import { Modal, Table, Typography } from '@douyinfe/semi-ui'
import { Modal, Table, Tabs, TabPane, Typography } from '@douyinfe/semi-ui'
import { IconVideoListStroked } from '@douyinfe/semi-icons'
import { SortOrder } from '@douyinfe/semi-ui/lib/es/table'
import useSWR from 'swr'
@@ -9,14 +9,23 @@ import dynamic from 'next/dynamic'
import { humDate } from '@/app/lib/utils'
import { formatSize } from '@/app/lib/use-dashboard'
import PageHeader from '../components/PageHeader'
import LiveMonitor from '@/app/ui/LiveMonitor'
import dc from '@/app/ui/data-card.module.scss'
import styles from './page.module.scss'
const Players = dynamic(() => import('@/app/ui/Player'), {
ssr: false,
})
type HistoryTab = 'files' | 'monitor'
/**
* 历史记录:「录制文件」(已录完的文件回放)与「实时监视」(正在录制的直播间多路同屏)两个 Tab。
* 监视器是独立组件 <LiveMonitor />,要挪到别的页面只需换个挂载点。
*/
export default function History() {
const { Text } = Typography
const [tab, setTab] = useState<HistoryTab>('files')
const { data: data, error, isLoading } = useSWR<FileList[]>('/v1/videos', fetcher)
const [fileName, setFileName] = useState<string>()
const [visible, setVisible] = useState(false)
@@ -60,18 +69,33 @@ export default function History() {
<PageHeader
icon={<IconVideoListStroked size="large" />}
title="历史记录"
description="已录制的视频文件,可在线回放"
description="已录制的视频文件可在线回放;「实时监视」同屏查看正在录制的直播间"
/>
<div className={dc.content}>
<div className={dc.card}>
<Table
size="small"
scroll={{ x: 'max-content' }}
columns={columns}
dataSource={data}
loading={isLoading}
/>
</div>
<Tabs
type="line"
activeKey={tab}
onChange={(key) => setTab(key as HistoryTab)}
className={styles.tabs}
keepDOM={false}
>
<TabPane tab="录制文件" itemKey="files">
<div className={dc.card}>
<Table
size="small"
scroll={{ x: 'max-content' }}
columns={columns}
dataSource={data}
loading={isLoading}
empty={error ? '加载失败,请检查后端连接' : '暂无数据'}
/>
</div>
</TabPane>
<TabPane tab="实时监视" itemKey="monitor">
{/* keepDOM=false:切走即卸载播放器、断开全部预览连接 */}
<LiveMonitor />
</TabPane>
</Tabs>
<Modal
visible={visible}
onCancel={() => setVisible(false)}
+38
View File
@@ -93,6 +93,42 @@ export interface StudioEntity {
extra_fields?: string;
}
/** 正在录制的直播间能否在页面内预览(复用正在录制的那一路流,见 GET /v1/streamers/{id}/live) */
export interface LivePreviewInfo {
/** 当前下载器能否提供预览;为 true 时 format 仍可能是 null(刚开始拉流、容器未定) */
available: boolean;
/** 视频流的容器,与 /live 响应的 Content-Type 一致:flv / mpegts 走 mpegts.js,fmp4 直接 MediaSource */
format: 'flv' | 'mpegts' | 'fmp4' | null;
/** fmp4 时从 init segment 解出的 RFC 6381 编码串(如 avc1.64001f,mp4a.40.2),其它容器为 null */
codecs: string | null;
/** 不可预览的原因(ffmpeg / streamlink 子进程落盘、HEVC FLV 等) */
reason: string | null;
/** 这一路有没有实时弹幕(平台实现了弹幕客户端),有则 /v1/streamers/{id}/danmaku(SSE)可用 */
danmaku: boolean;
/** 浏览器能否直连 CDN 拉这一路(全局配置 preview_transport = direct 时用;不能则回落中转并显示原因) */
direct: DirectCapability;
}
export interface DirectCapability {
capable: boolean;
/** 不能直连的原因;capable 为 true 时为 null */
reason: string | null;
}
/** GET /v1/streamers/{id}/live-url:正在录制的那条流的 CDN 直链 */
export interface LiveUrlInfo {
url: string;
/** 浏览器该用哪个播放器:flv → mpegts.js,hls → hls.js(TS / fMP4 分片都行) */
format: 'flv' | 'hls' | null;
platform: string;
/** 过期时间估计(Unix 秒),直链里没有可识别的过期参数时为 null */
expires_at: number | null;
direct: DirectCapability;
}
/** 直播预览的取流方式(全局配置 preview_transport),空值视同 relay */
export type PreviewTransport = 'relay' | 'direct';
export interface LiveStreamerEntity {
id: number;
url: string;
@@ -111,6 +147,8 @@ export interface LiveStreamerEntity {
live_cover_url?: string | null;
/** 录制中的主播头像地址;图片请走 /v1/streamers/{id}/avatar 代理 */
live_avatar_url?: string | null;
/** 录制中的预览能力;未录制为 null */
preview?: LivePreviewInfo | null;
statusTag?: React.ReactNode;
format?: string;
time_range?: string | Date[];
+99
View File
@@ -0,0 +1,99 @@
'use client'
import { useEffect, useMemo } from 'react'
import { API_BASE } from './api-streamer'
/** SSE 里一条弹幕的形状,与后端 `DanmakuFrame` 一致 */
export interface DanmakuFrame {
/** 直播间 id:一条 SSE 可能复用给多个直播间 */
id: number
kind: 'danmaku' | 'gift' | 'super_chat' | 'guard_buy'
text: string
name: string | null
/** RGB 整数(白 16777215) */
color: number
/** Unix 毫秒 */
ts: number
}
const KINDS: DanmakuFrame['kind'][] = ['danmaku', 'gift', 'super_chat', 'guard_buy']
/**
* 若干直播间共用的一条实时弹幕连接。
*
* 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个,监视器同屏 N 路视频已经占了 N 个,
* 弹幕不能再每路一条——所以由页面持有一个 feed(`/v1/danmaku?ids=…`,一条 SSE),
* 各播放器按 id 订阅自己那一路。单路弹层同样走这里(ids 只有一个)。
*/
export class DanmakuFeed {
private source: EventSource | null = null
private active = false
private listeners = new Map<number, Set<(frame: DanmakuFrame) => void>>()
/** 构造不产生副作用;`open()` 之后、有订阅者时才真正连接(StrictMode 下重复构造无害) */
constructor(readonly ids: number[]) {}
/** 允许连接;已有订阅者就立刻连。 */
open() {
this.active = true
this.connectIfNeeded()
}
/** 断开并禁止再连;订阅者保留,`open()` 后可恢复。 */
close() {
this.active = false
this.source?.close()
this.source = null
}
/** 订阅某个直播间的弹幕;返回退订函数。 */
subscribe(id: number, listener: (frame: DanmakuFrame) => void): () => void {
let set = this.listeners.get(id)
if (!set) {
set = new Set()
this.listeners.set(id, set)
}
set.add(listener)
this.connectIfNeeded()
return () => {
set?.delete(listener)
}
}
private connectIfNeeded() {
if (!this.active || this.source || this.ids.length === 0 || typeof window === 'undefined') return
if (![...this.listeners.values()].some((set) => set.size > 0)) return
const source = new EventSource(`${API_BASE}/v1/danmaku?ids=${this.ids.join(',')}`)
const handle = (e: MessageEvent<string>) => {
let frame: DanmakuFrame
try {
frame = JSON.parse(e.data)
} catch {
return
}
if (!frame.text) return
this.listeners.get(frame.id)?.forEach((l) => l(frame))
}
for (const kind of KINDS) source.addEventListener(kind, handle as EventListener)
this.source = source
}
}
/** 同一组 id 的稳定键,供 effect 依赖:顺序无关。 */
export function feedKey(ids: number[]): string {
return [...ids].sort((a, b) => a - b).join(',')
}
/**
* 按需持有一个 feed:`enabled` 且 ids 非空时连接,ids 变化(监视器换路)时换一条,
* 关闭 / 卸载时断开。返回值给各播放器的 `danmaku.feed`。
*/
export function useDanmakuFeed(ids: number[], enabled: boolean): DanmakuFeed | null {
const key = enabled ? feedKey(ids) : ''
// 构造无副作用,连接在 effect 里 open() 之后才发生
const feed = useMemo(() => (key ? new DanmakuFeed(key.split(',').map(Number)) : null), [key])
useEffect(() => {
feed?.open()
return () => feed?.close()
}, [feed])
return feed
}
+66 -1
View File
@@ -1,7 +1,7 @@
'use client'
import { useSyncExternalStore } from 'react'
import useSWR from 'swr'
import { API_BASE, fetcher, LiveStreamerEntity, StreamerInfo, FileList } from './api-streamer'
import { API_BASE, fetcher, LiveStreamerEntity, StreamerInfo, FileList, LiveUrlInfo, PreviewTransport } from './api-streamer'
import { platformName } from './status'
/**
@@ -112,6 +112,71 @@ export function liveImageUrl(
return `${API_BASE}/v1/streamers/${id}/${kind}?v=${(h >>> 0).toString(36)}`
}
/** 正在录制的那一路流的同源地址(chunked FLV / MPEG-TS / fMP4),供页面内播放器直接拉取。 */
export function livePreviewUrl(id: number): string {
return `${API_BASE}/v1/streamers/${id}/live`
}
/** 浏览器直连模式:向后端要当前录制中那条流的 CDN 直链。 */
export function fetchLiveUrl(id: number): Promise<LiveUrlInfo> {
return fetcher(`/v1/streamers/${id}/live-url`, { cache: 'no-store' })
}
/**
* CDN 直链在页面里能不能直接用:https 页面拉 http 直链会被当作混合内容拦掉(抖音的直链就是 http),
* 实测这些 CDN 都支持 https,直接换协议。
*/
export function directPlayableUrl(url: string): string {
if (typeof window !== 'undefined' && window.location.protocol === 'https:' && url.startsWith('http://')) {
return 'https://' + url.slice('http://'.length)
}
return url
}
/**
* 全局配置里的预览取流方式。与空间配置页共用同一个 SWR key,改完保存这里立刻拿到;
* 配置还没加载到时按 relay 处理(默认值)。
*/
export function usePreviewTransport(): PreviewTransport {
const { data } = useSWR<{ preview_transport?: PreviewTransport | null }>('/v1/configuration', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
})
return data?.preview_transport === 'direct' ? 'direct' : 'relay'
}
/**
* 卡片 / 监视器能否起播:正在录制、下载器能旁路、容器已确定。
* 容器未定(刚开始拉流的前几秒)时按钮先禁用,下一次轮询拿到 format 再放开。
*/
export function canPreview(streamer: LiveStreamerEntity): streamer is LiveStreamerEntity & {
preview: { available: true; format: 'flv' | 'mpegts' | 'fmp4' }
} {
const format = streamer.preview?.format
return (
streamer.status === LIVE_STATUS &&
!!streamer.preview?.available &&
(format === 'flv' || format === 'mpegts' || format === 'fmp4')
)
}
/** 容器短名 → 界面标签 */
export function previewFormatLabel(format: 'flv' | 'mpegts' | 'fmp4' | null | undefined): string | null {
if (format === 'flv') return 'FLV'
if (format === 'mpegts') return 'TS'
if (format === 'fmp4') return 'fMP4'
return null
}
/** 预览按钮禁用时的提示文案;能预览时返回 null。 */
export function previewDisabledReason(streamer: LiveStreamerEntity): string | null {
if (streamer.status !== LIVE_STATUS) return '未在录制'
const preview = streamer.preview
if (!preview) return '预览信息尚未就绪'
if (!preview.available) return preview.reason || '当前下载器不支持预览'
if (!preview.format) return '正在建立预览,请稍候'
return null
}
const DAY = 86400
const EVENT_WINDOW = DAY // 事件流只展示最近 24h
+66
View File
@@ -0,0 +1,66 @@
'use client'
import { useSyncExternalStore } from 'react'
/**
* 记在 localStorage 里的界面偏好(同屏路数、弹幕开关等)。
* 用 useSyncExternalStore 而不是「useState + effect 里 setState」:服务端与首屏统一给默认值,
* 挂载后同步读到真实值,没有水合不一致,也不触发 react-hooks/set-state-in-effect。
*/
const listeners = new Set<() => void>()
function notify() {
listeners.forEach((l) => l())
}
function subscribe(listener: () => void) {
listeners.add(listener)
window.addEventListener('storage', listener)
return () => {
listeners.delete(listener)
window.removeEventListener('storage', listener)
}
}
export function readPref(key: string): string | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
export function writePref(key: string, value: string) {
try {
window.localStorage.setItem(key, value)
} catch {
/* 隐私模式等写不进去时就只在本次会话里生效 */
}
notify()
}
/** 布尔偏好。 */
export function useBoolPref(key: string, fallback: boolean): [boolean, (v: boolean) => void] {
const value = useSyncExternalStore(
subscribe,
() => {
const raw = readPref(key)
return raw === null ? fallback : raw === '1'
},
() => fallback
)
return [value, (v: boolean) => writePref(key, v ? '1' : '0')]
}
/** 只接受给定候选值的数字偏好。 */
export function useChoicePref(key: string, options: number[], fallback: number): [number, (v: number) => void] {
const value = useSyncExternalStore(
subscribe,
() => {
const n = Number(readPref(key))
return options.includes(n) ? n : fallback
},
() => fallback
)
return [value, (v: number) => writePref(key, String(v))]
}
+270
View File
@@ -0,0 +1,270 @@
'use client'
import React, { useState } from 'react'
import useSWR from 'swr'
import { Button, Empty, Select, Switch, Tag, Tooltip, Typography } from '@douyinfe/semi-ui'
import { IconPlay, IconStop } from '@douyinfe/semi-icons'
import { fetcher, LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer'
import { LIVE_STATUS, platformName } from '@/app/lib/status'
import {
canPreview,
formatRate,
liveImageUrl,
previewDisabledReason,
previewFormatLabel,
SLOW_REFRESH_MS,
STREAMERS_REFRESH_MS,
} from '@/app/lib/use-dashboard'
import { useBoolPref, useChoicePref } from '@/app/lib/use-local-pref'
import { useDanmakuFeed } from '@/app/lib/danmaku-feed'
import { LivePreviewPlayer } from './LivePreview'
import styles from './live-monitor.module.scss'
/**
* 监视器默认同屏路数。每路小窗占用对应直播间的 1 个预览连接
* (服务端每间上限见 `PREVIEW_MAX_SUBSCRIBERS_PER_ROOM`,进程上限 16),改这里即可换默认值。
*/
export const DEFAULT_MONITOR_TILES = 4
/**
* 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个:每路小窗占 1 个长连接,弹幕复用 1 个,
* 列表轮询还要留 1 个,所以 HTTP/1.1 下最多 4 路;页面走 HTTP/2、HTTP/3(TLS 反代)时不受此限,
* 放开 6 / 9 路。
*/
export const MONITOR_TILE_OPTIONS_H1 = [1, 2, 4]
export const MONITOR_TILE_OPTIONS_H2 = [1, 2, 4, 6, 9]
function tileOptions(): number[] {
if (typeof window === 'undefined') return MONITOR_TILE_OPTIONS_H1
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
const proto = nav?.nextHopProtocol ?? ''
return proto === 'h2' || proto === 'h3' ? MONITOR_TILE_OPTIONS_H2 : MONITOR_TILE_OPTIONS_H1
}
const TILES_KEY = 'biliup.monitor.maxTiles'
/** 多路同屏默认不开弹幕(每路一条 SSE、多路叠加太吵),开关记在本地 */
const DANMAKU_KEY = 'biliup.monitor.danmaku'
/** 用户的选择:手动激活的顺序(先进先出)与手动停掉的房间;其余由数据推导。 */
interface Selection {
order: number[]
stopped: number[]
}
/**
* 由「用户选择 + 当前可预览房间 + 路数上限」推导正在播放的房间列表(纯函数,无副作用):
* 先保留用户激活且仍可预览的,再按列表顺序自动补满,手动停掉的不自动补。
*/
export function resolveActive(
selection: Selection,
previewableIds: number[],
maxTiles: number
): number[] {
const previewable = new Set(previewableIds)
const stopped = new Set(selection.stopped)
const active: number[] = []
for (const id of selection.order) {
if (previewable.has(id) && !active.includes(id)) active.push(id)
}
for (const id of previewableIds) {
if (active.length >= maxTiles) break
if (!active.includes(id) && !stopped.has(id)) active.push(id)
}
return active.slice(Math.max(0, active.length - maxTiles))
}
/**
* 实时监视:所有正在录制的直播间的小窗网格。
* 可预览且在路数上限内的直接播放(默认静音);超出的显示封面,点击替换最早开始的一路;
* 下载器不能旁路的显示封面与原因。独立组件,可整体挪到别的页面。
*/
export default function LiveMonitor() {
const { Text } = Typography
const { data: streamers, error, isLoading } = useSWR<LiveStreamerEntity[]>(
'/v1/streamers',
fetcher,
{ refreshInterval: STREAMERS_REFRESH_MS }
)
const { data: infos } = useSWR<StreamerInfo[]>('/v1/streamer-info', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
})
const options = tileOptions()
const [maxTiles, setMaxTiles] = useChoicePref(TILES_KEY, options, DEFAULT_MONITOR_TILES)
const [danmaku, setDanmaku] = useBoolPref(DANMAKU_KEY, false)
const [selection, setSelection] = useState<Selection>({ order: [], stopped: [] })
const live = (streamers ?? []).filter((s) => s.status === LIVE_STATUS)
const previewable = live.filter(canPreview)
const previewableIds = previewable.map((s) => s.id)
const active = resolveActive(selection, previewableIds, maxTiles)
const activeSet = new Set(active)
// 正在播且平台有弹幕客户端的那几路共用一条弹幕连接
const danmakuIds = previewable.filter((s) => activeSet.has(s.id) && s.preview?.danmaku).map((s) => s.id)
const danmakuFeed = useDanmakuFeed(danmakuIds, danmaku)
const infoByUrl = new Map<string, StreamerInfo>()
for (const i of infos ?? []) {
const cur = infoByUrl.get(i.url)
if (!cur || i.date > cur.date) infoByUrl.set(i.url, i)
}
// 激活:满了就替换最早开始的一路(先进先出)
const activate = (id: number) => {
setSelection((prev) => {
const current = resolveActive(prev, previewableIds, maxTiles)
const kept = current.length >= maxTiles ? current.slice(1) : current
return {
order: [...kept.filter((x) => x !== id), id],
stopped: prev.stopped.filter((x) => x !== id),
}
})
}
const stop = (id: number) => {
setSelection((prev) => ({
order: prev.order.filter((x) => x !== id),
stopped: prev.stopped.includes(id) ? prev.stopped : [...prev.stopped, id],
}))
}
if (error) {
return (
<div className={styles.center}>
<Empty title="加载失败" description="无法获取主播列表,请检查后端连接" />
</div>
)
}
if (!isLoading && live.length === 0) {
return (
<div className={styles.center}>
<Empty
title="当前没有正在录制的直播间"
description="开播并开始录制后,这里会以小窗形式同屏显示各路画面(stream-gears / mesio 下载器)"
/>
</div>
)
}
const full = active.length >= maxTiles
return (
<section className={styles.monitor}>
<div className={styles.toolbar}>
<div className={styles.summary}>
<Text strong>
正在监视 {active.length} / {maxTiles} 路
</Text>
<Text type="tertiary" size="small">
录制中 {live.length} 间 · 可预览 {previewable.length} 间
</Text>
</div>
<div className={styles.controls}>
<Tooltip content="所有小窗一起开关;只有平台实现了弹幕客户端的房间会显示">
<span className={styles.switchRow}>
<Text type="tertiary" size="small">
弹幕
</Text>
<Switch size="small" checked={danmaku} onChange={(v) => setDanmaku(!!v)} aria-label="弹幕" />
</span>
</Tooltip>
<Text type="tertiary" size="small">
同屏路数
</Text>
<Select
size="small"
value={maxTiles}
onChange={(v) => setMaxTiles(Number(v))}
optionList={options.map((n) => ({ value: n, label: `${n} 路` }))}
style={{ width: 88 }}
aria-label="同屏路数"
/>
</div>
</div>
<Text type="tertiary" size="small" className={styles.hint}>
画面来自各直播间正在写盘的同一路流,默认静音;每路小窗占用该直播间的 1 个预览连接。
超出路数的直播间显示封面,点击可替换最早开始的一路。
</Text>
<div className={styles.grid}>
{live.map((s) => {
const info = infoByUrl.get(s.url)
const playing = activeSet.has(s.id)
const can = canPreview(s)
const reason = can ? null : previewDisabledReason(s)
const cover = liveImageUrl(s.id, 'cover', s.live_cover_url)
const rate = formatRate(s.live_bytes_per_sec)
const name = s.remark || s.url
return (
<article
key={s.id}
className={`${styles.tile} ${playing ? styles.tilePlaying : ''}`}
data-id={s.id}
data-state={playing ? 'playing' : can ? 'idle' : 'unavailable'}
>
<header className={styles.tileHead}>
<span className={styles.tileName} title={name}>
{name}
</span>
<Tag size="small" color="grey">
{platformName(s.url)}
</Tag>
{previewFormatLabel(s.preview?.format) ? (
<Tag size="small" color="green">
{previewFormatLabel(s.preview?.format)}
</Tag>
) : null}
<span className={styles.tileRate}>{rate ?? '—'}</span>
{playing ? (
<Tooltip content="停止这一路">
<Button
size="small"
theme="borderless"
type="tertiary"
icon={<IconStop />}
aria-label={`停止 ${name}`}
onClick={() => stop(s.id)}
/>
</Tooltip>
) : null}
</header>
{playing ? (
<LivePreviewPlayer streamer={s} muted compact danmakuFeed={danmaku ? danmakuFeed : null} />
) : (
<button
type="button"
className={styles.idle}
disabled={!can}
onClick={() => activate(s.id)}
aria-label={
can ? (full ? `播放 ${name}(替换最早的一路)` : `播放 ${name}`) : `${name}:${reason}`
}
>
{cover ? (
// 封面走同源代理;失败时隐藏,只剩底色,说明文字仍在
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.idleCover}
src={cover}
alt=""
aria-hidden="true"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<span className={styles.idleText}>
{can ? (
<>
<IconPlay />
{full ? '点击替换最早的一路' : '点击开始预览'}
</>
) : (
reason
)}
</span>
</button>
)}
<footer className={styles.tileFoot} title={info?.title || ''}>
{info?.title || '\u00a0'}
</footer>
</article>
)
})}
</div>
</section>
)
}
+462
View File
@@ -0,0 +1,462 @@
'use client'
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 type { ButtonProps } from '@douyinfe/semi-ui/lib/es/button'
import { LiveStreamerEntity } from '@/app/lib/api-streamer'
import { platformName } from '@/app/lib/status'
import {
canPreview,
directPlayableUrl,
fetchLiveUrl,
formatRate,
liveImageUrl,
livePreviewUrl,
previewDisabledReason,
previewFormatLabel,
usePreviewTransport,
} from '@/app/lib/use-dashboard'
import { useBoolPref } from '@/app/lib/use-local-pref'
import { type DanmakuFeed, useDanmakuFeed } from '@/app/lib/danmaku-feed'
import styles from './live-preview.module.scss'
const Players = dynamic(() => import('@/app/ui/Player'), { ssr: false })
/** 服务端断开(掉队 / 换直链)后自动重连的次数上限;超过后交给用户手动重试 */
const MAX_AUTO_RECONNECT = 3
const RECONNECT_DELAY_MS = 2000
/** 弹层弹幕开关记在本地,默认开;监视器另有自己的开关(默认关) */
const MODAL_DANMAKU_KEY = 'biliup.preview.danmaku'
type Phase = 'connecting' | 'playing' | 'reconnecting' | 'ended' | 'error'
/** 实际在用的取流方式与角标文案 */
type Source =
| { kind: 'relay'; fallbackReason: null }
| { kind: 'relay'; fallbackReason: string }
| {
kind: 'direct'
url: string
format: 'flv' | 'hls' | null
/** 这条直链是不是失败后重取来的(立刻又失败就不再试直连) */
refetched: boolean
/** 开始用这条直链的时刻:播够一阵再断多半是直链到期(斗鱼 5 min、B 站 1 h),可以再取 */
startedAt: number
}
/**
* 直播预览播放区:复用正在录制的那一路流(`/v1/streamers/{id}/live`)。
*
* 播放器(Artplayer + mpegts.js)挂在 `key={nonce}` 上,重连即换 key 重建;
* 组件卸载时播放器销毁、fetch 中止,服务端随之释放该连接的槽位。
* 既用于卡片弹层,也用于历史记录页的监视器小窗。
*/
export function LivePreviewPlayer({
streamer,
muted = false,
compact = false,
danmakuFeed = null,
onFatal,
}: {
streamer: LiveStreamerEntity
muted?: boolean
/** 监视器小窗:状态文字更简短 */
compact?: boolean
/** 页面持有的实时弹幕连接;null 表示弹幕关。仅平台有弹幕客户端时装弹幕层 */
danmakuFeed?: DanmakuFeed | null
/** 自动重连耗尽或不可恢复错误(415 / 429 / 解码失败)时通知父组件 */
onFatal?: (message: string) => void
}) {
const [nonce, setNonce] = useState(0)
const [phase, setPhase] = useState<Phase>('connecting')
const [message, setMessage] = useState<string | null>(null)
const attemptsRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const transport = usePreviewTransport()
const directCapable = !!streamer.preview?.direct?.capable
const directReason = streamer.preview?.direct?.reason ?? null
// direct 模式且平台能直连:先向后端要直链;否则走中转,direct 模式下把原因挂在角标上。
// 取流方式随(房间, 配置, 能力)变化而重算:异步结果与失败回落都带着 key,key 不匹配的一律忽略,
// 这样切换时不需要在 effect 里同步重置 state。
const wantDirect = transport === 'direct' && directCapable
const sourceKey = `${streamer.id}:${transport}:${directCapable}`
const [fetched, setFetched] = useState<{ key: string; source: Source } | null>(null)
const [override, setOverride] = useState<{ key: string; source: Source } | null>(null)
useEffect(() => {
if (!wantDirect) return
let cancelled = false
fetchLiveUrl(streamer.id).then(
(info) => {
if (cancelled) return
const source: Source =
info.direct.capable && info.url
? { kind: 'direct', url: directPlayableUrl(info.url), format: info.format, refetched: false, startedAt: Date.now() }
: { kind: 'relay', fallbackReason: info.direct.reason ?? '该平台不支持直连' }
setFetched({ key: sourceKey, source })
},
(e: unknown) => {
if (cancelled) return
setFetched({
key: sourceKey,
source: { kind: 'relay', fallbackReason: `获取直链失败:${e instanceof Error ? e.message : String(e)}` },
})
}
)
return () => {
cancelled = true
}
}, [wantDirect, sourceKey, streamer.id])
const source = useMemo<Source | null>(() => {
if (override?.key === sourceKey) return override.source
if (!wantDirect) {
return { kind: 'relay', fallbackReason: transport === 'direct' ? (directReason ?? '该平台不支持直连') : null }
}
return fetched?.key === sourceKey ? fetched.source : null
}, [override, fetched, sourceKey, wantDirect, transport, directReason])
const relayFormat = streamer.preview?.format ?? undefined
const codecs = streamer.preview?.codecs ?? null
const relayUrl = livePreviewUrl(streamer.id)
const url = source?.kind === 'direct' ? source.url : relayUrl
const format = source?.kind === 'direct' ? (source.format ?? 'flv') : relayFormat
// 平台有弹幕客户端才装弹幕层;开关只控制订阅与显示
const danmakuLayer = streamer.preview?.danmaku
? { id: streamer.id, feed: danmakuFeed, fontSize: compact ? 15 : 22 }
: undefined
// 出错 / 断开时把封面垫在说明文字后面,而不是一块黑
const cover = liveImageUrl(streamer.id, 'cover', streamer.live_cover_url)
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
},
[]
)
const scheduleReconnect = useCallback(
(why: string) => {
if (attemptsRef.current >= MAX_AUTO_RECONNECT) {
setPhase('ended')
setMessage(`${why},已重连 ${MAX_AUTO_RECONNECT} 次`)
onFatal?.(why)
return
}
attemptsRef.current += 1
setPhase('reconnecting')
setMessage(`${why},${RECONNECT_DELAY_MS / 1000} 秒后重连(${attemptsRef.current}/${MAX_AUTO_RECONNECT})`)
timerRef.current = setTimeout(() => {
setPhase('connecting')
setMessage(null)
setNonce((n) => n + 1)
}, RECONNECT_DELAY_MS)
},
[onFatal]
)
// 直连出问题(CDN 403 / 直链过期 / 断开):重取一次直链再播;再失败就回落中转,不再试直连
const handleDirectFailure = useCallback(
(why: string) => {
const fallback = (reason: string) => {
setOverride({ key: sourceKey, source: { kind: 'relay', fallbackReason: reason } })
attemptsRef.current = 0
setPhase('connecting')
setMessage(null)
setNonce((n) => n + 1)
}
// 播够 60 s 再断多半是直链到期(斗鱼 token 5 min、B 站 1 h),照常再取一条;
// 刚起播就失败才算「直连不行」——重取一次后仍失败就回落中转
const playedAWhile = source?.kind === 'direct' && Date.now() - source.startedAt > 60_000
if (source?.kind !== 'direct' || (source.refetched && !playedAWhile)) {
fallback(`直连失败:${why}`)
return
}
setPhase('reconnecting')
setMessage('直连中断,重新获取直链…')
fetchLiveUrl(streamer.id).then(
(info) => {
if (info.direct.capable && info.url) {
setOverride({
key: sourceKey,
source: {
kind: 'direct',
url: directPlayableUrl(info.url),
format: info.format,
refetched: true,
startedAt: Date.now(),
},
})
setPhase('connecting')
setMessage(null)
setNonce((n) => n + 1)
} else {
fallback(info.direct.reason ?? `直连失败:${why}`)
}
},
() => fallback(`直连失败:${why},且重取直链失败`)
)
},
[source, sourceKey, streamer.id]
)
const handleEnded = useCallback(() => {
if (source?.kind === 'direct') handleDirectFailure('连接已断开')
else scheduleReconnect('连接已断开')
}, [source, handleDirectFailure, scheduleReconnect])
const handleError = useCallback(
(text: string) => {
if (source?.kind === 'direct') {
handleDirectFailure(text)
return
}
// 415(不支持)/ 429(超限)/ 解码 / 浏览器不支持的编码不会因重试而变化;503 与网络抖动可以再试
const recoverable = /重连|503|网络|连接失败/.test(text) && !/不支持|拒绝|无法起播/.test(text)
if (recoverable) {
scheduleReconnect(text)
return
}
setPhase('error')
setMessage(text)
onFatal?.(text)
},
[source, handleDirectFailure, scheduleReconnect, onFatal]
)
const retry = () => {
attemptsRef.current = 0
setPhase('connecting')
setMessage(null)
setNonce((n) => n + 1)
}
// 播放器一旦起播就当作在放(mpegts.js 没有可靠的 first-frame 事件;错误会另行回调)
useEffect(() => {
if (phase !== 'connecting') return
const t = setTimeout(() => setPhase((p) => (p === 'connecting' ? 'playing' : p)), 1500)
return () => clearTimeout(t)
}, [phase, nonce])
const pending = source === null
const showPlayer = (phase === 'connecting' || phase === 'playing') && !pending
const badge =
source?.kind === 'direct'
? { text: '直连 CDN', tone: 'direct' as const }
: source?.kind === 'relay' && source.fallbackReason
? { text: `已回落中转:${source.fallbackReason}`, tone: 'fallback' as const }
: null
return (
<div className={styles.player} data-phase={phase} data-source={source?.kind ?? 'pending'}>
{showPlayer ? (
<Players
key={`${source?.kind}-${url}-${nonce}`}
url={url}
type={format}
codecs={codecs}
isLive
muted={muted}
autoplay
onEnded={handleEnded}
onError={handleError}
danmaku={danmakuLayer}
/>
) : null}
{badge ? (
<Tooltip
content={
badge.tone === 'direct'
? '浏览器用另取的直链直接向 CDN 拉流,不经 biliup 中转、不影响录制(全局配置 preview_transport = direct)'
: badge.text
}
>
<span className={styles.badge} data-tone={badge.tone} data-compact={compact || undefined}>
{badge.text}
</span>
</Tooltip>
) : null}
{phase !== 'playing' || pending ? (
<div className={styles.overlay}>
{cover && (phase === 'error' || phase === 'ended') ? (
// 封面走同源代理;加载失败只剩底色
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.overlayCover}
src={cover}
alt=""
aria-hidden="true"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<div className={styles.overlayText}>
{pending
? '正在获取直链…'
: phase === 'connecting'
? source.kind === 'direct'
? '正在连接 CDN…'
: '正在连接录制流…'
: message}
</div>
{phase === 'ended' || phase === 'error' ? (
<Button
size={compact ? 'small' : 'default'}
icon={<IconRefresh />}
theme="light"
onClick={retry}
>
重试
</Button>
) : null}
</div>
) : null}
</div>
)
}
/** 卡片里的「预览」弹层:关闭即卸载播放器、断开连接。 */
export function LivePreviewModal({
streamer,
visible,
onClose,
}: {
streamer: LiveStreamerEntity
visible: boolean
onClose: () => void
}) {
const { Text } = Typography
const name = streamer.remark || streamer.url
const rate = formatRate(streamer.live_bytes_per_sec)
const format = streamer.preview?.format
const danmakuAvailable = !!streamer.preview?.danmaku
const [danmakuPref, setDanmakuPref] = useBoolPref(MODAL_DANMAKU_KEY, true)
const danmakuOn = visible && danmakuAvailable && danmakuPref
const danmakuFeed = useDanmakuFeed([streamer.id], danmakuOn)
const transport = usePreviewTransport()
const notifiedRef = useRef(false)
useEffect(() => {
if (!visible) notifiedRef.current = false
}, [visible])
const handleFatal = useCallback(
(text: string) => {
if (notifiedRef.current) return
notifiedRef.current = true
Toast.error({ content: `预览中断:${text}`, duration: 4 })
},
[]
)
return (
<Modal
visible={visible}
onCancel={onClose}
closeOnEsc
footer={null}
className={styles.modal}
style={{ width: 'min(960px, 94vw)' }}
bodyStyle={{ padding: 0 }}
title={
<div className={styles.modalTitle}>
<span className={styles.modalName} title={name}>
{name}
</span>
<Tag size="small" color="green">
{platformName(streamer.url)}
</Tag>
{previewFormatLabel(format) ? (
<Tag size="small" color="grey">
{previewFormatLabel(format)}
</Tag>
) : null}
<Text type="tertiary" size="small">
{rate ? `写盘 ${rate}` : '写盘速率 —'}
</Text>
<Tooltip
content={
danmakuAvailable
? '显示录制中的实时弹幕(来自本进程的弹幕客户端)'
: '这一路没有弹幕客户端:平台不支持,或未开启对应的 *_danmaku 配置(B 站 / 抖音 / 斗鱼 / 虎牙可开)'
}
>
<span className={styles.danmakuSwitch}>
<Text type="tertiary" size="small">
弹幕
</Text>
<Switch
size="small"
checked={danmakuAvailable && danmakuPref}
disabled={!danmakuAvailable}
onChange={(v) => setDanmakuPref(!!v)}
aria-label="弹幕"
/>
</span>
</Tooltip>
</div>
}
>
{/* 弹层关闭即卸载:不留后台连接 */}
{visible ? (
<LivePreviewPlayer streamer={streamer} danmakuFeed={danmakuOn ? danmakuFeed : null} onFatal={handleFatal} />
) : null}
<div className={styles.modalFoot}>
<Text type="tertiary" size="small">
{transport === 'direct'
? '直连模式:能直连的平台由浏览器直接向 CDN 拉流,不经 biliup;不能的自动回落到录制流中转(角标标出原因)。关闭弹窗即断开。'
: '画面来自正在写盘的同一路流,不另外向直播平台拉流;关闭弹窗即断开。'}
</Text>
</div>
</Modal>
)
}
/**
* 「预览」按钮 + 弹层。录制中且下载器能旁路时可点;否则禁用并用 tooltip 说明原因
* (ffmpeg / streamlink 子进程落盘、fMP4、HEVC、容器尚未确定)。
*/
export function LivePreviewButton({
streamer,
size = 'small',
theme = 'light',
iconOnly = false,
className,
}: {
streamer: LiveStreamerEntity
size?: ButtonProps['size']
theme?: ButtonProps['theme']
iconOnly?: boolean
className?: string
}) {
const [open, setOpen] = useState(false)
const enabled = canPreview(streamer)
const reason = previewDisabledReason(streamer)
const button = (
<Button
size={size}
theme={theme}
type={enabled ? 'primary' : 'tertiary'}
icon={<IconPlay />}
disabled={!enabled}
className={className}
aria-label={iconOnly ? '预览' : undefined}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
{iconOnly ? null : '预览'}
</Button>
)
return (
<>
{enabled ? (
button
) : (
<Tooltip content={reason ?? '暂不可预览'}>
{/* disabled 按钮不触发鼠标事件,包一层让 tooltip 仍能弹出 */}
<span className={styles.disabledWrap}>{button}</span>
</Tooltip>
)}
{open ? (
<LivePreviewModal streamer={streamer} visible={open} onClose={() => setOpen(false)} />
) : null}
</>
)
}
+671 -52
View File
@@ -1,92 +1,711 @@
import React, { useEffect, useRef } from 'react'
import Artplayer from 'artplayer'
import artplayerPluginDanmuku from 'artplayer-plugin-danmuku'
import mpegts from 'mpegts.js'
import type { DanmakuFeed, DanmakuFrame } from '@/app/lib/danmaku-feed'
type VideoPlayer = Artplayer | null
/** mpegts.js 能直接解封装的两种容器;其它扩展名交给浏览器原生 <video> */
export type MpegtsType = 'flv' | 'mpegts'
/**
* 直播预览支持的容器:flv / mpegts 走 mpegts.js,fmp4 直接 MediaSource 追加,
* hls(m3u8,浏览器直连 CDN 时的 HLS 直链)走 hls.js(按需加载,只在用到时下载那 ~120 KB)
*/
export type LiveType = MpegtsType | 'fmp4' | 'hls'
interface PlayerConfig {
url: string
height?: string
width?: string
/**
* 容器类型。不传时按 url 扩展名判断(`.flv` → flv,其余原生)。
* 直播预览的地址没有扩展名,由调用方按后端给的 `preview.format`(与响应 Content-Type 一致)传入;
* 直播且未传时,先用一次 GET 探测响应的 Content-Type 再起播。
*/
type?: LiveType
/** fmp4 的 RFC 6381 编码串(后端从 init segment 解出,如 `avc1.64001f,mp4a.40.2`) */
codecs?: string | null
/** 直播:隐藏进度条、开启追帧,播放器随分块到达持续解码 */
isLive?: boolean
muted?: boolean
autoplay?: boolean
/** 直播流结束 / 被服务端断开(如客户端掉队、录制换直链)时回调,调用方决定是否重连 */
onEnded?: () => void
/** mpegts.js 或探测阶段报错(网络 / 解码),附一句可展示的说明 */
onError?: (message: string) => void
/**
* 实时弹幕:`feed` 是页面持有的共享连接(见 `DanmakuFeed`),`id` 是本路直播间;
* `feed` 为 null 时弹幕层隐藏。传了这个 prop 就装 artplayer-plugin-danmuku,开关切换只
* 订阅 / 退订与显示 / 隐藏,不重建播放器。
*/
danmaku?: { id: number; feed: DanmakuFeed | null; fontSize?: number }
}
function playFlv(video: HTMLVideoElement, url: string, art: Artplayer) {
if (mpegts.isSupported()) {
const artWithFlv = art as Artplayer & { flv?: mpegts.Player | null }
if (artWithFlv.flv) {
artWithFlv.flv.destroy()
artWithFlv.flv = null
}
type DanmukuPlugin = ReturnType<ReturnType<typeof artplayerPluginDanmuku>>
const flv = mpegts.createPlayer({
type: 'flv',
url: url,
/** 把后端的 RGB 整数转成 CSS 颜色 */
function rgbInt(color: number): string {
return `#${(color & 0xffffff).toString(16).padStart(6, '0')}`
}
/**
* mpegts.js 的直播拉流 loader:只做一件事——`abort()` 时**一定**中止 fetch。
*
* 自带的 FetchStreamLoader 在 Chrome 上处于 buffering 状态时不调 `AbortController.abort()`,
* 而是等下一个 `read()` 回来再 `reader.cancel()`;解码出错后读取链已经断了,那一次永远等不到,
* 拉流连接就留在服务端占着该路的预览许可直到刷新页面(真实 Twitch TS 房间连开 4 次就 429)。
* 这里每次 `open()` 建一个 AbortController,`abort()` 直接调它,其余行为与原 loader 一致
* (直播不需要 Range,`needStash` 同样为 true)。
*/
class AbortableFetchLoader extends mpegts.BaseLoader {
private controller: AbortController | null = null
private received = 0
_needStash = true
constructor(_seekHandler: unknown, _config: unknown) {
super('abortable-fetch-loader')
}
static isSupported() {
return typeof self.fetch === 'function' && typeof self.ReadableStream === 'function'
}
destroy() {
if (this.isWorking()) this.abort()
super.destroy()
}
open(dataSource: { url: string; withCredentials?: boolean }, range: { from: number; to: number }) {
const controller = new AbortController()
this.controller = controller
this.received = 0
this._status = mpegts.LoaderStatus.kConnecting
fetch(dataSource.url, {
method: 'GET',
mode: 'cors',
cache: 'no-store',
credentials: dataSource.withCredentials ? 'include' : 'same-origin',
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok || !res.body) {
this._status = mpegts.LoaderStatus.kError
// 类型声明里 onError 的第一个参数是 LoaderErrors 接口本身,实际是其中的字符串值
this.onError?.(mpegts.LoaderErrors.HTTP_STATUS_CODE_INVALID as unknown as mpegts.LoaderErrors, {
code: res.status,
msg: res.statusText,
})
return
}
const reader = res.body.getReader()
for (;;) {
const { done, value } = await reader.read()
if (controller.signal.aborted) return
if (done) {
this._status = mpegts.LoaderStatus.kComplete
this.onComplete?.(range.from, range.from + this.received - 1)
return
}
this._status = mpegts.LoaderStatus.kBuffering
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer
const byteStart = range.from + this.received
this.received += chunk.byteLength
this.onDataArrival?.(chunk, byteStart, this.received)
}
})
.catch((e: Error & { code?: number }) => {
if (controller.signal.aborted) return
this._status = mpegts.LoaderStatus.kError
// 服务端结束响应(掉队断开 / 换直链)走 EARLY_EOF,mpegts.js 会把 MSE 收尾成 ended
const earlyEof = e.name === 'TypeError' || e.code === 19
const kind = earlyEof ? mpegts.LoaderErrors.EARLY_EOF : mpegts.LoaderErrors.EXCEPTION
this.onError?.(kind as unknown as mpegts.LoaderErrors, { code: e.code ?? -1, msg: e.message })
})
}
artWithFlv.flv = flv
art.on('destroy', () => {
if (artWithFlv.flv) {
artWithFlv.flv.destroy()
artWithFlv.flv = null
}
})
flv.attachMediaElement(video)
flv.load()
} else {
art.notice.show = 'Unsupported playback format: flv'
abort() {
this._status = mpegts.LoaderStatus.kComplete
this.controller?.abort()
this.controller = null
}
}
const Players: React.FC<PlayerConfig> = ({ url, height = '100%', width = '100%' }) => {
/** 销毁 mpegts.js 实例;`destroy()` 内部清 SourceBuffer 在元素出错后可能抛,拉流已由 loader 的 abort 保证断开。 */
function destroyMpegts(player: mpegts.Player) {
try {
player.destroy()
} catch (e) {
console.warn('[Player] mpegts.destroy 抛出异常(拉流已断开)', e)
}
}
/** 一条弹幕送进弹幕层:普通弹幕滚动,礼物 / 醒目留言 / 上舰置顶并描边。 */
function emitFrame(plugin: DanmukuPlugin, frame: DanmakuFrame) {
plugin.emit({
text: frame.text,
color: rgbInt(frame.color),
mode: frame.kind === 'danmaku' ? 0 : 1,
border: frame.kind !== 'danmaku',
})
}
/** 按响应头判断直播流容器:`video/x-flv` → flv,`video/mp2t` → mpegts,`video/mp4` → fmp4。 */
export function liveTypeFromContentType(contentType: string | null | undefined): LiveType | null {
const ct = (contentType ?? '').toLowerCase()
if (ct.includes('flv')) return 'flv'
if (ct.includes('mp2t') || ct.includes('mpegts') || ct.includes('mpeg2-ts')) return 'mpegts'
if (ct.includes('mp4')) return 'fmp4'
return null
}
/**
* 用一次 GET 读到响应头就中止,只为拿 Content-Type。
* 非 2xx 时把服务端的说明(415 / 429 / 503 的正文)原样抛出。
*/
async function probeLiveType(url: string): Promise<LiveType> {
const controller = new AbortController()
try {
const res = await fetch(url, { signal: controller.signal, cache: 'no-store' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const type = liveTypeFromContentType(res.headers.get('content-type'))
if (!type) throw new Error(`无法识别的流类型: ${res.headers.get('content-type') ?? '未知'}`)
return type
} finally {
controller.abort()
}
}
/** fmp4 预览要求的 MIME;`codecs` 为空时返回 null(Chrome 的 addSourceBuffer 必须带 codecs) */
export function fmp4MimeType(codecs: string | null | undefined): string | null {
const c = (codecs ?? '').trim()
return c ? `video/mp4; codecs="${c}"` : null
}
/** 当前浏览器能否用 MSE 播这组 fmp4 编码;SSR / 无 MediaSource 环境返回 false */
export function canPlayFmp4(codecs: string | null | undefined): boolean {
const mime = fmp4MimeType(codecs)
if (!mime || typeof window === 'undefined' || typeof MediaSource === 'undefined') return false
try {
return MediaSource.isTypeSupported(mime)
} catch {
return false
}
}
/** 直播追帧:缓冲末尾落后超过这么多秒就跳到末尾附近 */
const FMP4_MAX_LATENCY_S = 3
const FMP4_TARGET_REMAIN_S = 0.5
/** 早于当前播放位置这么多秒的缓冲定期清掉,长时间观看不涨内存 */
const FMP4_KEEP_BACKWARD_S = 30
interface Fmp4Options {
codecs: string | null | undefined
autoplay: boolean
onEnded?: () => void
onError?: (message: string) => void
}
interface HlsOptions {
autoplay: boolean
onEnded?: () => void
onError?: (message: string) => void
}
type HlsInstance = import('hls.js').default
/**
* Artplayer customType:HLS 直链(浏览器直连 CDN 的 TS / fMP4 分片流)交给 hls.js。
* 只在直连模式下会走到这里,所以 hls.js 用动态 import,不进首屏包。
* 直播参数:跟到最后 3 个分片、后向缓冲 30 s;分片请求不带凭据(CDN 的 ACAO 是 *)。
* 致命错误(清单 / 分片 404、CORS、解码)报给上层,由 LivePreviewPlayer 重取直链或回落中转。
*/
async function playWithHls(video: HTMLVideoElement, url: string, art: Artplayer, { autoplay, onEnded, onError }: HlsOptions) {
if (art.isDestroy) return
const artWithHls = art as Artplayer & { hls?: HlsInstance | null }
artWithHls.hls?.destroy()
artWithHls.hls = null
const { default: Hls } = await import('hls.js')
if (art.isDestroy) return
if (!Hls.isSupported()) {
// Safari 等原生支持 HLS 的浏览器直接交给 <video>
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
if (autoplay) video.play().catch(() => {})
return
}
onError?.('当前浏览器不支持 MSE,无法播放 HLS')
return
}
const hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
backBufferLength: 30,
maxBufferLength: 30,
enableWorker: true,
xhrSetup: (xhr) => {
xhr.withCredentials = false
},
})
artWithHls.hls = hls
art.on('destroy', () => {
if (artWithHls.hls) {
artWithHls.hls.destroy()
artWithHls.hls = null
}
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return
const status = data.response?.code
const detail =
data.type === Hls.ErrorTypes.NETWORK_ERROR
? status
? `连接失败(HTTP ${status})`
: `网络错误:${data.details}`
: data.type === Hls.ErrorTypes.MEDIA_ERROR
? `浏览器解码失败:${data.details}`
: `播放失败:${data.details}`
onError?.(detail)
})
// 直播清单不再更新(主播下播 / 直链失效但清单还在)→ 当作断开让上层处理
hls.on(Hls.Events.LEVEL_UPDATED, (_event, data) => {
if (data.details.live === false) onEnded?.()
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoplay) {
video.play().catch(() => {
if (art.isDestroy || artWithHls.hls !== hls) return
video.muted = true
video.play().catch(() => {})
})
}
})
hls.loadSource(url)
hls.attachMedia(video)
}
/**
* Artplayer customType:fMP4 直接交给 MediaSource。
* 服务端先发 init segment(ftyp + moov)再按 moof + mdat 分片广播,正是 MSE 的原生输入,不需要转封装:
* fetch 的 ReadableStream 按到达顺序进 appendBuffer 队列(`updateend` 串行),
* 首个缓冲区出现时把播放位置对齐到缓冲起点,之后落后超过阈值就追到末尾,并定期 remove 旧缓冲。
*/
function playWithMediaSource(
video: HTMLVideoElement,
url: string,
art: Artplayer,
{ codecs, autoplay, onEnded, onError }: Fmp4Options
) {
// 同 playWithMpegts:Artplayer 出错后的自动重连会在实例销毁后仍重设 url,忽略
if (art.isDestroy) return
const artWithMse = art as Artplayer & { fmp4Dispose?: (() => void) | null }
artWithMse.fmp4Dispose?.()
artWithMse.fmp4Dispose = null
const mime = fmp4MimeType(codecs)
if (!mime) {
onError?.('后端未能解析出 fMP4 的编码参数,无法起播')
return
}
if (typeof MediaSource === 'undefined' || !MediaSource.isTypeSupported(mime)) {
onError?.(`浏览器不支持该编码(${codecs}),无法在页面内播放`)
return
}
const controller = new AbortController()
const mediaSource = new MediaSource()
const objectUrl = URL.createObjectURL(mediaSource)
const queue: Uint8Array[] = []
let sourceBuffer: SourceBuffer | null = null
let streamEnded = false
let disposed = false
let aligned = false
let chaser: ReturnType<typeof setInterval> | null = null
const fail = (message: string) => {
if (disposed) return
onError?.(message)
}
const pump = () => {
if (disposed || !sourceBuffer || sourceBuffer.updating || mediaSource.readyState !== 'open') return
const next = queue.shift()
if (next) {
try {
sourceBuffer.appendBuffer(next as BufferSource)
} catch (error) {
// 配额满:先清掉早于当前位置的缓冲再重试这一块
if ((error as DOMException)?.name === 'QuotaExceededError' && video.buffered.length) {
queue.unshift(next)
const cut = Math.max(video.buffered.start(0), video.currentTime - 5)
if (cut > video.buffered.start(0)) sourceBuffer.remove(video.buffered.start(0), cut)
else fail('播放缓冲已满')
} else {
fail(`MSE 追加失败: ${(error as Error)?.message ?? error}`)
}
}
} else if (streamEnded) {
try {
mediaSource.endOfStream()
} catch {
/* 已经结束 */
}
}
}
const trim = () => {
if (!sourceBuffer || sourceBuffer.updating || !video.buffered.length) return
const start = video.buffered.start(0)
const cut = video.currentTime - FMP4_KEEP_BACKWARD_S
if (cut - start > 5) sourceBuffer.remove(start, cut)
}
const chase = () => {
if (disposed || !video.buffered.length) return
const end = video.buffered.end(video.buffered.length - 1)
if (!aligned) {
// 直播分片的时间戳从一个很大的值起步,播放位置得先跳进缓冲区
aligned = true
video.currentTime = Math.max(video.buffered.start(0), end - FMP4_TARGET_REMAIN_S)
if (autoplay) video.play().catch(() => {
video.muted = true
video.play().catch(() => {})
})
return
}
if (end - video.currentTime > FMP4_MAX_LATENCY_S && !video.paused) {
video.currentTime = end - FMP4_TARGET_REMAIN_S
}
}
const dispose = () => {
if (disposed) return
disposed = true
controller.abort()
if (chaser) clearInterval(chaser)
try {
if (mediaSource.readyState === 'open') mediaSource.endOfStream()
} catch {
/* ignore */
}
URL.revokeObjectURL(objectUrl)
}
artWithMse.fmp4Dispose = dispose
art.on('destroy', dispose)
mediaSource.addEventListener('sourceopen', () => {
if (disposed) return
try {
sourceBuffer = mediaSource.addSourceBuffer(mime)
} catch (error) {
fail(`浏览器拒绝该编码(${codecs}): ${(error as Error)?.message ?? error}`)
return
}
sourceBuffer.mode = 'segments'
sourceBuffer.addEventListener('updateend', () => {
trim()
pump()
})
sourceBuffer.addEventListener('error', () => fail('MSE 解码失败'))
chaser = setInterval(chase, 500)
fetch(url, { signal: controller.signal, cache: 'no-store' })
.then(async (res) => {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `连接失败(HTTP ${res.status})`)
}
if (!res.body) throw new Error('浏览器不支持流式读取响应')
const reader = res.body.getReader()
for (;;) {
const { done, value } = await reader.read()
if (disposed) return
if (done) break
if (value) {
queue.push(value)
pump()
}
}
streamEnded = true
pump()
// 服务端结束了响应(掉队断开 / 换直链 / 录制结束)
onEnded?.()
})
.catch((error: unknown) => {
if (disposed || (error as Error)?.name === 'AbortError') return
fail(error instanceof Error ? error.message : String(error))
})
})
video.src = objectUrl
}
function describeMpegtsError(errorType: string, detail: string, info?: { code?: number; msg?: string }): string {
if (errorType === mpegts.ErrorTypes.NETWORK_ERROR) {
if (detail === mpegts.ErrorDetails.NETWORK_STATUS_CODE_INVALID) {
const code = info?.code
if (code === 415) return '当前下载器 / 容器不支持预览'
if (code === 429) return '预览连接数已达上限,请稍后再试'
if (code === 503) return '录制尚未开始拉流或正在重连'
return `连接失败(HTTP ${code ?? '?'})`
}
return `网络错误: ${info?.msg ?? detail}`
}
if (errorType === mpegts.ErrorTypes.MEDIA_ERROR) {
if (detail === mpegts.ErrorDetails.MEDIA_CODEC_UNSUPPORTED) return '浏览器不支持该编码(可能为 HEVC)'
if (detail === mpegts.ErrorDetails.MEDIA_MSE_ERROR) {
// 典型来源:TS 的 PES 没有按访问单元对齐(如 Twitch),mpegts.js 拆出的帧不完整,浏览器拒绝解码
return '浏览器解码失败:该直播源的封装方式 mpegts.js 无法处理,录制不受影响'
}
return `解码错误: ${info?.msg ?? detail}`
}
return `播放错误: ${info?.msg ?? detail}`
}
interface MpegtsOptions {
type: MpegtsType
isLive: boolean
autoplay: boolean
onEnded?: () => void
onError?: (message: string) => void
}
/** Artplayer customType:用 mpegts.js 解封装 FLV / MPEG-TS 后喂给 MSE。 */
function playWithMpegts(
video: HTMLVideoElement,
url: string,
art: Artplayer,
{ type, isLive, autoplay, onEnded, onError }: MpegtsOptions
) {
if (!mpegts.isSupported()) {
art.notice.show = `当前浏览器不支持 MSE,无法播放 ${type}`
onError?.('当前浏览器不支持 MSE')
return
}
// Artplayer 在 video:error 后会隔 1 s 重设 art.url 最多 5 次(RECONNECT_TIME_MAX),
// 而且不看自己是否已被 destroy——那会在弹层关闭后凭空再拉一路流、占着服务端许可。
// 重连由 LivePreviewPlayer 自己管,这里对已销毁的实例直接不理。
if (art.isDestroy) return
const artWithMpegts = art as Artplayer & { mpegts?: mpegts.Player | null }
if (artWithMpegts.mpegts) {
destroyMpegts(artWithMpegts.mpegts)
artWithMpegts.mpegts = null
}
const player = mpegts.createPlayer(
// 直连 CDN 时是跨域请求:cors 模式、不带 cookie(这些 CDN 的 ACAO 是 *,带凭据反而会被拒);
// 同源的 /live 走 same-origin 凭据,登录 cookie 照常带上(见 AbortableFetchLoader)
{ type, url, isLive, cors: true, withCredentials: false },
isLive
? {
// 直播:不攒缓冲、落后就追,源缓冲区用完即清理,长时间观看不涨内存
enableStashBuffer: false,
customLoader: AbortableFetchLoader,
liveBufferLatencyChasing: true,
liveBufferLatencyMaxLatency: 3,
liveBufferLatencyMinRemain: 0.5,
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 10,
}
: {}
)
artWithMpegts.mpegts = player
art.on('destroy', () => {
if (artWithMpegts.mpegts) {
destroyMpegts(artWithMpegts.mpegts)
artWithMpegts.mpegts = null
}
})
player.on(mpegts.Events.ERROR, (errorType: string, detail: string, info?: { code?: number; msg?: string }) => {
const message = describeMpegtsError(errorType, detail, info)
art.notice.show = message
onError?.(message)
})
if (isLive) {
// 服务端结束响应(掉队断开 / 换直链)时 mpegts.js 会把 MSE 收尾成 ended
video.addEventListener('ended', () => onEnded?.(), { once: true })
}
player.attachMediaElement(video)
player.load()
if (autoplay) {
const playing = player.play()
if (playing && typeof (playing as Promise<void>).catch === 'function') {
;(playing as Promise<void>).catch(() => {
// 自动播放被浏览器拦下时静音重试,直播预览宁可无声也不要卡住;
// play() 被打断也可能是实例已被销毁(弹层关闭),那就不要再碰它
if (art.isDestroy || artWithMpegts.mpegts !== player) return
video.muted = true
player.play()
})
}
}
}
const Players: React.FC<PlayerConfig> = ({
url,
height = '100%',
width = '100%',
type,
codecs,
isLive = false,
muted = false,
autoplay = isLive,
onEnded,
onError,
danmaku,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<VideoPlayer>(null)
const danmakuCapable = !!danmaku
const danmakuId = danmaku?.id
const danmakuFeed = danmaku?.feed ?? null
const danmakuFontSize = danmaku?.fontSize ?? 22
// 回调放进 ref:父组件每次渲染传入的新函数不应重建播放器
const callbacksRef = useRef({ onEnded, onError })
useEffect(() => {
callbacksRef.current = { onEnded, onError }
})
useEffect(() => {
if (!containerRef.current) return
const container = containerRef.current
let cancelled = false
try {
const create = (mediaType: LiveType | null) => {
if (cancelled || !container.isConnected) return
if (playerRef.current) {
playerRef.current.destroy()
playerRef.current = null
}
if (url.endsWith('.flv')) {
playerRef.current = new Artplayer({
container: containerRef.current,
url,
type: 'flv',
customType: {
flv: playFlv,
},
autoSize: true,
fullscreen: true,
fullscreenWeb: true,
autoOrientation: true,
plugins: [],
})
} else {
playerRef.current = new Artplayer({
container: containerRef.current,
url,
autoSize: true,
fullscreen: true,
fullscreenWeb: true,
autoOrientation: true,
plugins: [],
})
const base = {
container,
url,
autoSize: !isLive,
fullscreen: true,
fullscreenWeb: true,
autoOrientation: true,
isLive,
muted,
autoplay,
plugins: danmakuCapable
? [
artplayerPluginDanmuku({
danmuku: [],
speed: 7,
fontSize: danmakuFontSize,
opacity: 0.9,
antiOverlap: true,
synchronousPlayback: false,
emitter: false,
visible: true,
margin: [10, '25%'],
}),
]
: [],
}
} catch (error) {
console.error('播放器初始化失败:', error)
const onEndedCb = () => callbacksRef.current.onEnded?.()
const onErrorCb = (message: string) => callbacksRef.current.onError?.(message)
try {
if (mediaType === 'hls') {
const options: HlsOptions = { autoplay, onEnded: onEndedCb, onError: onErrorCb }
playerRef.current = new Artplayer({
...base,
type: 'hls',
customType: {
hls: (video: HTMLVideoElement, src: string, art: Artplayer) => {
playWithHls(video, src, art, options).catch((e: unknown) =>
onErrorCb(`加载 hls.js 失败:${e instanceof Error ? e.message : String(e)}`)
)
},
},
})
} else if (mediaType === 'fmp4') {
const options: Fmp4Options = { codecs, autoplay, onEnded: onEndedCb, onError: onErrorCb }
playerRef.current = new Artplayer({
...base,
type: 'fmp4',
customType: {
fmp4: (video: HTMLVideoElement, src: string, art: Artplayer) =>
playWithMediaSource(video, src, art, options),
},
})
} else if (mediaType) {
const options: MpegtsOptions = {
type: mediaType,
isLive,
autoplay,
onEnded: onEndedCb,
onError: onErrorCb,
}
playerRef.current = new Artplayer({
...base,
type: mediaType,
customType: {
[mediaType]: (video: HTMLVideoElement, src: string, art: Artplayer) =>
playWithMpegts(video, src, art, options),
},
})
} else {
playerRef.current = new Artplayer(base)
}
} catch (error) {
console.error('播放器初始化失败:', error)
callbacksRef.current.onError?.('播放器初始化失败')
}
}
const declared: LiveType | null = type ?? (url.endsWith('.flv') ? 'flv' : null)
if (declared || !isLive) {
create(declared)
} else {
probeLiveType(url).then(create, (error: unknown) => {
if (cancelled) return
callbacksRef.current.onError?.(error instanceof Error ? error.message : String(error))
})
}
return () => {
cancelled = true
if (playerRef.current) {
playerRef.current.destroy()
playerRef.current = null
}
}
}, [url, height, width])
}, [url, height, width, type, codecs, isLive, muted, autoplay, danmakuCapable, danmakuFontSize])
// 弹幕开关:有 feed → 订阅本路、显示弹幕层;没有 → 退订、隐藏。不重建播放器。
// 播放器可能还在探测 Content-Type(异步创建),所以轮询等到实例出现再挂。
useEffect(() => {
if (danmakuId === undefined || !danmakuFeed) {
const plugin = playerRef.current?.plugins?.artplayerPluginDanmuku as DanmukuPlugin | undefined
plugin?.hide()
return
}
let unsubscribe: (() => void) | null = null
let cancelled = false
const attach = () => {
if (cancelled) return true
const plugin = playerRef.current?.plugins?.artplayerPluginDanmuku as DanmukuPlugin | undefined
if (!plugin) return false
plugin.show()
unsubscribe = danmakuFeed.subscribe(danmakuId, (frame) => emitFrame(plugin, frame))
return true
}
if (!attach()) {
const timer = setInterval(() => {
if (attach()) clearInterval(timer)
}, 300)
return () => {
cancelled = true
clearInterval(timer)
unsubscribe?.()
}
}
return () => {
cancelled = true
unsubscribe?.()
}
}, [danmakuId, danmakuFeed, url, type, codecs])
return <div ref={containerRef} style={{ width, height }} />
}
+65 -5
View File
@@ -3,7 +3,15 @@ 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, useNowSec } from '@/app/lib/use-dashboard'
import {
timeAgo,
formatDuration,
formatRate,
liveImageUrl,
useNowSec,
canPreview,
} from '@/app/lib/use-dashboard'
import { LivePreviewButton, LivePreviewModal } from './LivePreview'
import styles from './streamer-card.module.scss'
export interface StreamerCardProps {
@@ -40,12 +48,39 @@ function platTint(color: string): string {
* 录制中的直播间封面缩略图(16:9,懒加载)。
* 加载失败就整块消失,卡片回到没有封面时的样式;加载中显示占位底色。
* 调用方用 key={src} 挂载,封面地址变化时状态自然重置。
* 传入 onPreview 时封面可点击,中央显示播放标记,点击打开直播预览。
*/
export function LiveCover({ src, alt }: { src: string; alt: string }) {
export function LiveCover({
src,
alt,
onPreview,
}: {
src: string
alt: string
onPreview?: () => void
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
if (state === 'error') return null
const clickable = !!onPreview && state === 'ok'
return (
<figure className={styles.cover} data-state={state}>
<figure
className={`${styles.cover} ${clickable ? styles.coverClickable : ''}`}
data-state={state}
onClick={clickable ? onPreview : undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
aria-label={clickable ? '预览直播' : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onPreview?.()
}
}
: undefined
}
>
<Image
src={src}
alt={alt}
@@ -55,6 +90,13 @@ export function LiveCover({ src, alt }: { src: string; alt: string }) {
onLoad={() => setState('ok')}
onError={() => setState('error')}
/>
{clickable ? (
<span className={styles.coverPlay} aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M8 5.5v13l11-6.5z" />
</svg>
</span>
) : null}
</figure>
)
}
@@ -103,6 +145,9 @@ export default function StreamerCard({
const live = streamer.status === 'Working'
const paused = streamer.status === 'Pause'
const name = streamer.remark || streamer.url
// 封面点击打开的预览弹层;按钮自带一套,两处入口共用同一个弹层状态
const [previewOpen, setPreviewOpen] = useState(false)
const previewable = canPreview(streamer)
const lastRec = info?.date && !live ? timeAgo(info.date) : null
@@ -151,8 +196,15 @@ export default function StreamerCard({
</span>
</div>
{/* 录制中:直播间封面 */}
{coverSrc ? <LiveCover key={coverSrc} src={coverSrc} alt={`${name} 的直播间封面`} /> : null}
{/* 录制中:直播间封面(可预览时点击即打开播放器) */}
{coverSrc ? (
<LiveCover
key={coverSrc}
src={coverSrc}
alt={`${name} 的直播间封面`}
onPreview={previewable ? () => setPreviewOpen(true) : undefined}
/>
) : null}
{/* 名称(录制中带头像)+ 实时时长与写盘速率 / 最近录制 */}
<div className={styles.nameRow}>
@@ -172,6 +224,7 @@ export default function StreamerCard({
>
{rate ?? '—'}
</span>
<LivePreviewButton streamer={streamer} size="small" iconOnly className={styles.previewBtn} />
</span>
) : lastRec ? (
<span className={styles.recInfo}>{lastRec}</span>
@@ -193,6 +246,13 @@ export default function StreamerCard({
</a>
<div className={styles.meta}>{uploadStatusTag(streamer.upload_status)}</div>
{actions ? <div className={styles.actions}>{actions}</div> : null}
{previewOpen ? (
<LivePreviewModal
streamer={streamer}
visible={previewOpen}
onClose={() => setPreviewOpen(false)}
/>
) : null}
</article>
)
}
+165
View File
@@ -0,0 +1,165 @@
/* 实时监视:多路小窗网格(历史记录页 Tab,组件可整体挪动) */
.monitor {
display: flex;
flex-direction: column;
gap: 12px;
}
.center {
display: flex;
justify-content: center;
padding: 48px 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px 16px;
}
.summary {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.controls {
display: inline-flex;
align-items: center;
gap: 8px;
}
.switchRow {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 8px;
}
.hint {
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 14px;
}
/* ===== 小窗 ===== */
.tile {
display: flex;
flex-direction: column;
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
background: var(--semi-color-bg-1);
overflow: hidden;
box-shadow: 0 1px 2px rgba(var(--semi-grey-9), 0.04), 0 2px 6px rgba(var(--semi-grey-9), 0.03);
transition: border-color 0.15s ease;
}
.tilePlaying {
border-color: rgba(var(--semi-green-4), 0.6);
}
.tileHead {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px 8px 12px;
min-height: 40px;
}
.tileName {
font-size: 13.5px;
font-weight: 650;
color: var(--semi-color-text-0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.tileRate {
font-size: 12px;
font-weight: 600;
color: var(--semi-color-text-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
padding: 1px 7px;
border-radius: 999px;
background: var(--semi-color-fill-0);
}
/* 未播放:封面 + 说明,整块可点 */
.idle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 16 / 9;
border: 0;
padding: 0;
margin: 0;
background: var(--semi-color-fill-1);
color: #fff;
cursor: pointer;
overflow: hidden;
text-align: center;
}
.idle:disabled {
cursor: not-allowed;
}
.idle:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: -2px;
}
.idleCover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.55;
transition: opacity 0.15s ease;
}
.idle:not(:disabled):hover .idleCover {
opacity: 0.4;
}
.idle:disabled .idleCover {
filter: grayscale(0.6);
opacity: 0.35;
}
.idleText {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
padding: 6px 12px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
max-width: 90%;
line-height: 1.4;
}
.idle:disabled .idleText {
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
}
.tileFoot {
padding: 7px 12px 9px;
font-size: 12px;
color: var(--semi-color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+135
View File
@@ -0,0 +1,135 @@
/* 直播预览:卡片弹层播放区 + 监视器小窗共用 */
.player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
overflow: hidden;
}
.player > div {
width: 100% !important;
height: 100% !important;
}
/* 连接中 / 重连中 / 出错时盖在画面上的说明层 */
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
text-align: center;
color: rgba(255, 255, 255, 0.88);
background: rgba(0, 0, 0, 0.55);
pointer-events: none;
}
.overlay :global(.semi-button) {
pointer-events: auto;
}
.player[data-phase='connecting'] .overlay,
.player[data-phase='reconnecting'] .overlay {
background: rgba(0, 0, 0, 0.35);
}
/* 出错 / 断开时垫在说明后面的封面 */
.overlayCover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.35;
z-index: -1;
}
.overlay {
isolation: isolate;
}
.overlayText {
font-size: 13px;
line-height: 1.5;
max-width: 90%;
word-break: break-word;
}
/* ===== 弹层 ===== */
.modal :global(.semi-modal-header) {
margin-bottom: 0;
padding-bottom: 12px;
}
.modalTitle {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
/* 右侧留出关闭按钮的位置 */
padding-right: 28px;
}
.modalName {
font-size: 15px;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* 取流方式角标:左上角,不挡播放器控制栏 */
.badge {
position: absolute;
top: 8px;
left: 8px;
/* Artplayer 内部各层 z-index 到 90+,角标要压在它们之上 */
z-index: 120;
max-width: calc(100% - 16px);
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
color: #fff;
background: rgba(0, 0, 0, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: auto;
}
.badge[data-tone='direct'] {
background: rgba(22, 163, 74, 0.75);
}
.badge[data-tone='fallback'] {
background: rgba(217, 119, 6, 0.8);
}
.badge[data-compact] {
top: 4px;
left: 4px;
font-size: 11px;
line-height: 16px;
padding: 1px 6px;
}
.danmakuSwitch {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
flex-shrink: 0;
}
.modalFoot {
padding: 10px 24px 14px;
}
/* disabled 的按钮不响应鼠标,包一层让 Tooltip 仍能弹出 */
.disabledWrap {
display: inline-flex;
}
.disabledWrap :global(.semi-button) {
pointer-events: none;
}
+28
View File
@@ -268,6 +268,34 @@ const Global: React.FC = () => {
}}
showClear={true}
/>
<Form.Select
field="preview_transport"
label="直播预览取流方式(preview_transport)"
extraText={
<div style={{ fontSize: '14px' }}>
<div>
<strong>经 biliup 中转</strong>(默认):页面里的预览复用正在录制的那一路流,不向直播平台多拉一路。
浏览器与 biliup 在同一台机器或同一内网时选这个,不多占 CDN 带宽。
</div>
<div>
<strong>浏览器直连 CDN</strong>:biliup 向平台另取一条直链(新 token,不影响录制那条),浏览器自己去
CDN 拉,媒体流量不经过 biliup,适合 biliup 部署在异地服务器、浏览器远程访问的情况,省服务器出口带宽。
只有 CDN 放行跨域的平台能直连(B 站 / 抖音 / 斗鱼 / 虎牙,FLV 与 HLS 都行);Twitch 等未放行跨域的
平台在直连模式下自动回落中转并在播放器角标标出原因。
</div>
</div>
}
placeholder="经 biliup 中转(relay)"
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
showClear={true}
>
<Form.Select.Option value="relay">经 biliup 中转(relay)</Form.Select.Option>
<Form.Select.Option value="direct">浏览器直连 CDN(direct)</Form.Select.Option>
</Form.Select>
</div>
<Space />
+42
View File
@@ -147,6 +147,40 @@
opacity: 1;
}
/* 可预览时封面可点:中央播放标记,悬停加深 */
.coverClickable {
cursor: pointer;
}
.coverClickable:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: 2px;
}
.coverPlay {
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
margin: -22px 0 0 -22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
opacity: 0.85;
transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease;
}
.coverPlay svg {
margin-left: 2px;
}
.coverClickable:hover .coverPlay {
opacity: 1;
transform: scale(1.06);
background: rgba(0, 0, 0, 0.6);
}
/* ===== 名称 + 时长 ===== */
.nameRow {
display: flex;
@@ -216,6 +250,14 @@
text-align: center;
}
/* 直播中:预览按钮(图标) */
.previewBtn {
height: 22px;
min-width: 22px;
padding: 0 6px !important;
align-self: center;
}
/* 未开播:最近录制时间 */
.recInfo {
font-size: 12px;
+2
View File
@@ -18,6 +18,8 @@ mesio-engine = { git = "https://github.com/hua0512/rust-srec", rev = "dcdb80ee60
flv-fix = { git = "https://github.com/hua0512/rust-srec", rev = "dcdb80ee607ff6a991e5ddd86a0084a01bd945ac", default-features = false }
hls-fix = { git = "https://github.com/hua0512/rust-srec", rev = "dcdb80ee607ff6a991e5ddd86a0084a01bd945ac", default-features = false }
hls = { git = "https://github.com/hua0512/rust-srec", rev = "dcdb80ee607ff6a991e5ddd86a0084a01bd945ac" }
# 直播预览要把 mesio 管线里的 FlvData 重新编成字节,需要直接引用 flv 的类型(同一 rev,已在 lock 中)
flv = { git = "https://github.com/hua0512/rust-srec", rev = "dcdb80ee607ff6a991e5ddd86a0084a01bd945ac" }
pipeline-common = { git = "https://github.com/hua0512/rust-srec", rev = "dcdb80ee607ff6a991e5ddd86a0084a01bd945ac" }
serde = { workspace = true }
+2 -2
View File
@@ -88,7 +88,7 @@ async fn download_stream(
match live::media_ext_from_url(url).as_deref() {
Some("m3u8" | "ts") => {
let file = LifecycleFile::new(&output, "ts");
hls::download(url, &client, file, segmentable)
hls::download(url, &client, file, segmentable, None)
.await
.change_context_lazy(|| AppError::Unknown)
}
@@ -106,7 +106,7 @@ async fn download_stream(
.await
.change_context_lazy(|| AppError::Unknown)?;
let file = LifecycleFile::new(&output, "flv");
httpflv::download(connection, file, segmentable).await;
httpflv::download(connection, file, segmentable, None).await;
Ok(())
}
}
+16 -5
View File
@@ -1,3 +1,4 @@
use crate::server::api::live_preview::direct_capability;
use crate::server::common::recording_policy::{self, Rejection};
use crate::server::common::upload::{build_studio, submit_to_bilibili, upload};
use crate::server::common::util::Recorder;
@@ -6,7 +7,7 @@ use crate::server::core::download_manager::DownloadManager;
use crate::server::errors::{AppError, report_to_response};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::context::{Stage, Worker, WorkerStatus};
use crate::server::infrastructure::dto::LiveStreamerResponse;
use crate::server::infrastructure::dto::{LivePreviewResponse, LiveStreamerResponse};
use crate::server::infrastructure::models::live_streamer::{InsertLiveStreamer, LiveStreamer};
use crate::server::infrastructure::models::upload_streamer::{
InsertUploadStreamer, UploadStreamer,
@@ -67,7 +68,14 @@ pub async fn get_streamers_endpoint(
Some(t) => {
let downloader_status = t.downloader_status.read().unwrap();
let live = match &*downloader_status {
WorkerStatus::Working(task) => Some((task.bytes_per_sec(), task.live_media())),
WorkerStatus::Working(task) => {
Some((task.bytes_per_sec(), task.live_media(), {
let status = task.preview().status();
let source = task.live_source();
let direct = direct_capability(&source.platform);
LivePreviewResponse::new(status, task.danmaku_available(), direct)
}))
}
_ => None,
};
(format!("{:?}", *downloader_status), live)
@@ -82,8 +90,10 @@ pub async fn get_streamers_endpoint(
Some(reason) if status != "Working" => reason.to_string(),
_ => status,
};
let (live_bytes_per_sec, live_media) = live.unzip();
let live_media = live_media.unwrap_or_default();
let (live_bytes_per_sec, live_media, preview) = match live {
Some((rate, media, preview)) => (rate, media, Some(preview)),
None => (None, Default::default(), None),
};
results.push(LiveStreamerResponse {
status,
@@ -91,9 +101,10 @@ pub async fn get_streamers_endpoint(
upload_status: option
.map(|t| format!("{:?}", *t.uploader_status.read().unwrap()))
.unwrap_or_default(),
live_bytes_per_sec: live_bytes_per_sec.flatten(),
live_bytes_per_sec,
live_cover_url: live_media.cover_url,
live_avatar_url: live_media.avatar_url,
preview,
});
}
Ok(Json(results))
@@ -0,0 +1,788 @@
//! `GET /v1/streamers/{id}/live`:把正在录制的那一路流旁路给页面内的播放器。
//!
//! 响应是 chunked 的 `video/x-flv` 或 `video/mp2t` 字节流:先发「文件头 + 序列头 + 当前 GOP」
//! 的快照,再持续转发与写盘同步的实时分块。慢客户端落后到广播缓冲被覆盖(`Lagged`)时
//! 直接结束响应,由播放器重连拿新快照;写入端换代(断流重试 / 换直链)时同样结束响应。
//!
//! 连接数:每路 [`crate::server::common::download::PREVIEW_MAX_SUBSCRIBERS_PER_ROOM`]、
//! 进程 [`MAX_PREVIEW_CONNECTIONS`],超出返回 429。
//! 路由注册在 `router()` 里,`--auth` 时与 `/v1/streamers` 同一道登录校验。
use crate::server::core::download_manager::DownloadManager;
use crate::server::core::live::live_request;
use crate::server::infrastructure::context::WorkerStatus;
use crate::server::infrastructure::dto::DirectCapability;
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use biliup::downloader::live::LiveStatus;
use biliup::downloader::preview::{PreviewHub, SubscribeError, Subscription};
use bytes::Bytes;
use danmaku_client::DanmakuEvent;
use std::collections::VecDeque;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tracing::{debug, info, warn};
/// 进程内同时允许的预览连接总数(所有直播间合计)。
pub const MAX_PREVIEW_CONNECTIONS: usize = 16;
/// 等待写入端给出关键帧对齐快照的最长时间,需长于一个 GOP / 一个 HLS 分片。
pub const SUBSCRIBE_TIMEOUT: Duration = Duration::from_secs(20);
fn connection_limit() -> &'static Arc<Semaphore> {
static LIMIT: OnceLock<Arc<Semaphore>> = OnceLock::new();
LIMIT.get_or_init(|| Arc::new(Semaphore::new(MAX_PREVIEW_CONNECTIONS)))
}
/// `GET /v1/streamers/{id}/live`
pub async fn get_live_stream(
State(managers): State<Arc<DownloadManager>>,
Path(id): Path<i64>,
) -> Response {
let Some(worker) = managers.get_room_by_id(id).await else {
return (StatusCode::NOT_FOUND, "直播间不存在").into_response();
};
// 只取 hub 句柄,不把状态锁带过 await
let hub: PreviewHub = match &*worker.downloader_status.read().unwrap() {
WorkerStatus::Working(task) => task.preview().clone(),
_ => return (StatusCode::NOT_FOUND, "直播间未在录制").into_response(),
};
let Ok(global) = connection_limit().clone().try_acquire_owned() else {
return (
StatusCode::TOO_MANY_REQUESTS,
format!("预览连接数已达上限(进程内最多 {MAX_PREVIEW_CONNECTIONS} 路)"),
)
.into_response();
};
match hub.subscribe(SUBSCRIBE_TIMEOUT).await {
Ok(subscription) => {
info!(id, format = subscription.format.as_str(), "开始直播预览");
live_response(subscription, global)
}
Err(error) => subscribe_error_response(id, error),
}
}
fn subscribe_error_response(id: i64, error: SubscribeError) -> Response {
debug!(id, %error, "直播预览订阅失败");
let (status, message) = match &error {
SubscribeError::Unavailable(reason) => (StatusCode::UNSUPPORTED_MEDIA_TYPE, reason.clone()),
SubscribeError::TooManySubscribers(_) => (StatusCode::TOO_MANY_REQUESTS, error.to_string()),
SubscribeError::NotAttached => (
StatusCode::SERVICE_UNAVAILABLE,
"录制尚未开始拉流或正在重连,请稍后重试".to_string(),
),
SubscribeError::Timeout => (
StatusCode::SERVICE_UNAVAILABLE,
"等待关键帧超时(流可能已停滞),请稍后重试".to_string(),
),
};
let mut response = (status, message).into_response();
if status == StatusCode::SERVICE_UNAVAILABLE {
response
.headers_mut()
.insert(header::RETRY_AFTER, HeaderValue::from_static("3"));
}
response
}
/// 浏览器能否直连 CDN 拉这一路——按各平台 CDN 的跨域放行实测(PR #1712 review 1 / 2)。
///
/// 直连**不复用录制那条直链**而是向平台另取一条(新 token),所以「同一直链两条连接」的限制
/// (斗鱼一 token 一连接、部分虎牙节点对第二条连接只给 GOP 缓存即断)不再是问题——实测新 token
/// 的斗鱼 / 虎牙直链各读 25 s 稳定,录制那条不受影响。容器不影响判定:FLV 走 mpegts.js,
/// HLS(TS / fMP4 分片,`.m3u8`)走 hls.js,B 站 `.m4s` 分片的 CDN 同样 `ACAO: *`。
///
/// | 平台 | ACAO | 判定 |
/// | --- | --- | --- |
/// | B 站 / 抖音 / 虎牙 / 斗鱼 | `*` | 能 |
/// | Twitch | 200 响应无 ACAO | 不能 |
///
/// 其它平台没实测,按不能处理,回落中转。
pub fn direct_capability(platform: &str) -> DirectCapability {
let no = |reason: &str| DirectCapability {
capable: false,
reason: Some(reason.to_string()),
};
match platform {
"bilibili" | "douyin" | "huya" | "douyu" => DirectCapability {
capable: true,
reason: None,
},
"twitch" => no("Twitch CDN 未放行跨域(响应无 Access-Control-Allow-Origin)"),
_ => no("该平台的 CDN 跨域放行未验证"),
}
}
/// 直链在浏览器里该用哪个播放器:`.flv` → mpegts.js,`.m3u8` → hls.js,其它猜不出。
pub fn direct_format(stream_url: &str) -> Option<&'static str> {
let path = stream_url.split('?').next().unwrap_or("");
if path.ends_with(".flv") {
Some("flv")
} else if path.ends_with(".m3u8") {
Some("hls")
} else {
None
}
}
/// 从直链的查询参数里估计过期时间(Unix 秒)。B 站 `expires=`、抖音 `expire=` 是十进制 Unix 秒;
/// 虎牙 `wsTime=`、腾讯云 `txTime=` 是十六进制。斗鱼 `expire=300` 这类相对秒数不算。
pub fn estimate_expiry(stream_url: &str) -> Option<i64> {
let query = stream_url.split_once('?')?.1;
for pair in query.split('&') {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
let parsed = match key {
"expires" | "expire" | "exp" => value.parse::<i64>().ok(),
"wsTime" | "txTime" => i64::from_str_radix(value, 16).ok(),
_ => None,
};
// 小于 2001 年的数当作相对秒数或别的东西,不算
if let Some(ts) = parsed
&& ts > 1_000_000_000
{
return Some(ts);
}
}
None
}
/// `GET /v1/streamers/{id}/live-url` 的响应:当前录制中那条流的 CDN 直链。
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct LiveUrlResponse {
/// 直链,含 CDN 参数;与录制用的是同一条
pub url: String,
/// 浏览器该用哪个播放器:`flv`(mpegts.js)/ `hls`(hls.js,TS 或 fMP4 分片都行);后缀猜不出为 `null`
pub format: Option<&'static str>,
pub platform: String,
/// 过期时间估计(Unix 秒);直链里没有可识别的过期参数时为 `null`
pub expires_at: Option<i64>,
pub direct: DirectCapability,
}
/// `GET /v1/streamers/{id}/live-url`:浏览器直连模式用。
///
/// **不复用录制那条直链**,而是向平台重新取一条(ForgQi,#1712):斗鱼 CDN 一个 token 只允许一条连接、
/// 部分虎牙节点对同一直链的第二条连接只给 GOP 缓存即断——都是「同一直链两条连接」的问题,
/// 浏览器拿自己的 token 就绕开了,也不会挤掉录制。只在录制中可用(404 否则);向平台取新直链
/// 失败时 503,由前端回落中转。
pub async fn get_live_url(
State(managers): State<Arc<DownloadManager>>,
Path(id): Path<i64>,
) -> Response {
let Some(worker) = managers.get_room_by_id(id).await else {
return (StatusCode::NOT_FOUND, "直播间不存在").into_response();
};
let source = match &*worker.downloader_status.read().unwrap() {
WorkerStatus::Working(task) => task.live_source(),
_ => return (StatusCode::NOT_FOUND, "直播间未在录制").into_response(),
};
let capability = direct_capability(&source.platform);
if !capability.capable {
// 不能直连就不去平台多要一条,直接把原因给前端回落
let response = LiveUrlResponse {
direct: capability,
expires_at: None,
format: direct_format(&source.url),
platform: source.platform,
url: String::new(),
};
return no_store(axum::Json(response).into_response());
}
let room_url = worker.get_streamer().url.clone();
let Some(plugin) = managers.plugin_for(&room_url).await else {
return (StatusCode::SERVICE_UNAVAILABLE, "找不到该直播间的平台插件").into_response();
};
let fresh = match plugin.check_stream(live_request(&worker)).await {
Ok(LiveStatus::Live { stream }) => stream,
Ok(LiveStatus::Offline) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"平台返回未开播,取不到新直链",
)
.into_response();
}
Err(e) => {
warn!(id, error = ?e, "向平台取预览直链失败");
return (
StatusCode::SERVICE_UNAVAILABLE,
format!("向平台取新直链失败:{e}"),
)
.into_response();
}
};
let url = fresh.raw_stream_url;
let response = LiveUrlResponse {
direct: direct_capability(&fresh.platform),
expires_at: estimate_expiry(&url),
format: direct_format(&url),
platform: fresh.platform,
url,
};
info!(id, "已向平台取到预览直链");
no_store(axum::Json(response).into_response())
}
fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
/// 进程内同时允许的实时弹幕连接总数。弹幕是文本流,比视频便宜得多,上限放宽一些。
pub const MAX_DANMAKU_CONNECTIONS: usize = 32;
fn danmaku_connection_limit() -> &'static Arc<Semaphore> {
static LIMIT: OnceLock<Arc<Semaphore>> = OnceLock::new();
LIMIT.get_or_init(|| Arc::new(Semaphore::new(MAX_DANMAKU_CONNECTIONS)))
}
/// SSE 里的一条弹幕事件,字段面向播放器弹幕层。
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct DanmakuFrame {
/// 直播间 id:一条 SSE 可以复用给多个直播间(监视器),前端按它分发
pub id: i64,
/// `danmaku` / `gift` / `super_chat` / `guard_buy`
pub kind: &'static str,
/// 显示文本(礼物 / 上舰会拼成一句话)
pub text: String,
pub name: Option<String>,
/// RGB 整数(白 16777215)
pub color: u32,
/// 消息时间,Unix 毫秒
pub ts: i64,
}
impl DanmakuFrame {
/// 进场消息与无法识别的原始数据不进预览。
pub fn from_event(id: i64, event: &DanmakuEvent) -> Option<Self> {
Some(match event {
DanmakuEvent::Chat(m) => Self {
id,
kind: "danmaku",
text: m.content.clone(),
name: m.name.clone(),
color: m.color,
ts: m.timestamp.timestamp_millis(),
},
DanmakuEvent::Gift(m) => Self {
id,
kind: "gift",
text: if m.content.is_empty() {
format!("{} 送出 {} ×{}", m.name, m.gift_name, m.num)
} else {
m.content.clone()
},
name: Some(m.name.clone()),
color: 0xff_a5_00,
ts: m.timestamp.timestamp_millis(),
},
DanmakuEvent::SuperChat(m) => Self {
id,
kind: "super_chat",
text: m.content.clone(),
name: Some(m.name.clone()),
color: 0xff_69_b4,
ts: m.timestamp.timestamp_millis(),
},
DanmakuEvent::GuardBuy(m) => Self {
id,
kind: "guard_buy",
text: format!("{} 开通了 {} ×{}", m.name, m.gift_name, m.num),
name: Some(m.name.clone()),
color: 0x87_ce_eb,
ts: m.timestamp.timestamp_millis(),
},
DanmakuEvent::Enter(_) | DanmakuEvent::Other { .. } => return None,
})
}
}
/// `GET /v1/streamers/{id}/danmaku`:正在录制的直播间的实时弹幕,Server-Sent Events。
///
/// 每条事件是一个 [`DanmakuFrame`] 的 JSON;掉队时跳过丢掉的那几条继续(弹幕丢几条无妨),
/// 录制任务结束时流结束。房间不存在 / 未录制 404,平台没有弹幕客户端 415,连接数超限 429。
pub async fn get_live_danmaku(
State(managers): State<Arc<DownloadManager>>,
Path(id): Path<i64>,
) -> Response {
let Some(worker) = managers.get_room_by_id(id).await else {
return (StatusCode::NOT_FOUND, "直播间不存在").into_response();
};
let rx = match &*worker.downloader_status.read().unwrap() {
WorkerStatus::Working(task) => match task.subscribe_danmaku() {
Some(rx) => rx,
None => {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"该平台没有弹幕客户端,无法提供实时弹幕",
)
.into_response();
}
},
_ => return (StatusCode::NOT_FOUND, "直播间未在录制").into_response(),
};
let Ok(permit) = danmaku_connection_limit().clone().try_acquire_owned() else {
return danmaku_limit_reached();
};
info!(id, "开始实时弹幕");
danmaku_response(vec![(id, rx)], permit)
}
/// 一条 SSE 里最多复用多少个直播间的弹幕。
pub const MAX_DANMAKU_ROOMS_PER_CONNECTION: usize = 16;
#[derive(Debug, serde::Deserialize)]
pub struct DanmakuQuery {
/// 逗号分隔的直播间 id
pub ids: String,
}
/// `GET /v1/danmaku?ids=1,3,9`:多个直播间的实时弹幕复用一条 SSE,事件里带 `id`。
///
/// 监视器同屏 N 路时用它:浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个,N 路视频已经占了
/// N 个,弹幕不能再每路一条。没有弹幕客户端 / 未在录制的 id 静默跳过;一个都没有时 404。
pub async fn get_live_danmaku_multi(
State(managers): State<Arc<DownloadManager>>,
Query(query): Query<DanmakuQuery>,
) -> Response {
let ids: Vec<i64> = query
.ids
.split(',')
.filter_map(|s| s.trim().parse().ok())
.take(MAX_DANMAKU_ROOMS_PER_CONNECTION)
.collect();
let mut receivers = Vec::new();
for id in ids {
let Some(worker) = managers.get_room_by_id(id).await else {
continue;
};
if let WorkerStatus::Working(task) = &*worker.downloader_status.read().unwrap()
&& let Some(rx) = task.subscribe_danmaku()
{
receivers.push((id, rx));
}
}
if receivers.is_empty() {
return (StatusCode::NOT_FOUND, "这些直播间都没有可用的实时弹幕").into_response();
}
let Ok(permit) = danmaku_connection_limit().clone().try_acquire_owned() else {
return danmaku_limit_reached();
};
info!(
ids = ?receivers.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
"开始实时弹幕(复用)"
);
danmaku_response(receivers, permit)
}
fn danmaku_limit_reached() -> Response {
(
StatusCode::TOO_MANY_REQUESTS,
format!("实时弹幕连接数已达上限(进程内最多 {MAX_DANMAKU_CONNECTIONS} 路)"),
)
.into_response()
}
/// 把一路弹幕广播编成 SSE 事件流;掉队跳过,发送端 drop 后结束。
fn danmaku_events(
id: i64,
rx: tokio::sync::broadcast::Receiver<DanmakuEvent>,
) -> impl futures::Stream<Item = Result<Event, std::convert::Infallible>> {
futures::stream::unfold(rx, move |mut rx| async move {
loop {
match rx.recv().await {
Ok(event) => {
let Some(frame) = DanmakuFrame::from_event(id, &event) else {
continue;
};
let Ok(json) = serde_json::to_string(&frame) else {
continue;
};
return Some((Ok(Event::default().event(frame.kind).data(json)), rx));
}
Err(RecvError::Lagged(skipped)) => {
debug!(id, skipped, "实时弹幕订阅者掉队,跳过");
}
Err(RecvError::Closed) => return None,
}
}
})
}
/// 把若干路弹幕广播合成一条 SSE。许可随响应体活,客户端断开即释放;
/// 所有路的录制都结束后流结束。
pub fn danmaku_response(
receivers: Vec<(i64, tokio::sync::broadcast::Receiver<DanmakuEvent>)>,
permit: OwnedSemaphorePermit,
) -> Response {
let merged = futures::stream::select_all(
receivers
.into_iter()
.map(|(id, rx)| Box::pin(danmaku_events(id, rx))),
);
let stream = HoldPermit {
inner: merged,
_permit: permit,
};
Sse::new(stream)
.keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
.into_response()
}
/// 让连接许可随 SSE 流一起活。
struct HoldPermit<S> {
inner: S,
_permit: OwnedSemaphorePermit,
}
impl<S: futures::Stream + Unpin> futures::Stream for HoldPermit<S> {
type Item = S::Item;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
std::pin::Pin::new(&mut self.inner).poll_next(cx)
}
}
struct LiveBody {
snapshot: VecDeque<Bytes>,
/// 持有该路的连接许可;`rx` 是实时接收端
subscription: Subscription,
_global: OwnedSemaphorePermit,
}
impl Drop for LiveBody {
/// 客户端断开、掉队或写入端换代都走到这里:两个许可随之释放
fn drop(&mut self) {
debug!("直播预览连接结束,释放许可");
}
}
/// 把订阅编成 chunked 响应:快照分块先发,之后逐个转发实时分块。
///
/// 两个许可(该路、进程)都随响应体一起活,客户端断开或响应结束即释放。
pub fn live_response(mut subscription: Subscription, global: OwnedSemaphorePermit) -> Response {
let format = subscription.format;
let state = LiveBody {
snapshot: VecDeque::from(std::mem::take(&mut subscription.snapshot)),
subscription,
_global: global,
};
let stream = futures::stream::unfold(state, |mut state| async move {
if let Some(chunk) = state.snapshot.pop_front() {
return Some((Ok::<Bytes, std::io::Error>(chunk), state));
}
match state.subscription.rx.recv().await {
Ok(chunk) => Some((Ok(chunk), state)),
Err(RecvError::Lagged(skipped)) => {
// 慢客户端:丢掉的数据没法补,结束响应让播放器重连拿新快照
warn!(skipped, "预览客户端落后于录制进度,断开以便其重连");
None
}
// 写入端换代(拉流结束 / 断流重试):新连接会拿到新的序列头
Err(RecvError::Closed) => None,
}
});
let mut response = Body::from_stream(stream).into_response();
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(format.content_type()),
);
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
// 反向代理不要攒着再发
headers.insert("X-Accel-Buffering", HeaderValue::from_static("no"));
response
}
#[cfg(test)]
mod tests {
use super::*;
use biliup::downloader::preview::{ChunkKind, PreviewFormat, flv};
/// 响应体 = 快照(文件头 + 序列头 + GOP)+ 实时分块,写入端 drop 后响应结束;
/// 头部声明 FLV 类型、禁止缓存、不带 Content-Length(chunked)。
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn live_response_streams_snapshot_then_live_chunks_and_ends_with_the_sink() {
let hub = PreviewHub::new(4);
let mut sink = hub.attach(PreviewFormat::Flv);
sink.push(ChunkKind::Header, Bytes::from_static(&flv::FILE_HEADER));
sink.push(ChunkKind::SequenceHeader(9), Bytes::from_static(b"avc"));
sink.push(ChunkKind::Keyframe, Bytes::from_static(b"K1"));
let pending = tokio::spawn({
let hub = hub.clone();
async move { hub.subscribe(Duration::from_secs(5)).await }
});
tokio::time::sleep(Duration::from_millis(20)).await;
sink.push(ChunkKind::Media, Bytes::from_static(b"p2"));
let subscription = pending.await.unwrap().unwrap();
let global = connection_limit().clone().try_acquire_owned().unwrap();
let response = live_response(subscription, global);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"video/x-flv"
);
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
assert!(response.headers().get(header::CONTENT_LENGTH).is_none());
sink.push(ChunkKind::Media, Bytes::from_static(b"p3"));
sink.push(ChunkKind::Keyframe, Bytes::from_static(b"K4"));
drop(sink);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let mut expected = Vec::new();
expected.extend_from_slice(&flv::FILE_HEADER);
expected.extend_from_slice(b"avc");
expected.extend_from_slice(b"K1");
expected.extend_from_slice(b"p2");
expected.extend_from_slice(b"p3");
expected.extend_from_slice(b"K4");
assert_eq!(&body[..], &expected[..]);
}
#[test]
fn subscribe_errors_map_to_http_statuses() {
assert_eq!(
subscribe_error_response(1, SubscribeError::Unavailable("ffmpeg".into())).status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
assert_eq!(
subscribe_error_response(1, SubscribeError::TooManySubscribers(4)).status(),
StatusCode::TOO_MANY_REQUESTS
);
let not_attached = subscribe_error_response(1, SubscribeError::NotAttached);
assert_eq!(not_attached.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
not_attached.headers().get(header::RETRY_AFTER).unwrap(),
"3"
);
assert_eq!(
subscribe_error_response(1, SubscribeError::Timeout).status(),
StatusCode::SERVICE_UNAVAILABLE
);
}
#[test]
fn direct_capability_follows_the_measured_platform_table() {
for platform in ["bilibili", "douyin", "huya", "douyu"] {
let cap = direct_capability(platform);
assert!(cap.capable, "{platform}");
assert!(cap.reason.is_none());
}
let twitch = direct_capability("twitch");
assert!(!twitch.capable);
assert!(
twitch
.reason
.unwrap()
.contains("Access-Control-Allow-Origin")
);
assert!(!direct_capability("youtube").capable);
// 容器由直链后缀决定播放器:flv → mpegts.js,m3u8 → hls.js
assert_eq!(
direct_format("https://x.bilivideo.com/a_2500.flv?expires=1"),
Some("flv")
);
assert_eq!(
direct_format("https://x.bilivideo.com/live-bvc/1/index.m3u8?expires=1"),
Some("hls")
);
assert_eq!(
direct_format("https://usher.ttvnw.net/api/channel/hls/x.m3u8?sig=a"),
Some("hls")
);
assert_eq!(direct_format("https://x/y.mp4"), None);
}
#[test]
fn expiry_is_read_from_known_query_parameters() {
assert_eq!(
estimate_expiry("https://x.bilivideo.com/a.flv?a=1&expires=1790073449&len=0"),
Some(1790073449)
);
assert_eq!(
estimate_expiry("https://pull-flv-q11.douyincdn.com/a.flv?expire=1790678986&sign=x"),
Some(1790678986)
);
// 虎牙 wsTime 是十六进制
assert_eq!(
estimate_expiry("https://tx.flv.huya.com/a.flv?wsSecret=x&wsTime=6ab3aa97"),
Some(0x6ab3aa97)
);
// 斗鱼 expire=300 是相对秒数,不算
assert_eq!(
estimate_expiry("https://ws1a.douyucdn.cn/a.flv?wsAuth=x&expire=300&token=y"),
None
);
assert_eq!(estimate_expiry("https://x/a.flv"), None);
assert_eq!(estimate_expiry("https://x/a.flv?"), None);
}
/// 弹幕 SSE:Chat / Gift / SuperChat / GuardBuy 各成一条 `event:` + JSON `data:`,
/// Enter / Other 不出现;掉队跳过;发送端全部 drop 后流结束。
#[tokio::test]
async fn danmaku_sse_maps_events_and_ends_when_the_recorder_is_gone() {
use danmaku_client::message::EnterMessage;
use danmaku_client::{ChatMessage, GiftMessage};
let (tx, rx) = tokio::sync::broadcast::channel(8);
let permit = danmaku_connection_limit()
.clone()
.try_acquire_owned()
.unwrap();
let response = danmaku_response(vec![(7, rx)], permit);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"text/event-stream"
);
tx.send(DanmakuEvent::Chat(
ChatMessage::new("你好".into())
.with_name("观众A")
.with_color(0xff0000),
))
.unwrap();
tx.send(DanmakuEvent::Enter(EnterMessage {
name: "路人".into(),
uid: None,
timestamp: chrono::Utc::now(),
}))
.unwrap();
tx.send(DanmakuEvent::Gift(GiftMessage {
name: "土豪".into(),
uid: 1,
gift_name: "小心心".into(),
price: 0,
num: 3,
content: String::new(),
timestamp: chrono::Utc::now(),
}))
.unwrap();
drop(tx);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(
text.contains(
"event: danmaku\ndata: {\"id\":7,\"kind\":\"danmaku\",\"text\":\"你好\",\"name\":\"观众A\",\"color\":16711680"
),
"{text}"
);
assert!(
text.contains(
"event: gift\ndata: {\"id\":7,\"kind\":\"gift\",\"text\":\"土豪 送出 小心心 ×3\""
),
"{text}"
);
assert!(!text.contains("路人"), "{text}");
assert_eq!(
danmaku_connection_limit().available_permits(),
MAX_DANMAKU_CONNECTIONS
);
}
/// 复用:两路广播合成一条 SSE,每条事件带各自的 id;两路发送端都 drop 后流才结束。
#[tokio::test]
async fn multiplexed_danmaku_tags_each_event_with_its_room_id() {
use danmaku_client::ChatMessage;
let (tx_a, rx_a) = tokio::sync::broadcast::channel(8);
let (tx_b, rx_b) = tokio::sync::broadcast::channel(8);
let permit = danmaku_connection_limit()
.clone()
.try_acquire_owned()
.unwrap();
let response = danmaku_response(vec![(1, rx_a), (3, rx_b)], permit);
tx_a.send(DanmakuEvent::Chat(ChatMessage::new("来自1".into())))
.unwrap();
tx_b.send(DanmakuEvent::Chat(ChatMessage::new("来自3".into())))
.unwrap();
drop(tx_a);
// a 结束了 b 还在:流不能结束
tx_b.send(DanmakuEvent::Chat(ChatMessage::new("b还在".into())))
.unwrap();
drop(tx_b);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(
text.contains("{\"id\":1,\"kind\":\"danmaku\",\"text\":\"来自1\""),
"{text}"
);
assert!(
text.contains("{\"id\":3,\"kind\":\"danmaku\",\"text\":\"来自3\""),
"{text}"
);
assert!(text.contains("b还在"), "{text}");
assert_eq!(
danmaku_connection_limit().available_permits(),
MAX_DANMAKU_CONNECTIONS
);
}
#[test]
fn danmaku_frame_skips_enter_and_other() {
use danmaku_client::message::EnterMessage;
assert!(
DanmakuFrame::from_event(
1,
&DanmakuEvent::Other {
raw_data: "x".into()
}
)
.is_none()
);
assert!(
DanmakuFrame::from_event(
1,
&DanmakuEvent::Enter(EnterMessage {
name: "a".into(),
uid: Some(1),
timestamp: chrono::Utc::now(),
})
)
.is_none()
);
}
/// 进程级连接许可与该路的接收端都随响应体存在,响应体 drop 后立刻释放。
#[tokio::test]
async fn global_connection_limit_is_released_with_the_response_body() {
let limit = connection_limit();
let hub = PreviewHub::new(4);
let mut sink = hub.attach(PreviewFormat::MpegTs);
sink.push(ChunkKind::Keyframe, Bytes::from_static(&[0x47, 0, 0, 0]));
let pending = tokio::spawn({
let hub = hub.clone();
async move { hub.subscribe(Duration::from_secs(5)).await }
});
tokio::time::sleep(Duration::from_millis(20)).await;
sink.push(ChunkKind::Media, Bytes::from_static(&[0x47, 1, 1, 1]));
let subscription = pending.await.unwrap().unwrap();
let before = limit.available_permits();
let global = limit.clone().try_acquire_owned().unwrap();
let response = live_response(subscription, global);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"video/mp2t"
);
assert_eq!(limit.available_permits(), before - 1);
assert_eq!(sink.receiver_count(), 1);
drop(response);
assert_eq!(limit.available_permits(), before);
assert_eq!(sink.receiver_count(), 0);
}
}
+2
View File
@@ -6,6 +6,8 @@ pub mod bilibili_endpoints;
pub mod endpoints;
/// 录制中直播间的封面 / 头像图片代理
pub mod live_media;
/// 直播预览:把正在录制的流旁路给页面内播放器
pub mod live_preview;
/// 单页应用静态文件处理
pub mod spa;
pub mod ws;
@@ -15,10 +15,12 @@ use crate::server::infrastructure::context::{Context, Stage, WorkerStatus};
use crate::server::infrastructure::models::hook_step::process;
use async_channel::Sender;
use biliup::downloader::live::{LivePlugin, LiveStatus, LiveStream};
use biliup::downloader::preview::PreviewHub;
use danmaku_client::DanmakuEvent;
use error_stack::ResultExt;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, Notify};
use tokio::sync::{Mutex, Notify, broadcast};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
@@ -125,6 +127,23 @@ pub struct LiveMedia {
pub avatar_url: Option<String>,
}
/// 正在录制的那一路流的来源:平台名与 CDN 直链。供浏览器直连模式(`preview_transport = direct`)
/// 判定能力、下发直链;随每次 `check_stream` 刷新(换直链 / 重试后是新的)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveSource {
pub platform: String,
pub url: String,
}
impl LiveSource {
pub fn from_stream(stream: &LiveStream) -> Self {
Self {
platform: stream.platform.clone(),
url: stream.raw_stream_url.clone(),
}
}
}
impl LiveMedia {
pub fn from_stream(stream: &LiveStream) -> Self {
let non_empty = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
@@ -135,6 +154,11 @@ impl LiveMedia {
}
}
/// 每路直播同时允许的预览连接数(信号量上限,超出返回 429)。
///
/// 与前端监视器的默认同屏路数无关:监视器每路占其对应直播间的一个连接。
pub const PREVIEW_MAX_SUBSCRIBERS_PER_ROOM: usize = 4;
/// 下载任务
pub struct DownloadTask {
token: CancellationToken,
@@ -144,12 +168,27 @@ pub struct DownloadTask {
/// 写盘速率表;各下载器拿它的计数器句柄累加,采样任务随 `execute` 启停。
meter: Arc<RateMeter>,
media: std::sync::RwLock<LiveMedia>,
/// 当前拉的那条直链与平台名,随 `check_stream` 刷新
source: std::sync::RwLock<LiveSource>,
/// 直播预览 hub,寿命与本任务相同:跨分段、跨断流重试都是同一个。
preview: PreviewHub,
/// 实时弹幕广播:弹幕客户端每解出一条就 `send` 一份给预览播放器;
/// 平台没有弹幕实现(`stream.danmaku` 为 None)时为 `None`。
danmaku_tx: Option<broadcast::Sender<DanmakuEvent>>,
}
/// 每路实时弹幕广播的槽位数;掉队的订阅者跳过丢掉的那几条继续收,不断开。
pub const DANMAKU_BROADCAST_CAPACITY: usize = 256;
impl DownloadTask {
pub fn new(downloader: DownloaderRuntime, stream: &LiveStream) -> Self {
let sync_session = matches!(&downloader, DownloaderRuntime::Sync(_))
.then(|| Arc::new(Mutex::new(SyncSession::default())));
let preview = preview_hub_for(&downloader);
let danmaku_tx = stream
.danmaku
.as_ref()
.map(|_| broadcast::channel(DANMAKU_BROADCAST_CAPACITY).0);
Self {
token: CancellationToken::new(),
done_notify: Notify::new(),
@@ -157,9 +196,32 @@ impl DownloadTask {
sync_session,
meter: Arc::new(RateMeter::new()),
media: std::sync::RwLock::new(LiveMedia::from_stream(stream)),
source: std::sync::RwLock::new(LiveSource::from_stream(stream)),
preview,
danmaku_tx,
}
}
/// 当前正在录制的那条流的平台名与 CDN 直链。
pub fn live_source(&self) -> LiveSource {
self.source.read().unwrap().clone()
}
/// 本任务的直播预览 hub。
pub fn preview(&self) -> &PreviewHub {
&self.preview
}
/// 这一路有没有弹幕客户端(平台实现了弹幕且该流带弹幕源)。
pub fn danmaku_available(&self) -> bool {
self.danmaku_tx.is_some()
}
/// 订阅实时弹幕;没有弹幕客户端的平台返回 `None`。
pub fn subscribe_danmaku(&self) -> Option<broadcast::Receiver<DanmakuEvent>> {
self.danmaku_tx.as_ref().map(|tx| tx.subscribe())
}
/// 最近一个滑动窗口内的写盘速率(字节/秒)。
///
/// 边录边传与 yt-dlp 的字节不经过计数器,报告 `None` 而不是一个恒为 0 的假数。
@@ -176,6 +238,7 @@ impl DownloadTask {
}
fn refresh_media(&self, ctx: &Context, stream: &LiveStream) {
*self.source.write().unwrap() = LiveSource::from_stream(stream);
let media = LiveMedia::from_stream(stream);
let changed = self.media.read().unwrap().avatar_url != media.avatar_url;
if changed {
@@ -213,6 +276,7 @@ impl DownloadTask {
stream.danmaku.as_ref(),
filename_prefix.as_deref(),
&stream.name,
self.danmaku_tx.clone(),
);
// 启动弹幕客户端
if let Some(ref client) = danmaku_client {
@@ -353,6 +417,7 @@ impl DownloadTask {
let streamer = ctx.live_streamer();
let mut download_config = ctx.download_config(stream);
download_config.bytes_written = self.meter.counter();
download_config.preview = self.preview.clone();
if let crate::server::core::downloader::DownloaderRuntime::Sync(sync) = &self.downloader {
info!(
page_url = streamer.url,
@@ -436,6 +501,25 @@ impl DownloadTask {
}
}
/// 按下载器类型建预览 hub:媒体字节经过本进程写盘的(stream-gears / mesio)能旁路;
/// 子进程直接落盘的,以及边录边传,明确标为不可预览并给出原因,界面据此禁用按钮。
fn preview_hub_for(downloader: &DownloaderRuntime) -> PreviewHub {
match downloader {
DownloaderRuntime::StreamGears(_) | DownloaderRuntime::Mesio(_) => {
PreviewHub::new(PREVIEW_MAX_SUBSCRIBERS_PER_ROOM)
}
DownloaderRuntime::Ffmpeg(_) => {
PreviewHub::unavailable("ffmpeg 子进程直接写盘,媒体数据不经过 biliup,无法预览")
}
DownloaderRuntime::StreamLink(_) => {
PreviewHub::unavailable("streamlink 子进程直接写盘,媒体数据不经过 biliup,无法预览")
}
DownloaderRuntime::YtDlp(_) => PreviewHub::unavailable(
"yt-dlp / ytarchive 子进程直接写盘,媒体数据不经过 biliup,无法预览",
),
DownloaderRuntime::Sync(_) => PreviewHub::unavailable("边录边传走投稿管线,不提供预览"),
}
}
/// Whether a finished download attempt counts as progress for live-retry backoff.
///
@@ -527,8 +611,12 @@ mod tests {
#[test]
fn segment_completed_and_stream_ended_count_as_progress() {
assert!(download_attempt_progressed(&Ok(DownloadStatus::SegmentCompleted)));
assert!(download_attempt_progressed(&Ok(DownloadStatus::StreamEnded)));
assert!(download_attempt_progressed(&Ok(
DownloadStatus::SegmentCompleted
)));
assert!(download_attempt_progressed(&Ok(
DownloadStatus::StreamEnded
)));
}
#[test]
@@ -536,9 +624,10 @@ mod tests {
assert!(!download_attempt_progressed(&Ok(DownloadStatus::Error(
"Streamlink error: Some(1)".into()
))));
assert!(!download_attempt_progressed(&Ok(DownloadStatus::Downloading)));
let err: AppResult<DownloadStatus> =
Err(Report::new(AppError::Custom("boom".into())));
assert!(!download_attempt_progressed(&Ok(
DownloadStatus::Downloading
)));
let err: AppResult<DownloadStatus> = Err(Report::new(AppError::Custom("boom".into())));
assert!(!download_attempt_progressed(&err));
}
+17
View File
@@ -7,6 +7,17 @@ use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, path::Path, path::PathBuf};
use struct_patch::Patch;
/// 直播预览的取流方式,见 [`Config::preview_transport`]。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PreviewTransport {
/// 经 biliup 中转:复用正在写盘的那一路,不多拉 CDN
#[default]
Relay,
/// 浏览器直连 CDN:每个观众自己拉一路,省服务器出口带宽
Direct,
}
/// 全局配置结构体
#[derive(bon::Builder, Debug, PartialEq, Clone, Serialize, Deserialize, Patch)]
#[patch(attribute(derive(Debug, Clone, Default, Deserialize, Serialize)))]
@@ -94,6 +105,12 @@ pub struct Config {
#[serde(default = "default_pool2_size")]
pub pool2_size: u32,
/// 直播预览的取流方式:`relay`(默认,复用正在录制的那一路、经 biliup 中转,不多拉 CDN)
/// 或 `direct`(浏览器直接向 CDN 拉一路,省服务器带宽;平台不支持时自动回落中转)。
/// `None` 视同 `relay`。
#[serde(default)]
pub preview_transport: Option<PreviewTransport>,
// ===== 各平台录播设置 =====
/// 是否使用直播封面
#[serde(default)]
@@ -90,6 +90,11 @@ impl DownloadManager {
.cloned()
}
/// 按房间 URL 找到对应的平台插件。
pub async fn plugin_for(&self, url: &str) -> Option<Arc<dyn LivePlugin + Send + Sync>> {
self.rooms_handle.plugin_for(url).await
}
pub async fn cleanup(&self) {
let vec = self.rooms_handle.get_all().await;
for worker in vec {
@@ -20,6 +20,7 @@ use crate::server::core::downloader::sync_downloader::SyncDownloader;
use crate::server::core::downloader::ytdlp::YouTubeDownloader;
use crate::server::errors::{AppError, AppResult};
use async_trait::async_trait;
use biliup::downloader::preview::PreviewHub;
use biliup::downloader::util::ByteCounter;
use danmaku_client::{DanmakuRecorder, RecorderConfig, RecorderHandle};
use error_stack::Report;
@@ -59,6 +60,12 @@ pub struct DownloadConfig {
/// 只是一个原子计数器的句柄:不参与序列化,也不影响任何下载控制流。
#[serde(skip)]
pub bytes_written: ByteCounter,
/// 本次录制任务的直播预览 hub。进程内写盘的下载器(stream-gears / mesio)开始拉流时
/// 从它 `attach` 一个写入端,在写盘点旁边旁路媒体字节;其它下载器不碰它。
/// 与 `bytes_written` 一样只是句柄,不参与序列化,不影响任何下载控制流。
#[serde(skip)]
pub preview: PreviewHub,
}
impl DownloadConfig {
@@ -11,8 +11,10 @@ use crate::server::core::downloader::{
self, DownloadConfig, DownloadStatus, SegmentEvent, SegmentInfo,
};
use crate::server::errors::{AppError, AppResult};
use biliup::downloader::preview::{self, ChunkKind, PreviewFormat, PreviewSink};
use biliup::downloader::util::ByteCounter;
use error_stack::Report;
use flv::{CodecKind, FlvData};
use flv_fix::{
ContinuityMode, FlvPipeline, FlvPipelineConfig, FlvWriter, FlvWriterConfig, ScriptFillerConfig,
};
@@ -43,6 +45,9 @@ const CHANNEL_SIZE: usize = 64;
/// 分段回调载荷:已关闭的分段文件路径与 0 起始的序号。
type SegmentClosed = (PathBuf, u32);
/// 直播预览旁路:写入端与「把一个管线条目旁路给它」的函数。
type PreviewTee<I> = (PreviewSink, fn(&mut PreviewSink, &I));
/// mesio 下载器实例。可跨多次 `download` 复用,每次调用使用新的取消令牌。
pub struct Mesio {
token: RwLock<CancellationToken>,
@@ -146,6 +151,7 @@ impl Mesio {
} = session;
let event_task = tokio::spawn(log_events(events));
let items = items.map(|r| r.map_err(|e| PipelineError::Strategy(Box::new(e))));
let preview = download_config.preview.attach(PreviewFormat::Flv);
let outcome = run_pipeline::<FlvPipeline, _>(
&pipeline_config,
flv_pipeline_config,
@@ -156,6 +162,7 @@ impl Mesio {
token.clone(),
callback.as_mut(),
(download_config.bytes_written.clone(), |item| item.size()),
Some((preview, tee_flv)),
)
.await;
handle.cancel();
@@ -188,6 +195,16 @@ impl Mesio {
};
let extension = hls_extension(&first);
warn_on_suffix_mismatch(&download_config.suffix, extension);
// TS 分片给浏览器里的 mpegts.js;fMP4 分片直接交给 MediaSource
let format = if first.is_ts() {
PreviewFormat::MpegTs
} else {
PreviewFormat::Fmp4
};
let preview = Some((
download_config.preview.attach(format),
tee_hls as fn(&mut PreviewSink, &HlsData),
));
let mut writer = HlsWriter::new(HlsWriterConfig {
output_dir,
base_name,
@@ -209,6 +226,7 @@ impl Mesio {
token.clone(),
callback.as_mut(),
(download_config.bytes_written.clone(), |item| item.size()),
preview,
)
.await;
handle.cancel();
@@ -342,6 +360,9 @@ fn segment_complete_hook(
/// `byte_meter`:写盘速率计数器与「一个管线条目占多少字节」的取值函数。
/// 修复管线的 writer 来自外部 crate,拿不到逐条写出量,因此在条目进入管线前计数;
/// 修复只增删极少量 tag,与实际落盘量相差可忽略。
///
/// `preview`:直播预览的写入端与「把一个条目旁路给它」的函数,同样在条目进入管线前
/// 调用(预览拿到的是拉到的原始流,不含修复管线的改动)。只是 push,不会失败、不 await。
#[allow(clippy::too_many_arguments)]
async fn run_pipeline<'a, P, W>(
common: &PipelineConfig,
@@ -353,6 +374,7 @@ async fn run_pipeline<'a, P, W>(
token: CancellationToken,
callback: &mut (dyn FnMut(SegmentEvent) + Send + Sync + 'a),
byte_meter: (ByteCounter, fn(&P::Item) -> usize),
preview: Option<PreviewTee<P::Item>>,
) -> Result<WriterStats, String>
where
P: PipelineProvider,
@@ -370,10 +392,14 @@ where
let forward = tokio::spawn(async move {
let (bytes_written, item_size) = byte_meter;
let mut preview = preview;
let mut items = items;
while let Some(item) = items.next().await {
if let Ok(item) = &item {
bytes_written.add(item_size(item) as u64);
if let Some((sink, tee)) = preview.as_mut() {
tee(sink, item);
}
}
if input_tx.send(item).await.is_err() {
debug!("mesio 管线已关闭,停止转发");
@@ -405,6 +431,63 @@ where
}
}
/// 把一个 mesio FLV 条目旁路给直播预览,重新编成与 FLV 文件一致的字节排列。
///
/// 类型判定沿用 `flv` crate 对 tag 的分类;修复管线的控制项(`Split` / `EndOfSequence`)
/// 不是媒体字节,跳过。视频序列头是 HEVC 时把 hub 标为不可预览(Chrome 的 MSE 放不了),
/// 录制本身不受影响。
fn tee_flv(sink: &mut PreviewSink, item: &FlvData) {
match item {
FlvData::Header(header) => {
if let Ok(bytes) = flv::encode::encode_header_bytes(header) {
sink.push(ChunkKind::Header, bytes::Bytes::copy_from_slice(&bytes));
}
}
FlvData::Tag(tag) => {
let class = tag.classification();
let kind = if tag.is_script_tag() {
ChunkKind::SequenceHeader(preview::flv::TAG_SCRIPT)
} else if tag.is_audio_sequence_header() {
ChunkKind::SequenceHeader(preview::flv::TAG_AUDIO)
} else if tag.is_video_sequence_header() {
if class.codec == Some(CodecKind::Hevc) {
sink.mark_unavailable(
"视频为 HEVC 编码,浏览器内的播放器无法解码,录制不受影响",
);
}
ChunkKind::SequenceHeader(preview::flv::TAG_VIDEO)
} else if tag.is_video_tag() && class.keyframe_media {
ChunkKind::Keyframe
} else {
ChunkKind::Media
};
let tag_type = u8::from(tag.tag_type()) | if tag.is_filtered() { 0x20 } else { 0 };
sink.push(
kind,
preview::flv::tag_chunk(tag_type, tag.timestamp_ms, tag.data()),
);
}
FlvData::Split(_) | FlvData::EndOfSequence(_) => {}
}
}
/// 把一个 mesio HLS 分片旁路给直播预览。
///
/// TS:每个分片自含(带 PAT/PMT),整片作为一个分块推送,起点是否关键帧由写入端嗅探决定。
/// fMP4:init segment(ftyp + moov)作为文件头进快照;分段按 moof + mdat 切成分片,
/// 视频首帧是关键帧的分片作为 GOP 起点(B 站的 fmp4 分段边界不对齐关键帧,不能按分段起播)。
fn tee_hls(sink: &mut PreviewSink, item: &HlsData) {
let Some(data) = item.data() else { return };
if item.is_mp4_init() {
sink.push(ChunkKind::Header, data.clone());
} else if item.is_mp4() {
// 分段里可能有多个 moof/mdat 对,且分段边界未必是关键帧:按分片切开、关键帧分片起 GOP
sink.push_fmp4_segment(data.clone());
} else {
sink.push_ts_segment_start(data.clone());
}
}
/// 把引擎事件写进日志。进度事件只在 debug 级别记录,避免刷屏。
async fn log_events(mut events: mesio_engine::DownloadEventStream) {
while let Some(event) = events.next().await {
@@ -531,8 +614,10 @@ mod tests {
output_dir: dir.path().to_path_buf(),
suffix: "flv".to_string(),
bytes_written: ByteCounter::new(),
preview: Default::default(),
};
let bytes_written = config.bytes_written.clone();
let preview = config.preview.clone();
let seen: Arc<Mutex<Vec<SegmentInfo>>> = Arc::default();
let sink = seen.clone();
@@ -554,6 +639,14 @@ mod tests {
payload_len as u64,
"进入管线的字节应等于源 FLV 的长度(文件头 + 全部 tag)"
);
// 预览 hub 在 FLV 会话开始时被 attach 为 FLV,会话结束后写入端已 drop 但格式保留
let preview_status = preview.status();
assert!(preview_status.available);
assert_eq!(
preview_status.format,
Some(biliup::downloader::preview::PreviewFormat::Flv)
);
assert!(!preview.is_attached());
let seen = seen.lock().unwrap();
assert!(
seen.len() >= 2,
@@ -569,6 +662,191 @@ mod tests {
}
}
/// mesio FLV 条目旁路:文件头 / script / 序列头 / 关键帧 / 普通帧各归其位,
/// 新订阅者拿到「文件头 + onMetaData + 序列头 + 从关键帧起的 GOP」,字节排列与 FLV 文件一致。
#[tokio::test]
async fn tee_flv_rebuilds_a_playable_flv_prefix_for_new_subscribers() {
use biliup::downloader::preview::{PreviewFormat, PreviewHub};
use flv::{FlvHeader, FlvTag, FlvTagType};
let tag = |tag_type: FlvTagType, ts: u32, data: &[u8]| {
FlvData::Tag(FlvTag::new(
ts,
0,
tag_type,
false,
bytes::Bytes::copy_from_slice(data),
))
};
let hub = PreviewHub::new(4);
let mut sink = hub.attach(PreviewFormat::Flv);
tee_flv(&mut sink, &FlvData::Header(FlvHeader::new(true, true)));
tee_flv(
&mut sink,
&tag(FlvTagType::ScriptData, 0, b"\x02\x00\x0aonMetaData\x05"),
);
tee_flv(
&mut sink,
&tag(FlvTagType::Audio, 0, &[0xaf, 0x00, 0x12, 0x10]),
);
tee_flv(
&mut sink,
&tag(FlvTagType::Video, 0, &[0x17, 0x00, 0, 0, 0, 0x01, 0x64]),
);
// 第一个 GOP 会被第二个关键帧替掉
tee_flv(
&mut sink,
&tag(FlvTagType::Video, 0, &[0x17, 0x01, 0, 0, 0, 0xa1]),
);
tee_flv(
&mut sink,
&tag(FlvTagType::Video, 40, &[0x27, 0x01, 0, 0, 0, 0xb1]),
);
tee_flv(
&mut sink,
&tag(FlvTagType::Video, 80, &[0x17, 0x01, 0, 0, 0, 0xa2]),
);
tee_flv(&mut sink, &tag(FlvTagType::Audio, 90, &[0xaf, 0x01, 0x21]));
tee_flv(&mut sink, &FlvData::Split(flv::SplitReason::SizeLimit));
let pending = tokio::spawn({
let hub = hub.clone();
async move { hub.subscribe(Duration::from_secs(5)).await }
});
tokio::time::sleep(Duration::from_millis(20)).await;
tee_flv(
&mut sink,
&tag(FlvTagType::Video, 120, &[0x27, 0x01, 0, 0, 0, 0xb2]),
);
let sub = pending.await.unwrap().unwrap();
let chunks = sub.snapshot;
assert_eq!(
chunks.len(),
1 + 3 + 3,
"header + 3 sequence headers + GOP of 3"
);
assert_eq!(
&chunks[0][..],
&[0x46, 0x4C, 0x56, 1, 5, 0, 0, 0, 9, 0, 0, 0, 0]
);
// 每个 tag 分块:11 字节头 + 载荷 + 4 字节 PreviousTagSize
let script = &chunks[1];
assert_eq!(script[0], 18);
assert_eq!(&script[script.len() - 4..], &(11u32 + 14).to_be_bytes());
assert_eq!(chunks[2][0], 8);
assert_eq!(&chunks[3][11..13], &[0x17, 0x00]);
assert_eq!(&chunks[4][11..17], &[0x17, 0x01, 0, 0, 0, 0xa2]);
assert_eq!(&chunks[4][4..8], &[0, 0, 80, 0], "timestamp 80 ms");
assert_eq!(&chunks[5][11..14], &[0xaf, 0x01, 0x21]);
assert_eq!(&chunks[6][11..17], &[0x27, 0x01, 0, 0, 0, 0xb2]);
assert!(hub.status().available);
}
#[test]
fn tee_flv_marks_hevc_streams_as_not_previewable() {
use biliup::downloader::preview::{PreviewFormat, PreviewHub};
use flv::{FlvTag, FlvTagType};
let hub = PreviewHub::new(4);
let mut sink = hub.attach(PreviewFormat::Flv);
// E-RTMP 序列头:0x90 = ExHeader | KeyFrame | SequenceStart,fourcc hvc1
let hevc = FlvData::Tag(FlvTag::new(
0,
0,
FlvTagType::Video,
false,
bytes::Bytes::from_static(&[0x90, b'h', b'v', b'c', b'1', 1, 2, 3]),
));
tee_flv(&mut sink, &hevc);
let status = hub.status();
assert!(!status.available);
assert!(status.reason.unwrap().contains("HEVC"));
}
#[tokio::test]
async fn tee_hls_forwards_ts_segments_as_keyframe_chunks() {
use biliup::downloader::preview::{PreviewFormat, PreviewHub};
let hub = PreviewHub::new(4);
let mut sink = hub.attach(PreviewFormat::MpegTs);
let segment = || m3u8_rs::MediaSegment {
uri: "1.ts".to_string(),
..Default::default()
};
let ts = HlsData::ts(
segment(),
bytes::Bytes::from_static(&[0x47, 0x40, 0x00, 0x10]),
);
tee_hls(&mut sink, &ts);
tee_hls(&mut sink, &HlsData::end_marker());
let pending = tokio::spawn({
let hub = hub.clone();
async move { hub.subscribe(Duration::from_secs(5)).await }
});
tokio::time::sleep(Duration::from_millis(20)).await;
tee_hls(&mut sink, &ts);
let sub = pending.await.unwrap().unwrap();
// 快照只含最近一个完整分片,且以 TS 同步字节开头
assert_eq!(sub.snapshot.len(), 1);
assert_eq!(sub.snapshot[0][0], 0x47);
}
/// fMP4:init segment 进快照并解出编码串,moof/mdat 分片按关键帧分块;
/// 新订阅者拿到 init + 最近一个完整分片。
#[tokio::test]
async fn tee_hls_keeps_the_fmp4_init_segment_for_new_subscribers() {
use biliup::downloader::preview::{PreviewFormat, PreviewHub};
fn bx(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = ((8 + body.len()) as u32).to_be_bytes().to_vec();
out.extend_from_slice(kind);
out.extend_from_slice(body);
out
}
let hub = PreviewHub::new(4);
let mut sink = hub.attach(PreviewFormat::Fmp4);
let segment = |uri: &str| m3u8_rs::MediaSegment {
uri: uri.to_string(),
..Default::default()
};
// 一个只含 avc1/avcC 的最小 init:ftyp + moov/trak/mdia/minf/stbl/stsd
let mut entry = vec![0u8; 78];
entry.extend_from_slice(&bx(b"avcC", &[1, 0x64, 0x00, 0x28]));
let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1];
stsd.extend_from_slice(&bx(b"avc1", &entry));
let trak = bx(
b"trak",
&bx(b"mdia", &bx(b"minf", &bx(b"stbl", &bx(b"stsd", &stsd)))),
);
let mut init = bx(b"ftyp", b"iso5");
init.extend_from_slice(&bx(b"moov", &trak));
let init = bytes::Bytes::from(init);
tee_hls(
&mut sink,
&HlsData::mp4_init(segment("init.mp4"), init.clone()),
);
let status = hub.status();
assert_eq!(status.format, Some(PreviewFormat::Fmp4));
assert_eq!(status.codecs.as_deref(), Some("avc1.640028"));
let seg1 = bytes::Bytes::from_static(b"moof1mdat1");
let seg2 = bytes::Bytes::from_static(b"moof2mdat2");
tee_hls(&mut sink, &HlsData::mp4_segment(segment("1.m4s"), seg1));
let pending = tokio::spawn({
let hub = hub.clone();
async move { hub.subscribe(Duration::from_secs(5)).await }
});
tokio::time::sleep(Duration::from_millis(20)).await;
tee_hls(
&mut sink,
&HlsData::mp4_segment(segment("2.m4s"), seg2.clone()),
);
let sub = pending.await.unwrap().unwrap();
assert_eq!(sub.snapshot, vec![init, seg2]);
}
#[test]
fn cancellation_is_a_normal_segment_end_not_an_error() {
let token = CancellationToken::new();
@@ -5,6 +5,7 @@ use crate::server::errors::{AppError, AppResult};
use biliup::client::StatelessClient;
use biliup::downloader::flv_parser::header;
use biliup::downloader::httpflv::Connection;
use biliup::downloader::preview::PreviewFormat;
use biliup::downloader::util::{LifecycleFile, Segmentable};
use biliup::downloader::{hls, httpflv};
use error_stack::{ResultExt, bail};
@@ -98,7 +99,9 @@ impl StreamGears {
// FLV流下载
let file = LifecycleFile::with_hook(&file_name, "flv", hook)
.with_counter(download_config.bytes_written.clone());
httpflv::download(connection, file, segment.clone()).await;
// 直播预览:写入端与这一次拉流同寿命,拉流结束即 drop
let preview = download_config.preview.attach(PreviewFormat::Flv);
httpflv::download(connection, file, segment.clone(), Some(preview)).await;
}
Err(Err::Incomplete(needed)) => {
error!("needed: {needed:?}")
@@ -108,7 +111,8 @@ impl StreamGears {
// HLS流下载
let file = LifecycleFile::with_hook(&file_name, "ts", hook)
.with_counter(download_config.bytes_written.clone());
hls::download(&url, &client, file, segment.clone())
let preview = download_config.preview.attach(PreviewFormat::MpegTs);
hls::download(&url, &client, file, segment.clone(), Some(preview))
.await
.change_context(AppError::Unknown)?;
}
+7 -1
View File
@@ -286,10 +286,13 @@ fn ytdlp_backend(
}
}
/// 构造弹幕客户端。`live_tx` 为直播预览的实时弹幕广播:客户端每解出一条就 `send` 一份
/// (不等待、无订阅者时丢弃),XML 录制不受影响。
pub fn danmaku_client(
source: Option<&DanmakuSource>,
filename_prefix: Option<&str>,
name: &str,
live_tx: Option<tokio::sync::broadcast::Sender<danmaku_client::DanmakuEvent>>,
) -> Option<Arc<dyn crate::server::core::downloader::DanmakuClient + Send + Sync>> {
let source = source?;
let mut context = PlatformContext::new();
@@ -303,13 +306,16 @@ pub fn danmaku_client(
context.movie_id = source.movie_id.clone();
context.password = source.password.clone();
let config = RecorderConfig::new(
let mut config = RecorderConfig::new(
source.url.clone(),
PathBuf::from(danmaku_filename_template(filename_prefix, name)),
)
.with_context(context)
.with_raw(source.raw)
.with_detail(source.detail);
if let Some(tx) = live_tx {
config = config.with_live_tx(tx);
}
Some(Arc::new(RustDanmakuClient::new(config)))
}
@@ -285,6 +285,20 @@ impl Monitor {
}
}
/// 按房间 URL 找到对应的平台插件。
pub async fn plugin_for(
self: &Arc<Self>,
url: &str,
) -> Option<Arc<dyn LivePlugin + Send + Sync>> {
let (send, recv) = oneshot::channel();
let msg = ActorMessage::PluginFor {
respond_to: send,
url: url.to_owned(),
};
let _ = self.sender.send(msg).await;
recv.await.ok().flatten()
}
/// 获取某平台当前队列中所有房间的 URL(用于批量检测)。
async fn platform_urls(self: &Arc<Self>, platform_name: &str) -> Vec<String> {
let (send, recv) = oneshot::channel();
@@ -488,6 +502,11 @@ enum ActorMessage {
respond_to: oneshot::Sender<Vec<String>>,
platform_name: String,
},
/// 按房间 URL 找到对应平台插件(预览直连模式向平台要一条新直链时用)
PluginFor {
respond_to: oneshot::Sender<Option<Arc<dyn LivePlugin + Send + Sync>>>,
url: String,
},
/// 添加工作器
Add(
oneshot::Sender<Option<Arc<dyn LivePlugin + Send + Sync>>>,
@@ -563,6 +582,9 @@ impl RoomsActor {
// 如果使用`select!`宏取消等待响应,可能会发生这种情况
let _ = respond_to.send(self.next(&platform_name));
}
ActorMessage::PluginFor { respond_to, url } => {
let _ = respond_to.send(self.matches(&url));
}
ActorMessage::PlatformUrls {
respond_to,
platform_name,
@@ -130,6 +130,7 @@ impl Context {
output_dir: PathBuf::from("."),
suffix,
bytes_written: Default::default(),
preview: Default::default(),
}
}
}
@@ -1,6 +1,46 @@
use crate::server::infrastructure::models::live_streamer::LiveStreamer;
use biliup::downloader::preview::PreviewStatus;
use serde::Serialize;
/// 正在录制的直播间能否在页面内预览(复用正在录制的那一路流)。
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
pub struct LivePreviewResponse {
/// 当前下载器能否提供预览。为 `true` 时 `format` 仍可能是 `null`(刚开始拉流、容器未定)
pub available: bool,
/// `flv` / `mpegts` / `fmp4`,即 `GET /v1/streamers/{id}/live` 的容器,与其 `Content-Type` 一致
pub format: Option<&'static str>,
/// `fmp4` 时从 init segment 解出的 RFC 6381 编码串(如 `avc1.64001f,mp4a.40.2`),
/// 供前端 `MediaSource.isTypeSupported()` 校验后 `addSourceBuffer`;其它容器为 `null`
pub codecs: Option<String>,
/// 不可预览的原因(ffmpeg / streamlink 子进程落盘、HEVC FLV 等)
pub reason: Option<String>,
/// 这一路有没有实时弹幕(平台实现了弹幕客户端),有则 `GET /v1/streamers/{id}/danmaku` 可用
pub danmaku: bool,
/// 浏览器能否直连 CDN 拉这一路(`preview_transport = direct` 时前端据此选直连或回落中转)
pub direct: DirectCapability,
}
/// 浏览器直连 CDN 的能力判定(按平台 CDN 的跨域放行与并发策略实测得出)。
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
pub struct DirectCapability {
pub capable: bool,
/// 不能直连的原因;`capable = true` 时为 `null`
pub reason: Option<String>,
}
impl LivePreviewResponse {
pub fn new(status: PreviewStatus, danmaku: bool, direct: DirectCapability) -> Self {
Self {
available: status.available,
format: status.format.map(|f| f.as_str()),
codecs: status.codecs,
reason: status.reason,
danmaku,
direct,
}
}
}
/// 直播主播响应数据传输对象
/// 包含主播信息和当前工作状态
#[derive(Serialize)]
@@ -21,4 +61,6 @@ pub struct LiveStreamerResponse {
pub live_cover_url: Option<String>,
/// 正在录制的主播头像地址;图片本身请走 `GET /v1/streamers/{id}/avatar` 代理。
pub live_avatar_url: Option<String>,
/// 正在录制时的预览能力;未录制为 `null`。视频流走 `GET /v1/streamers/{id}/live`。
pub preview: Option<LivePreviewResponse>,
}
+10
View File
@@ -10,6 +10,9 @@ use crate::server::api::endpoints::{
put_configuration, put_streamers_endpoint,
};
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::infrastructure::service_register::ServiceRegister;
use axum::Router;
use axum::body::Body;
@@ -37,6 +40,13 @@ pub fn router(service_register: ServiceRegister) -> Router<()> {
// 正在录制的直播间封面 / 主播头像(服务端带 Referer 转发,避开图片 CDN 防盗链)
.route("/v1/streamers/{id}/cover", get(get_live_cover))
.route("/v1/streamers/{id}/avatar", get(get_live_avatar))
// 直播预览:复用正在录制的那一路流(chunked FLV / MPEG-TS),同样在登录校验之内
.route("/v1/streamers/{id}/live", get(get_live_stream))
// 浏览器直连模式:当前录制中那条流的 CDN 直链
.route("/v1/streamers/{id}/live-url", get(get_live_url))
// 预览播放器的实时弹幕(SSE),同样在登录校验之内;监视器多路复用一条
.route("/v1/streamers/{id}/danmaku", get(get_live_danmaku))
.route("/v1/danmaku", get(get_live_danmaku_multi))
// 配置管理路由
.route(
"/v1/configuration",
+1
View File
@@ -4,6 +4,7 @@ pub mod flv_writer;
pub mod hls;
pub mod httpflv;
pub mod live;
pub mod preview;
pub mod util;
// fn retry<O, E: std::fmt::Display>(mut f: impl FnMut() -> Result<O, E>) -> Result<O, E> {
+26
View File
@@ -1,4 +1,5 @@
use crate::downloader::error::{Error, Result};
use crate::downloader::preview::{ChunkKind, PreviewSink};
use crate::downloader::util::{ByteCounter, LifecycleFile, Segmentable};
use m3u8_rs::{MediaPlaylist, Playlist};
@@ -26,11 +27,15 @@ fn playlist_should_refresh(playlist: &MediaPlaylist) -> bool {
!playlist.end_list
}
/// 轮询 m3u8 并把分片追加进同一个 `.ts` 文件。
///
/// `preview` 为直播预览的写入端:每个分片的字节在落盘的同时旁路一份给它,`None` 则不旁路。
pub async fn download(
url: &str,
client: &StatelessClient,
file: LifecycleFile<'_>,
mut splitting: Segmentable,
mut preview: Option<PreviewSink>,
) -> Result<()> {
info!("Downloading {}...", url);
let resp = client.retryable(url).await?;
@@ -101,6 +106,7 @@ pub async fn download(
client,
&mut ts_file.buf_writer,
&ts_file.file.bytes_written,
preview.as_mut(),
)
.await?;
splitting.increase_size(length);
@@ -140,14 +146,34 @@ async fn download_to_file(
client: &StatelessClient,
out: &mut impl Write,
bytes_written: &ByteCounter,
mut preview: Option<&mut PreviewSink>,
) -> Result<u64> {
debug!("url: {url}");
let mut response = client.retryable(url.as_str()).await?;
let mut length: u64 = 0;
// 分片起点即预览的关键帧边界(HLS 分片自带 PAT/PMT、从关键帧开始),
// 新订阅者从最近一个分片的开头起播
let mut segment_start = true;
while let Some(chunk) = response.chunk().await? {
length += chunk.len() as u64;
out.write_all(&chunk)?;
bytes_written.add(chunk.len() as u64);
if let Some(sink) = preview.as_deref_mut() {
if segment_start && chunk.first() != Some(&0x47) {
// 不是 TS 同步字节:多半是 fMP4(m4s)分片。这条路径没有下载 #EXT-X-MAP 的
// 初始化分片(录制文件同样如此),没有 init segment 就播不了,明确标为不可预览
sink.mark_unavailable(
"HLS 分片不是 MPEG-TS(可能是 fMP4),stream-gears 暂不支持预览此格式,可改用 mesio",
);
}
if segment_start {
// 分片起点:嗅探首个视频 PES 是否从 IDR 起,决定要不要作为新 GOP 的起点
sink.push_ts_segment_start(chunk);
} else {
sink.push(ChunkKind::Media, chunk);
}
}
segment_start = false;
}
// let mut out = File::options()
// .append(true)
+250 -4
View File
@@ -3,6 +3,7 @@ use crate::downloader::flv_parser::{
aac_audio_packet_header, avc_video_packet_header, script_data, tag_data, tag_header,
};
use crate::downloader::flv_writer::{FlvFile, FlvTag, TagDataHeader};
use crate::downloader::preview::{self, ChunkKind, PreviewSink};
use crate::downloader::util::{LifecycleFile, Segmentable};
use bytes::{Buf, BufMut, Bytes, BytesMut};
use nom::{Err, IResult};
@@ -12,9 +13,17 @@ use std::time::Duration;
use tokio::time::timeout;
use tracing::{info, warn};
pub async fn download(connection: Connection, file: LifecycleFile<'_>, segment: Segmentable) {
/// 下载 FLV 流并按关键帧分段落盘。
///
/// `preview` 为直播预览的写入端:每个写盘的 tag 顺带旁路一份给它,`None` 则不旁路。
pub async fn download(
connection: Connection,
file: LifecycleFile<'_>,
segment: Segmentable,
preview: Option<PreviewSink>,
) {
let file_name = file.file_name.clone();
match parse_flv(connection, file, segment).await {
match parse_flv(connection, file, segment, preview).await {
Ok(_) => {
info!("Done... {}", file_name);
}
@@ -28,6 +37,7 @@ pub(crate) async fn parse_flv(
mut connection: Connection,
file: LifecycleFile<'_>,
mut segment: Segmentable,
mut preview: Option<PreviewSink>,
) -> crate::downloader::error::Result<()> {
let mut flv_tags_cache: Vec<(TagHeader, Bytes, Bytes)> = Vec::new();
// println!("parse_flv Segment: {:?}", segment);
@@ -35,6 +45,12 @@ pub(crate) async fn parse_flv(
let mut out = FlvFile::new(file)?;
segment.set_size_position(9 + 4);
if let Some(sink) = preview.as_mut() {
sink.push(
ChunkKind::Header,
Bytes::from_static(&preview::flv::FILE_HEADER),
);
}
// let mut downloaded_size = 9 + 4;
let mut on_meta_data = None;
let mut aac_sequence_header = None;
@@ -157,6 +173,23 @@ pub(crate) async fn parse_flv(
);
}
out.write_tag(tag_header, flv_tag_data, previous_tag_size_bytes)?;
// 直播预览旁路:与写盘同一个 tag、同一份字节。只是 push,不 await、
// 不返回错误——预览端的任何状况都不改变这里的控制流。
if let Some(sink) = preview.as_mut() {
let tag_type = tag_header.tag_type as u8;
sink.push(
preview::flv::classify(tag_type, flv_tag_data),
preview::flv::tag_chunk_from_parts(
&preview::flv::tag_header(
tag_type,
tag_header.data_size,
tag_header.timestamp,
),
flv_tag_data,
previous_tag_size_bytes,
),
);
}
segment.increase_size((11 + tag_header.data_size + 4) as u64);
// downloaded_size += (11 + tag_header.data_size + 4) as u64;
prev_timestamp = tag_header.timestamp
@@ -386,7 +419,220 @@ mod tests {
let segment = Segmentable::new(None, Some(1));
// 修复前:此调用会 panic(aac_sequence_header does not exist)。
super::parse_flv(connection, file, segment).await?;
super::parse_flv(connection, file, segment, None).await?;
Ok(())
}
/// 一段带 onMetaData、AVC 序列头与 `keyframes` 个 GOP(每 GOP 一个关键帧 + 两个普通帧)
/// 的 FLV 流体(不含 9 字节文件头,含起始的 PreviousTagSize0)。
fn gop_flv_body(keyframes: u32) -> Vec<u8> {
fn tag(data: &mut Vec<u8>, tag_type: u8, ts: u32, body: &[u8]) {
data.push(tag_type);
data.extend_from_slice(&(body.len() as u32).to_be_bytes()[1..]);
data.extend_from_slice(&(ts & 0xff_ffff).to_be_bytes()[1..]);
data.push((ts >> 24) as u8);
data.extend_from_slice(&[0, 0, 0]);
data.extend_from_slice(body);
data.extend_from_slice(&((11 + body.len()) as u32).to_be_bytes());
}
let mut data = vec![0, 0, 0, 0];
tag(
&mut data,
18,
0,
&[
0x02, 0x00, 0x0A, b'o', b'n', b'M', b'e', b't', b'a', b'D', b'a', b't', b'a', 0x05,
],
);
// AVC 序列头(packet_type 0)
tag(&mut data, 9, 0, &[0x17, 0x00, 0x00, 0x00, 0x00, 0x01, 0x64]);
for i in 0..keyframes {
let base = i * 3000;
// 关键帧 NALU(packet_type 1)+ 两个普通帧,载荷里带序号便于比对
let mut key = vec![0x17, 0x01, 0, 0, 0];
key.extend_from_slice(&i.to_be_bytes());
key.resize(300, 0xaa);
tag(&mut data, 9, base, &key);
tag(&mut data, 9, base + 1000, &[0x27, 0x01, 0, 0, 0, 0x11]);
tag(&mut data, 9, base + 2000, &[0x27, 0x01, 0, 0, 0, 0x22]);
}
data
}
fn concat(chunks: &[bytes::Bytes]) -> Vec<u8> {
chunks.iter().flat_map(|b| b.iter().copied()).collect()
}
/// 预览快照 + 实时分块拼起来,恰好等于「文件头 + 落盘的 tag 序列」的一段;
/// 跨多个分段文件(rolling)时连接不断、不重发 FLV 文件头。
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn preview_follows_the_written_tags_across_rolling_segments()
-> Result<(), Box<dyn std::error::Error>> {
use crate::downloader::preview::{PreviewFormat, PreviewHub};
use crate::downloader::util::{LifecycleFile, Segmentable};
use futures::StreamExt;
use std::sync::{Arc, Mutex};
use std::time::Duration;
// 按真实直播的节奏分块送入(每块之间隔 2 ms),而不是一次性给完:
// 瞬时灌完会让订阅者必然掉队(Lagged),那是另一个测试覆盖的场景
let body = gop_flv_body(120);
let pieces: Vec<Vec<u8>> = body.chunks(512).map(|c| c.to_vec()).collect();
let paced = futures::stream::iter(pieces).then(|piece| async move {
tokio::time::sleep(Duration::from_millis(2)).await;
Ok::<_, std::io::Error>(piece)
});
let http_resp = http::Response::builder()
.status(200)
.body(reqwest::Body::wrap_stream(paced))?;
let connection = super::Connection::new(reqwest::Response::from(http_resp));
// 文件名模板不含时间占位符时每个分段都叫同一个名字,会互相覆盖;
// 在 rename 钩子里把每个分段改成带序号的名字留下来(钩子触发时 BufWriter
// 可能还没 flush,所以只改名、等全部结束后再读)
let dir = tempfile::tempdir()?;
let file_stem = dir.path().join("rolling");
let segments: Arc<Mutex<Vec<std::path::PathBuf>>> = Arc::default();
let file = LifecycleFile::with_hook(file_stem.to_str().unwrap(), "flv", {
let segments = segments.clone();
let dir = dir.path().to_path_buf();
move |name: &str| {
let mut segments = segments.lock().unwrap();
let kept = dir.join(format!("seg-{:04}.flv", segments.len()));
std::fs::rename(name, &kept).unwrap();
segments.push(kept);
}
});
let hub = PreviewHub::new(4);
let sink = hub.attach(PreviewFormat::Flv);
// 每个分段 2 KB 左右,120 个 GOP 会切出二十多个文件
let segment = Segmentable::new(None, Some(2 * 1024));
// 先把订阅请求确定地排进写入端的队列(poll 一次即入队),再开始写盘,
// 这样快照一定在第一个关键帧被回应,预览覆盖整条流
let mut subscribe = Box::pin({
let hub = hub.clone();
async move { hub.subscribe(Duration::from_secs(5)).await }
});
assert!(futures::poll!(subscribe.as_mut()).is_pending());
let subscriber = tokio::spawn(async move {
let mut sub = subscribe.await.unwrap();
let mut live = Vec::new();
while let Ok(chunk) = sub.rx.recv().await {
live.extend_from_slice(&chunk);
}
(sub.snapshot, live)
});
super::parse_flv(connection, file, segment, Some(sink)).await?;
let (snapshot, live) = subscriber.await?;
// 快照 = 文件头 + onMetaData + AVC 序列头 + 从第一个关键帧起的 GOP
assert_eq!(
&snapshot[0][..],
&crate::downloader::preview::flv::FILE_HEADER
);
let is_key_nalu =
|c: &bytes::Bytes| c.len() > 12 && c[0] == 9 && c[11] == 0x17 && c[12] == 0x01;
let first_key = snapshot
.iter()
.position(is_key_nalu)
.expect("snapshot must contain a keyframe");
let seq_headers: Vec<u8> = snapshot[1..first_key].iter().map(|c| c[0]).collect();
assert_eq!(
seq_headers,
vec![18, 9],
"onMetaData + AVC sequence header before the GOP"
);
let mut received = concat(&snapshot);
received.extend_from_slice(&live);
// 整条流里不再出现第二个文件头
assert_eq!(
received[13..].windows(3).filter(|w| *w == b"FLV").count(),
0,
"existing subscribers must never get the FLV file header again"
);
// 落盘:多个分段,每个都以 FLV 头开始;预览收到的 tag 序列是落盘序列的后缀
let segments = segments.lock().unwrap();
assert!(
segments.len() > 5,
"expected rolling into many segments, got {}",
segments.len()
);
let mut written_tags = Vec::new();
for path in segments.iter() {
let data = std::fs::read(path)?;
assert_eq!(&data[..13], &crate::downloader::preview::flv::FILE_HEADER);
written_tags.extend_from_slice(&data[13..]);
}
// 预览 = 快照里的序列头 + 从第一个关键帧起的全部 tag,与落盘的 tag 序列完全一致
let preview_tags = &received[13..];
assert_eq!(preview_tags.len(), written_tags.len());
assert!(written_tags.ends_with(preview_tags));
Ok(())
}
/// 一个从不读取的订阅者在场时,录制照常结束、落盘内容与无订阅者时完全一致。
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stalled_preview_subscriber_does_not_change_what_is_written()
-> Result<(), Box<dyn std::error::Error>> {
use crate::downloader::preview::{PreviewFormat, PreviewHub, PreviewSink};
use crate::downloader::util::{LifecycleFile, Segmentable};
use std::time::Duration;
// 足够多的 tag,让掉队的订阅者远超广播缓冲容量
let body = gop_flv_body(PreviewFormat::Flv.broadcast_capacity() as u32 * 2);
let run = |body: Vec<u8>, dir: &std::path::Path, sink: Option<PreviewSink>| {
let dir = dir.to_path_buf();
async move {
let http_resp = http::Response::builder().status(200).body(body).unwrap();
let connection = super::Connection::new(reqwest::Response::from(http_resp));
let file_stem = dir.join("rec");
let file = LifecycleFile::new(file_stem.to_str().unwrap(), "flv");
let started = std::time::Instant::now();
super::parse_flv(connection, file, Segmentable::new(None, None), sink)
.await
.unwrap();
let data = std::fs::read(dir.join("rec.flv")).unwrap();
(data, started.elapsed())
}
};
let plain_dir = tempfile::tempdir()?;
let (plain, _) = run(body.clone(), plain_dir.path(), None).await;
let hub = PreviewHub::new(4);
let sink = hub.attach(PreviewFormat::Flv);
let mut subscribe = Box::pin({
let hub = hub.clone();
async move { hub.subscribe(Duration::from_secs(5)).await }
});
assert!(futures::poll!(subscribe.as_mut()).is_pending());
let stalled = tokio::spawn(async move {
let sub = subscribe.await.unwrap();
// 拿到订阅后一个字节都不读,直到写入端结束
tokio::time::sleep(Duration::from_millis(1500)).await;
sub
});
let teed_dir = tempfile::tempdir()?;
let (teed, elapsed) = run(body, teed_dir.path(), Some(sink)).await;
assert_eq!(
teed, plain,
"the recording must not depend on preview subscribers"
);
assert!(
elapsed < Duration::from_secs(5),
"the producer must not wait for a stalled subscriber ({elapsed:?})"
);
let mut sub = stalled.await?;
assert!(matches!(
sub.rx.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_))
| Err(tokio::sync::broadcast::error::TryRecvError::Closed)
));
Ok(())
}
@@ -410,7 +656,7 @@ mod tests {
let file =
LifecycleFile::new(file_stem.to_str().unwrap(), "flv").with_counter(counter.clone());
super::parse_flv(connection, file, Segmentable::new(None, None)).await?;
super::parse_flv(connection, file, Segmentable::new(None, None), None).await?;
assert_eq!(counter.total(), 11 + 14 + 4);
Ok(())
}
File diff suppressed because it is too large. Load diff
+24 -1
View File
@@ -12,7 +12,7 @@ use futures::{SinkExt, StreamExt};
use rustls_platform_verifier::BuilderVerifierExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, oneshot, watch};
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use tokio::time::interval;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
@@ -20,6 +20,7 @@ use tokio_tungstenite::{Connector, connect_async_tls_with_config};
use tracing::{debug, error, info, warn};
use crate::error::{DanmakuError, Result};
use crate::message::DanmakuEvent;
use crate::output::xml::{XmlWriter, XmlWriterConfig};
use crate::protocols::{
ConnectionInfo, ConnectionTransport, DecodeResult, HeartbeatData, Platform, PlatformContext,
@@ -58,6 +59,10 @@ pub struct RecorderConfig {
pub save_raw: bool,
/// Whether to save detailed info.
pub save_detail: bool,
/// Optional live tee: every decoded event is also `send` to this broadcast channel
/// (best effort — no subscribers or a full ring just drops the copy). Used by the
/// web UI's live preview to overlay danmaku; never affects the XML recording.
pub live_tx: Option<broadcast::Sender<DanmakuEvent>>,
}
impl RecorderConfig {
@@ -69,9 +74,16 @@ impl RecorderConfig {
context: PlatformContext::new(),
save_raw: false,
save_detail: false,
live_tx: None,
}
}
/// Also broadcast every decoded event to `tx` (see [`RecorderConfig::live_tx`]).
pub fn with_live_tx(mut self, tx: broadcast::Sender<DanmakuEvent>) -> Self {
self.live_tx = Some(tx);
self
}
/// Set the platform context.
pub fn with_context(mut self, context: PlatformContext) -> Self {
self.context = context;
@@ -148,6 +160,14 @@ impl DanmakuRecorder {
})
}
/// Tee one decoded event to the live broadcast, if configured. `send` never waits:
/// with no receivers the copy is dropped, with a full ring the oldest is overwritten.
fn emit_live(&self, event: &DanmakuEvent) {
if let Some(tx) = &self.config.live_tx {
let _ = tx.send(event.clone());
}
}
/// Start recording in a background task.
///
/// Returns a handle that can be used to control the recorder.
@@ -299,6 +319,7 @@ impl DanmakuRecorder {
_ = ticker.tick() => {
let events = self.platform.poll_messages(&self.config.url, &mut context).await?;
for event in events {
self.emit_live(&event);
if let Err(e) = xml_writer.write_event(&event) {
warn!("Failed to write event: {}", e);
}
@@ -456,6 +477,7 @@ impl DanmakuRecorder {
Ok(result) => {
// Write decoded events
for event in result.events {
self.emit_live(&event);
if let Err(e) = xml_writer.write_event(&event) {
warn!("Failed to write event: {}", e);
}
@@ -567,6 +589,7 @@ impl DanmakuRecorder {
match decode_message_guarded(self.platform.as_ref(), &frame, platform_name) {
Ok(result) => {
for event in result.events {
self.emit_live(&event);
if let Err(e) = xml_writer.write_event(&event) {
warn!("Failed to write event: {}", e);
}
+4 -2
View File
@@ -50,7 +50,7 @@ pub async fn download_with_hook(
debug!("header: {header:#?}");
info!("Downloading {}...", url);
let file = LifecycleFile::with_hook(file_name, "flv", file_name_hook);
httpflv::download(connection, file, segment).await;
httpflv::download(connection, file, segment, None).await;
}
Err(nom::Err::Incomplete(needed)) => {
error!("needed: {needed:?}")
@@ -58,7 +58,9 @@ pub async fn download_with_hook(
Err(e) => {
error!("{e}");
let file = LifecycleFile::with_hook(file_name, "ts", file_name_hook);
hls::download(url, &client, file, segment).await.unwrap();
hls::download(url, &client, file, segment, None)
.await
.unwrap();
}
}
Ok(())
+14
View File
@@ -10,6 +10,8 @@
"dependencies": {
"@douyinfe/semi-ui": "^2.103.0",
"artplayer": "^5.4.0",
"artplayer-plugin-danmuku": "^5.3.0",
"hls.js": "^1.7.3",
"mpegts.js": "^1.8.2",
"next": "^16.3.5",
"qrcode.react": "^4.2.0",
@@ -3295,6 +3297,12 @@
"option-validator": "^2.0.6"
}
},
"node_modules/artplayer-plugin-danmuku": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/artplayer-plugin-danmuku/-/artplayer-plugin-danmuku-5.3.0.tgz",
"integrity": "sha512-a76bU1cWEZr53hD1X8IHfOzS4QawGHn8dQ3fIYyJ5TsyhTSnznhi2WzkS8M7LL3H02ekXRZTUFan9+9EERk12g==",
"license": "MIT"
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmmirror.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -5357,6 +5365,12 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+2
View File
@@ -11,6 +11,8 @@
"dependencies": {
"@douyinfe/semi-ui": "^2.103.0",
"artplayer": "^5.4.0",
"artplayer-plugin-danmuku": "^5.3.0",
"hls.js": "^1.7.3",
"mpegts.js": "^1.8.2",
"next": "^16.3.5",
"qrcode.react": "^4.2.0",