* 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
305 lines
8.8 KiB
TypeScript
305 lines
8.8 KiB
TypeScript
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
|