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:
39 files changed
+5684
-92
No files matched your search
Generated
+1
@@ -484,6 +484,7 @@ dependencies = [
|
||||
"dialoguer",
|
||||
"dirs",
|
||||
"error-stack",
|
||||
"flv",
|
||||
"flv-fix",
|
||||
"futures",
|
||||
"glob",
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user