Files
biliup/app/ui/TemplateModal.tsx
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

443 lines
17 KiB
TypeScript
Raw Permalink 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,
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">
如:&quot;-ss&quot;、&quot;00:00:16&quot;,每个参数需单独一行
</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