From 67e49354adbcaf90a2c848182ecf32fa79ea0bb4 Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 24 Sep 2026 06:24:17 +0000 Subject: [PATCH] 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 --- README.md | 2 +- app/ui/FileSizeInput.tsx | 127 ++++++++++++ app/ui/OverrideModal.tsx | 14 +- app/ui/TemplateModal.tsx | 2 +- app/ui/plugins/bilibili.tsx | 3 +- app/ui/plugins/douyin.tsx | 4 +- app/ui/plugins/global.tsx | 45 ++-- app/ui/plugins/twitch.tsx | 2 +- .../biliup-cli/src/server/common/timerange.rs | 37 ++-- crates/biliup-cli/src/server/common/util.rs | 67 +++++- .../biliup-cli/src/server/core/downloader.rs | 80 ++++++- .../core/downloader/ffmpeg_downloader.rs | 6 + .../src/server/core/downloader/mesio.rs | 38 ++-- .../server/core/downloader/stream_gears.rs | 8 +- crates/biliup-cli/src/server/core/live.rs | 51 ++++- crates/biliup/Cargo.toml | 1 + crates/biliup/src/downloader/flv_writer.rs | 21 +- crates/biliup/src/downloader/hls.rs | 51 ++++- crates/biliup/src/downloader/httpflv.rs | 196 ++++++++++++++---- crates/biliup/src/downloader/util.rs | 44 ++++ docs/content/docs/guide/CHANGELOG.md | 11 + public/config.toml | 6 +- public/config.yaml | 6 +- 23 files changed, 683 insertions(+), 139 deletions(-) create mode 100644 app/ui/FileSizeInput.tsx diff --git a/README.md b/README.md index 24b8b67..1017142 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ biliup season sort <小节ID> ... # 列出的稿件按顺序 - 详见[Wiki](https://github.com/biliup/biliup/wiki/Termux-%E4%B8%AD%E4%BD%BF%E7%94%A8-biliup) > [!NOTE] -> 默认下载器 `stream-gears` 由 Rust 实现,无需外部依赖。若配置 `ffmpeg` 下载器或使用后处理,需要本机安装 `ffmpeg`;YouTube、niconico 等平台与通用适配器则依赖 `yt-dlp` 或 `streamlink`。Docker 镜像已内置 `ffmpeg`。 +> 默认下载器 `mesio`(rust-srec 引擎)和可选的 `stream-gears` 都由 Rust 实现、编进 biliup,无需外部依赖。若配置 `ffmpeg` 下载器或使用后处理,需要本机安装 `ffmpeg`;YouTube、niconico 等平台与通用适配器则依赖 `yt-dlp` 或 `streamlink`。Docker 镜像已内置 `ffmpeg`。 ### 🔓 远程访问(监听 0.0.0.0) diff --git a/app/ui/FileSizeInput.tsx b/app/ui/FileSizeInput.tsx new file mode 100644 index 0000000..53cb282 --- /dev/null +++ b/app/ui/FileSizeInput.tsx @@ -0,0 +1,127 @@ +'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 = { 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 = ({ + value, + onChange, + onBlur, + disabled, + id, + placeholder, + validateStatus, +}) => { + const bytes = bytesOf(value) + const [draft, setDraft] = useState(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(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 ( +
+ { + if (next === '') commit('', unit) + else if (typeof next === 'number' && Number.isFinite(next)) commit(next, unit) + }} + style={{ flex: '1 1 120px', minWidth: 0 }} + /> +