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

128 lines
4.7 KiB
TypeScript

'use client'
import React, { useState } from 'react'
import { InputNumber, Select, Typography, withField } from '@douyinfe/semi-ui'
const MB = 1024 * 1024
const GB = 1024 * MB
export type SizeUnit = 'MB' | 'GB'
const UNIT_BYTES: Record<SizeUnit, number> = { MB, GB }
/** 选能用两位以内小数精确表示的最大单位;都不能精确表示时,1 GB 及以上用 GB,否则用 MB */
export const pickUnit = (bytes: number): SizeUnit => {
for (const unit of ['GB', 'MB'] as const) {
const size = UNIT_BYTES[unit]
if (bytes >= size && (bytes * 100) % size === 0) return unit
}
return bytes >= GB ? 'GB' : 'MB'
}
/** 显示用的数值:1 以上保留两位小数,更小的保留三位有效数字 */
export const formatAmount = (bytes: number, unit: SizeUnit): number => {
const amount = bytes / UNIT_BYTES[unit]
return amount >= 1 ? Math.round(amount * 100) / 100 : Number(amount.toPrecision(3))
}
export const toBytes = (amount: number, unit: SizeUnit): number => Math.round(amount * UNIT_BYTES[unit])
const bytesOf = (value: unknown): number | null => {
if (value === null || value === undefined || value === '') return null
const bytes = typeof value === 'number' ? value : Number(value)
return Number.isFinite(bytes) ? bytes : null
}
/** 用户最近一次输入:只对应它产生的那个字节数 */
type Draft = { bytes: number | null; unit: SizeUnit; amount: number | '' }
type Props = {
/** 字节数;空串 / undefined / null 表示没有值 */
value?: number | string | null
/**
* 清空时:外部带进来的值被清掉给 null(覆写里即「显式清空」,Semi 表单会保留这个键);
* 清掉的是自己刚输入的值则给空串,Semi 会把键移除,回到打开时的状态(覆写里即「跟随全局」)
*/
onChange?: (value: number | '' | null) => void
onBlur?: (e: React.FocusEvent) => void
disabled?: boolean
id?: string
placeholder?: string
validateStatus?: 'default' | 'error' | 'warning' | 'success'
}
/**
* 字节数的「数值 + 单位(MB / GB,1024 进制)」输入。表单里存的仍是字节整数:
* 没动过就原样保留(不经过换算,不丢字节),改了数值或单位才按新输入换算。
*/
const FileSizeInput: React.FC<Props> = ({
value,
onChange,
onBlur,
disabled,
id,
placeholder,
validateStatus,
}) => {
const bytes = bytesOf(value)
const [draft, setDraft] = useState<Draft | null>(null)
// 表单值被外部改写(读回配置、平台插件回填覆写)后草稿作废,按新值重新选单位
const current = draft && draft.bytes === bytes ? draft : null
const unit = current?.unit ?? (bytes === null ? 'GB' : pickUnit(bytes))
const amount = current ? current.amount : bytes === null ? '' : formatAmount(bytes, unit)
// 最近一次从外部带进来的值,用来区分「清空已有值」和「撤销自己的输入」
const [loaded, setLoaded] = useState<number | null>(bytes)
if (!current && bytes !== loaded) setLoaded(bytes)
const commit = (nextAmount: number | '', nextUnit: SizeUnit) => {
const nextBytes = nextAmount === '' ? null : toBytes(nextAmount, nextUnit)
setDraft({ bytes: nextBytes, unit: nextUnit, amount: nextAmount })
onChange?.(nextBytes !== null ? nextBytes : loaded === null ? '' : null)
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, width: '100%' }}>
<InputNumber
id={id}
value={amount}
min={0}
placeholder={placeholder}
disabled={disabled}
validateStatus={validateStatus}
showClear
onBlur={onBlur}
onChange={next => {
if (next === '') commit('', unit)
else if (typeof next === 'number' && Number.isFinite(next)) commit(next, unit)
}}
style={{ flex: '1 1 120px', minWidth: 0 }}
/>
<Select
aria-label="单位"
value={unit}
disabled={disabled}
onChange={next => {
const nextUnit = next as SizeUnit
// 还没填数值时只记住单位,不写表单:保持「未设置」
if (amount === '') setDraft({ bytes, unit: nextUnit, amount: '' })
else commit(amount, nextUnit)
}}
optionList={[
{ value: 'MB', label: 'MB' },
{ value: 'GB', label: 'GB' },
]}
style={{ width: 88 }}
/>
{bytes !== null ? (
<Typography.Text type="tertiary" size="small">
= {bytes.toLocaleString('en-US')} 字节
</Typography.Text>
) : null}
</div>
)
}
/** 表单字段版:`field` 存字节数 */
export const FileSizeField = withField(FileSizeInput)
export default FileSizeInput