Files
biliup/app/ui/OverrideModal.tsx
T
Noah 67e49354ad fix(recorder): stop stream-gears losing the tail, default to mesio (#1733)
* fix(stream-gears): write the cached GOP on disconnect, flush before the segment hook

The httpflv loop kept the current GOP in `flv_tags_cache` and only wrote it
when the next keyframe arrived. On EOF, a 30 s read timeout or a parse error
the function returned and the cache was dropped, so every disconnect,
reconnect and stream end lost the last 1-10 s. The loop now runs in an inner
function; whatever way it exits, the complete tags in the cache are written
before the original result (including the error) is returned.

`FlvFile` / `TsFile` renamed the `.part` file and ran the segment hook before
the old `BufWriter` was flushed, so the hook (FileValidator, upload) could see
a truncated file, and a failed final flush (disk full) was swallowed. Both now
go through `finish()`: flush and check the error, then rename, then run the
hook. `create_new` returns the flush error; `Drop` logs it with `error!`.

Tests: the rolling-segment test now expects 120 keyframes on disk (was 119)
and checks the size seen by the hook equals the final size; new cases for a
stream that ends cleanly, a read that times out and a parse error; a TS hook
size test; a failed-flush test.

Ref #1726

* feat(recorder): default to mesio when no downloader is configured

Rooms without a `downloader` setting followed the plugin hint, which is
stream-gears for almost every platform. They now use mesio: its FLV output
carries `onMetaData.keyframes` and every segment starts at timestamp 0, so
players can seek; it also records HEVC / Enhanced-FLV and HLS fMP4, which
stream-gears cannot. Only the hint mapping in `core/live.rs` changes; an
explicit `stream-gears` keeps using stream-gears, and plugins that hint
streamlink / yt-dlp are unaffected.

`DownloaderRuntime::from_type` no longer has a catch-all arm. `ffmpeg-external`
and `ffmpeg-internal` used to fall into it and silently run stream-gears; both
now run ffmpeg (external segmenting, same as `ffmpeg`). The arm for
streamlink / yt-dlp / ytarchive, which need the live stream and are built in
`downloader_runtime`, is listed explicitly and logs a warning.

WebUI, README, sample configs and CHANGELOG now say mesio is the default and
list the behaviour differences: the container follows the source (HLS fMP4 is
saved as .mp4), files are written under their final name without `.part`, and
Douyu Wangsu reconnects leave a 2.6-5.8 s overlap between segments.

Ref #1726

* feat(mesio): pass segment duration and size to the segment callback

mesio's segment-complete callback reports the duration and byte size of each
closed file, but biliup only logged them and built `SegmentInfo` with the path
and index. `SegmentInfo` gains `duration_secs` / `size_bytes` (None for the
other downloaders) and mesio fills them in. The end-to-end mesio test checks
the reported size equals the file size on disk.

Ref #1726

* fix(recorder): parse segment_time the same way in every downloader

`segment_time` was read three different ways. stream-gears indexed
`parts[1]` / `parts[2]` directly, so "3600" or "01:00" panicked the download
task (and an unparsable hour silently became 1); mesio returned 0 for anything
that was not HH:MM:SS and silently recorded without time splits; ffmpeg got
the raw string and accepted both forms.

`parse_segment_time` follows ffmpeg's duration syntax (`[HH:]MM:SS[.frac]`
or plain seconds; minutes and seconds below 60 when colons are used), so all
three downloaders now agree. stream-gears and mesio share
`DownloadConfig::segment_time_limit`: 0 means no time split for both (mesio
already did this; stream-gears used to split at every keyframe), and a value
that still cannot be parsed logs a warning and records without time splits
instead of panicking or being ignored silently. The time-range clamp uses the
same parser, so "3600" is shortened near the window end like "01:00:00".

Ref #1726

* feat(webui): enter file_size as a number with an MB/GB unit

The config still stores bytes. The control converts with 1024 (the same
sizes Windows Explorer shows), keeps an untouched value byte-exact even
when it is not a round MB/GB, and re-picks the unit when the form value
is replaced from outside.

In the streamer override, clearing a value that was already set now
saves null (no size split for that streamer); before, Semi dropped the
emptied key and the old value stayed. Clearing something typed in the
same session still falls back to inheriting the global setting.

* fix(webui): keep the file_size unit beside the number in narrow modals
2026-09-24 06:24:17 +00:00

305 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Form,
Modal,
Notification,
Collapse,
Select,
Avatar,
} from '@douyinfe/semi-ui'
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import React, { useRef } from 'react'
import { useState } from 'react'
import { LiveStreamerEntity } from '../lib/api-streamer'
import { SupportedPlatforms } from '@/app/ui/plugins'
import { useBiliUsers } from '../lib/use-streamers'
import { FileSizeField } from './FileSizeInput'
type PluginProps = {
entity?: LiveStreamerEntity
list?: { value: number; label: React.ReactNode }[]
initValues?: any
}
type TemplateModalProps = {
visible?: boolean
entity?: LiveStreamerEntity
children?: React.ReactNode
onOk: (e: any) => Promise<void>
}
const removeCircularReferences = (obj: any, seen = new WeakSet()): any => {
// 处理 null 或非对象类型
if (obj === null || typeof obj !== 'object') return obj
// 检测循环引用
if (seen.has(obj)) return '[Circular Reference]'
seen.add(obj)
if (Array.isArray(obj)) {
return obj.map((item: any) => removeCircularReferences(item, seen))
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
// 跳过 React 相关的属性
if (key === '_context' || key === 'Provider' || key === 'Consumer') continue
result[key] = removeCircularReferences(value, seen)
}
return result
}
type PlatformPattern = keyof typeof SupportedPlatforms
/** 按直播间地址找到对应平台插件在 SupportedPlatforms 里的键;没匹配到返回 undefined */
const matchPlatformPattern = (url?: string): PlatformPattern | undefined =>
(Object.keys(SupportedPlatforms) as PlatformPattern[]).find(pattern => url?.match(new RegExp(pattern)))
const OverrideModal: React.FC<TemplateModalProps> = ({ children, entity, onOk }) => {
const [isOpen, setOpen] = useState(false)
const toggle = () => {
setOpen(!isOpen)
}
// 平台插件组件从模块级常量表里按键取出,渲染间始终是同一个引用,不会因为重渲染而重置内部状态
const platformPattern = matchPlatformPattern(entity?.url)
const PlatformPlugin = platformPattern
? (SupportedPlatforms[platformPattern] as React.ComponentType<PluginProps>)
: null
const api = useRef<FormApi>(undefined)
const { biliUsers } = useBiliUsers()
const list = biliUsers?.map(item => {
return {
value: item.value,
label: (
<>
<Avatar size="extra-small" src={item.face} />
<span style={{ marginLeft: 8 }}>{item.name}</span>
</>
),
}
})
const [visible, setVisible] = useState(false)
const showDialog = () => {
setVisible(true)
}
const handleOk = async () => {
let values = await api.current?.validate()
// 从 LiveStreamerEntity 接口定义中获取所有字段
const entityFields = new Set([
'id',
'url',
'remark',
'filename',
'filename_prefix',
'split_time',
'split_size',
'upload_id',
'upload_streamers_id',
'status',
'format',
'time_range',
'excluded_keywords',
'preprocessor',
'segment_processor',
'downloaded_processor',
'postprocessor',
'opt_args',
'override',
])
if (values) {
// 处理 override_text
if (values.override_text) {
try {
values.override = JSON.parse(values.override_text)
delete values.override_text
} catch (e) {
Notification.error({
title: '错误',
content: '配置格式不正确,请检查 JSON 格式',
})
return
}
}
const overrideConfig = { ...(values.override || {}) }
Object.keys(values).forEach(key => {
if (!entityFields.has(key)) {
if (values[key] !== undefined) {
overrideConfig[key] = values[key] === '' ? null : values[key]
}
delete values[key]
}
})
values.override = overrideConfig
// PUT /v1/streamers 会按整行覆盖。漏掉 upload_streamers_id 会被写成 NULL,
// 之后录像走默认 rm 且不再投稿。以当前行打底,再叠表单字段。
const payload = {
...entity,
...values,
override: overrideConfig,
upload_streamers_id:
values.upload_streamers_id ?? entity?.upload_streamers_id ?? null,
}
// 处理循环引用
const cleanValues = removeCircularReferences(payload)
await onOk(cleanValues)
setVisible(false)
return
}
setVisible(false)
}
const handleCancel = () => {
setVisible(false)
}
const childrenWithProps = React.Children.map(children, child => {
if (React.isValidElement<{ onClick?: () => void }>(child)) {
return React.cloneElement(child, {
onClick: () => {
showDialog()
child.props.onClick?.()
},
})
}
})
const downloadSettings = (
<Collapse.Panel header="下载设置" itemKey="download">
<div style={{ marginBottom: 12 }}>
请到
<a href="/dashboard" style={{ textDecoration: 'none', color: 'var(--semi-color-primary)' }}>
空间配置
</a>
查看选项说明
</div>
<Form.Select
label="下载插件(downloader)"
field="downloader"
placeholder="mesio(默认)"
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
showClear={true}
>
<Select.Option value="streamlink">streamlink(hls多线程下载)</Select.Option>
<Select.Option value="ffmpeg">ffmpeg</Select.Option>
<Select.Option value="stream-gears">stream-gears</Select.Option>
<Select.Option value="sync-downloader">sync-downloader(边录边传)</Select.Option>
<Select.Option value="mesio">mesio(默认)</Select.Option>
</Form.Select>
<FileSizeField
label="视频分段大小(file_size)"
field="file_size"
extraText="按 1024 进制:1 GB = 1024 MB。没填过的留空即跟随全局设置;把已有的值清空,则这个主播不按大小分段(边录边传仍约 2 GB 一段)。"
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
/>
<Form.Input
field="segment_time"
label="视频分段时长(segment_time)"
placeholder="01:00:00"
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
showClear={true}
rules={[
{
pattern: /^[^:]*$/,
message: '请使用英文冒号',
},
{
pattern: /^[0-9:]*$/,
message: '只接受数字和英文冒号',
},
{
pattern: /^$|^[0-9]{2,4}:[0-5][0-9]:[0-5][0-9]$/,
message: '分或秒不符合规范',
},
]}
stopValidateWithError={true}
/>
<Form.InputNumber
field="filtering_threshold"
label="碎片过滤(filtering_threshold)"
suffix={'MB'}
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
showClear={true}
/>
</Collapse.Panel>
)
return (
<>
{childrenWithProps}
<Modal
title="配置覆写"
visible={visible}
onOk={handleOk}
style={{ width: 'min(600px, 90vw)' }}
onCancel={handleCancel}
bodyStyle={{
overflow: 'auto',
maxHeight: 'calc(100vh - 320px)',
paddingLeft: 10,
paddingRight: 10,
}}
>
<Form initValues={entity} getFormApi={formApi => (api.current = formApi)}>
<Form.TextArea
field="override_text"
label="配置覆写"
placeholder="请输入 JSON 格式的配置"
style={{ marginBottom: 12 }}
initValue={entity?.override ? JSON.stringify(entity.override, null, 2) : ''}
rules={[
{ required: false },
{
validator: (rule, value) => {
if (!value) return true
try {
JSON.parse(value)
return true
} catch (e) {
return false
}
},
message: '请输入有效的 JSON 格式',
},
]}
/>
<Form.Section>
<Collapse defaultActiveKey={['plugin']}>
{downloadSettings}
{PlatformPlugin ? (
<PlatformPlugin entity={entity} list={list} initValues={entity?.override} />
) : null}
</Collapse>
</Form.Section>
</Form>
</Modal>
</>
)
}
export default OverrideModal