* 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
128 lines
4.7 KiB
TypeScript
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
|