* 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
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
import {
|
||
Form,
|
||
Modal,
|
||
Input,
|
||
Notification,
|
||
Typography,
|
||
Collapse,
|
||
ArrayField,
|
||
Button,
|
||
Collapsible,
|
||
} from '@douyinfe/semi-ui'
|
||
import { IconPlusCircle, IconMinusCircle } from '@douyinfe/semi-icons'
|
||
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
|
||
import React, { CSSProperties, useMemo, useRef } from 'react'
|
||
import { useState } from 'react'
|
||
import { fetcher, LiveStreamerEntity, sendRequest, StudioEntity } from '../lib/api-streamer'
|
||
import useSWR from 'swr'
|
||
import useSWRMutation from 'swr/mutation'
|
||
import { useMe } from '../lib/use-me'
|
||
|
||
type TemplateModalProps = {
|
||
visible?: boolean
|
||
entity?: LiveStreamerEntity
|
||
children?: React.ReactNode
|
||
onOk: (e: any) => Promise<void>
|
||
}
|
||
|
||
const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk }) => {
|
||
const { Paragraph, Title, Text } = Typography
|
||
let message = '该项为必填项'
|
||
const [isOpen, setOpen] = useState(false)
|
||
const maskStyle = isOpen
|
||
? {}
|
||
: {
|
||
WebkitMaskImage:
|
||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||
}
|
||
|
||
const collapsed = (
|
||
<div className="semi-form-field-extra">
|
||
流程无报错结束时触发,将按自定义顺序执行操作。默认<Text type="danger">删除</Text>
|
||
视频文件,若要保留文件请设置为 mv。示例:
|
||
<br />
|
||
<code>rm</code> 删除文件,为默认操作
|
||
<br />
|
||
<code>mv = backup/</code> 移动文件到backup目录下
|
||
<br />
|
||
<code>run = echo hello!</code> 使用当前运行用户在 shell 执行任意命令,
|
||
<br />
|
||
<code>webhook = url</code>
|
||
不填写则是默认的逻辑,上传失败后重试直至达到最大重试次数
|
||
<br />
|
||
填写后,<strong>是否上传成功的判断由WebHook给出</strong>,但失败最大重试次数仍会被限制(前往空间配置-上传重试次数限制处配置)
|
||
<br />
|
||
上传任务完成后,会将主播信息、稿件信息、捕获的异常等以post模式发送给WebHook,<strong>当WebHook返回success字符串时视为上传成功</strong>,执行正常后处理流程,否则返回其他任意内容或请求失败均尝试重新上传
|
||
<br />
|
||
<Text type="danger">注意风险。</Text>视频文件路径作为标准输入传入
|
||
<br />
|
||
TODO: 在这里塞插件仓库
|
||
</div>
|
||
)
|
||
const toggle = () => {
|
||
setOpen(!isOpen)
|
||
}
|
||
const linkStyle: CSSProperties = {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
textAlign: 'center',
|
||
bottom: isOpen ? 0 : -10,
|
||
fontWeight: 700,
|
||
cursor: 'pointer',
|
||
}
|
||
const api = useRef<FormApi>(undefined)
|
||
// 各类处理器会在服务器上执行 shell 命令,只有超级管理员能看到和修改;后端也会忽略其他角色提交的这几项
|
||
const { can } = useMe()
|
||
const hooks = can('streamer.hooks')
|
||
const {
|
||
data: templates,
|
||
error,
|
||
isLoading,
|
||
} = useSWR<StudioEntity[]>('/v1/upload/streamers', fetcher)
|
||
|
||
const [visible, setVisible] = useState(false)
|
||
const showDialog = () => {
|
||
setVisible(true)
|
||
}
|
||
const handleOk = async () => {
|
||
let values = await api.current?.validate()
|
||
values = {
|
||
...values,
|
||
remark: values?.remark?.trim(),
|
||
url: values?.url?.trim(),
|
||
format: values?.format?.trim(),
|
||
time_range: JSON.stringify(values?.time_range?.map((date: Date) => date.toISOString())),
|
||
}
|
||
await onOk(values)
|
||
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 list = templates?.map(template => {
|
||
return {
|
||
value: template.id,
|
||
label: template.template_name,
|
||
}
|
||
})
|
||
|
||
// 后端把 time_range 存成 JSON 字串,TimePicker 需要 Date[]。entity 是上层传下来的 props,
|
||
// 渲染期不能就地改写,转换结果放进一份本地副本作为表单初始值;解析失败时保持原样(与之前一致)
|
||
const initValues = useMemo(() => {
|
||
if (entity && entity.time_range && typeof entity.time_range === 'string') {
|
||
try {
|
||
const tr: string[] = JSON.parse(entity.time_range)
|
||
return { ...entity, time_range: tr.map(t => new Date(t)) }
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
}
|
||
return entity
|
||
}, [entity])
|
||
|
||
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={initValues} getFormApi={formApi => (api.current = formApi)}>
|
||
<Form.Input
|
||
field="remark"
|
||
label="录播备注"
|
||
trigger="blur"
|
||
rules={[{ required: true, message }]}
|
||
/>
|
||
|
||
<Form.Input
|
||
field="url"
|
||
label="直播链接"
|
||
trigger="blur"
|
||
rules={[{ required: true, message }]}
|
||
/>
|
||
|
||
<Form.Input
|
||
field="filename_prefix"
|
||
label="文件名模板(可选)"
|
||
placeholder="{streamer}%Y-%m-%dT%H_%M_%S"
|
||
/>
|
||
|
||
<Form.Select
|
||
showClear
|
||
field="upload_streamers_id"
|
||
label={{ text: '投稿模板', optional: true }}
|
||
style={{ width: 176 }}
|
||
optionList={list}
|
||
/>
|
||
|
||
{hooks ? (
|
||
<ArrayField
|
||
field="postprocessor"
|
||
initValue={entity === undefined ? [{ cmd: 'rm' }] : undefined}
|
||
>
|
||
{({ add, arrayFields }) => (
|
||
<>
|
||
<Form.Slot label={{ text: '后处理' }} labelPosition="left">
|
||
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
|
||
添加行
|
||
</Button>
|
||
</Form.Slot>
|
||
|
||
{arrayFields.map(({ field, key, remove }, i) => (
|
||
<div key={key} style={{ display: 'flex' }}>
|
||
<Form.Select
|
||
field={`${field}.cmd`}
|
||
label="操作"
|
||
rules={[{ required: true, message }]}
|
||
noLabel
|
||
>
|
||
<Form.Select.Option value="run">run(运行)</Form.Select.Option>
|
||
<Form.Select.Option value="mv">mv(移动到)</Form.Select.Option>
|
||
<Form.Select.Option value="rm">rm(删除文件)</Form.Select.Option>
|
||
<Form.Select.Option value="webhook">webhook</Form.Select.Option>
|
||
</Form.Select>
|
||
{api.current?.getValue(field)?.cmd !== 'rm' ? (
|
||
<Form.Input
|
||
field={`${field}.value`}
|
||
label="="
|
||
labelPosition="inset"
|
||
rules={[{ required: true, message }]}
|
||
style={{ width: 300, marginRight: 16 }}
|
||
placeholder={ api.current?.getValue(field)?.cmd === 'webhook' ? 'https://example.com/notify' : undefined }
|
||
></Form.Input>
|
||
) : null}
|
||
<Button
|
||
type="danger"
|
||
theme="borderless"
|
||
icon={<IconMinusCircle />}
|
||
onClick={remove}
|
||
disabled={arrayFields.length <= 1}
|
||
style={{ margin: 12 }}
|
||
/>
|
||
</div>
|
||
))}
|
||
<div style={{ position: 'relative' }}>
|
||
<Collapsible isOpen={isOpen} collapseHeight={60} style={{ ...maskStyle }}>
|
||
{collapsed}
|
||
</Collapsible>
|
||
<a onClick={toggle} style={{ ...linkStyle }}>
|
||
{isOpen ? '收起' : '展开更多'}
|
||
</a>
|
||
</div>
|
||
</>
|
||
)}
|
||
</ArrayField>
|
||
) : (
|
||
<Form.Slot label={{ text: '后处理' }}>
|
||
<Text type="tertiary">后处理与各类处理器命令仅超级管理员可配置,保存时保持原样。</Text>
|
||
</Form.Slot>
|
||
)}
|
||
|
||
<Form.Input
|
||
field="format"
|
||
label="视频格式"
|
||
placeholder="flv"
|
||
style={{ width: 176 }}
|
||
helpText="视频保存格式(转封装)。mesio、stream-gears 下载器不转封装,按源站容器保存;YouTube 平台也不支持。"
|
||
/>
|
||
|
||
<Collapse keepDOM>
|
||
<Collapse.Panel header="更多设置" itemKey="processors">
|
||
<Form.TimePicker
|
||
field="time_range"
|
||
type="timeRange"
|
||
placeholder=" "
|
||
extraText={
|
||
<div style={{ fontSize: '14px' }}>
|
||
如果设置了录制时间范围,不在时间范围内,将不进行录制<br />
|
||
下载器需使用ffmpeg或streamlink
|
||
</div>
|
||
}
|
||
label={{
|
||
text: '录制时间范围',
|
||
optional: true,
|
||
style: {
|
||
marginBottom: '4px',
|
||
paddingBottom: '8px',
|
||
borderBottom: '1px solid var(--semi-color-border)',
|
||
}
|
||
}}
|
||
style={{ width: 176 }}
|
||
/>
|
||
|
||
<ArrayField field="excluded_keywords">
|
||
{({ add, arrayFields }) => (
|
||
<Form.Section text="不录制关键词">
|
||
<div className="semi-form-field-extra">
|
||
如果房间名包含关键词,则停止或不录制该场直播,每个关键词需单独一行<br />
|
||
暂不支持<strong>cc直播</strong>、<strong>yy直播</strong>、<strong>twitch直播</strong>
|
||
</div>
|
||
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
|
||
添加关键词
|
||
</Button>
|
||
{arrayFields.map(({ field, key, remove }, i) => (
|
||
<div key={key} style={{ width: '100%', display: 'flex' }}>
|
||
<Form.Input
|
||
field={field}
|
||
label={`关键词${i + 1}`}
|
||
labelPosition="left"
|
||
rules={[{ required: true, message }]}
|
||
></Form.Input>
|
||
<Button
|
||
type="danger"
|
||
theme="borderless"
|
||
icon={<IconMinusCircle />}
|
||
onClick={remove}
|
||
style={{ margin: 12 }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</Form.Section>
|
||
)}
|
||
</ArrayField>
|
||
|
||
{hooks && (
|
||
<>
|
||
<ArrayField field="preprocessor">
|
||
{({ add, arrayFields }) => (
|
||
<Form.Section text="下载前处理">
|
||
<div className="semi-form-field-extra">
|
||
下载直播前触发,将按自定义顺序执行自定义操作,仅支持shell指令
|
||
</div>
|
||
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
|
||
添加行
|
||
</Button>
|
||
{arrayFields.map(({ field, key, remove }, i) => (
|
||
<div key={key} style={{ width: '100%', display: 'flex' }}>
|
||
<Form.Input
|
||
field={`${field}[run]`}
|
||
label={`run = `}
|
||
labelPosition="inset"
|
||
rules={[{ required: true, message }]}
|
||
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
|
||
></Form.Input>
|
||
<Button
|
||
type="danger"
|
||
theme="borderless"
|
||
icon={<IconMinusCircle />}
|
||
onClick={remove}
|
||
style={{ margin: 12 }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</Form.Section>
|
||
)}
|
||
</ArrayField>
|
||
|
||
<ArrayField field="segment_processor">
|
||
{({ add, arrayFields }) => (
|
||
<Form.Section text="分段时后处理">
|
||
<div className="semi-form-field-extra">
|
||
分段时触发,将按自定义顺序执行自定义操作,仅支持shell指令
|
||
</div>
|
||
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
|
||
添加行
|
||
</Button>
|
||
{arrayFields.map(({ field, key, remove }, i) => (
|
||
<div key={key} style={{ width: '100%', display: 'flex' }}>
|
||
<Form.Input
|
||
field={`${field}[run]`}
|
||
label={`run = `}
|
||
labelPosition="inset"
|
||
rules={[{ required: true, message }]}
|
||
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
|
||
></Form.Input>
|
||
<Button
|
||
type="danger"
|
||
theme="borderless"
|
||
icon={<IconMinusCircle />}
|
||
onClick={remove}
|
||
style={{ margin: 12 }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</Form.Section>
|
||
)}
|
||
</ArrayField>
|
||
|
||
<ArrayField field="downloaded_processor">
|
||
{({ add, arrayFields }) => (
|
||
<Form.Section text="下载后处理">
|
||
<div className="semi-form-field-extra">
|
||
准备上传直播时触发,将按自定义顺序执行自定义操作,仅支持shell指令,如果对上传的视频进行修改,需要保证和filename_prefix命名规则一致,会自动检测上传
|
||
</div>
|
||
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
|
||
添加行
|
||
</Button>
|
||
{arrayFields.map(({ field, key, remove }, i) => (
|
||
<div key={key} style={{ width: '100%', display: 'flex' }}>
|
||
<Form.Input
|
||
field={`${field}[run]`}
|
||
label={`run = `}
|
||
labelPosition="inset"
|
||
rules={[{ required: true, message }]}
|
||
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
|
||
></Form.Input>
|
||
<Button
|
||
type="danger"
|
||
theme="borderless"
|
||
icon={<IconMinusCircle />}
|
||
onClick={remove}
|
||
style={{ margin: 12 }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</Form.Section>
|
||
)}
|
||
</ArrayField>
|
||
|
||
</>
|
||
)}
|
||
|
||
<ArrayField field="opt_args">
|
||
{({ add, arrayFields }) => (
|
||
<Form.Section text="ffmpeg参数">
|
||
<div className="semi-form-field-extra">
|
||
如:"-ss"、"00:00:16",每个参数需单独一行
|
||
</div>
|
||
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
|
||
添加行
|
||
</Button>
|
||
{arrayFields.map(({ field, key, remove }, i) => (
|
||
<div key={key} style={{ width: '100%', display: 'flex' }}>
|
||
<Form.Input
|
||
field={field}
|
||
label={`参数${i + 1}`}
|
||
labelPosition="left"
|
||
rules={[{ required: true, message }]}
|
||
></Form.Input>
|
||
<Button
|
||
type="danger"
|
||
theme="borderless"
|
||
icon={<IconMinusCircle />}
|
||
onClick={remove}
|
||
style={{ margin: 12 }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</Form.Section>
|
||
)}
|
||
</ArrayField>
|
||
</Collapse.Panel>
|
||
</Collapse>
|
||
</Form>
|
||
</Modal>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default TemplateModal
|