diff --git a/.github/desktop-release-notes.md b/.github/desktop-release-notes.md new file mode 100644 index 0000000..4828497 --- /dev/null +++ b/.github/desktop-release-notes.md @@ -0,0 +1,10 @@ + +## Windows 桌面版 + +- **推荐下载 `biliup_*_x64-setup.exe`**:双击安装,默认装在当前用户目录下,不需要管理员权限,也不需要另装 Python 或 FFmpeg。装在系统盘时,第一次打开会询问数据和录像放在哪里。`.msi` 适合批量部署。 +- 安装包暂未签名,Windows 可能会拦一下: + - 浏览器提示「不常下载」「可能有害」时,在下载列表里点「…」→「保留」(Edge 还要再点「显示详细信息」→「仍然保留」)。 + - 第一次运行出现蓝色的「Windows 已保护你的电脑」(SmartScreen)时,点「更多信息」→「仍要运行」。 +- 安装包内置 FFmpeg `{{FFMPEG_VERSION}}`(BtbN/FFmpeg-Builds `{{FFMPEG_TAG}}` 的 win64 GPL 静态构建),在安装目录的 `ffmpeg\` 下,按 GPLv3 分发,许可文本见同目录 `LICENSE.txt`。Docker 镜像用的是同一 tag 的 linux64 / linuxarm64 构建。对应源码由上游托管: + - FFmpeg 源码:<{{FFMPEG_SOURCE_URL}}> + - BtbN 构建脚本与补丁:<{{FFMPEG_BUILD_SCRIPTS_URL}}>(`scripts.d/` 固定了各依赖库的版本与源码地址,`download.sh` 可一次拉取全部依赖源码) diff --git a/.github/scripts/ffmpeg-version.sh b/.github/scripts/ffmpeg-version.sh new file mode 100755 index 0000000..995a955 --- /dev/null +++ b/.github/scripts/ffmpeg-version.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Prints the BtbN FFmpeg build that the Dockerfile pins, as KEY=value lines +# (for $GITHUB_ENV), so the desktop installer bundles the same build: +# FFMPEG_TAG BtbN release tag, e.g. autobuild-2026-08-31-13-27 +# FFMPEG_VERSION FFmpeg version, e.g. n8.1.2-50-g1a748fe2cd +# FFMPEG_BRANCH FFmpeg release branch of the build, e.g. 8.1 +# FFMPEG_COMMIT abbreviated FFmpeg commit, e.g. 1a748fe2cd +set -euo pipefail + +dockerfile="${1:-Dockerfile}" +url=$(grep -o "https://github.com/BtbN/FFmpeg-Builds/releases/download/[^']*" "$dockerfile" | head -n 1 || true) +asset=$(grep -o "linux64-gpl-[0-9.]*\.tar\.xz" "$dockerfile" | head -n 1 || true) +url_re='/download/(autobuild-[^/]+)/ffmpeg-(.+-g([0-9a-f]+))-$' +asset_re='^linux64-gpl-([0-9.]+)\.tar\.xz$' +if [[ ! $url =~ $url_re ]]; then + echo "no BtbN FFmpeg URL found in $dockerfile" >&2 + exit 1 +fi +echo "FFMPEG_TAG=${BASH_REMATCH[1]}" +echo "FFMPEG_VERSION=${BASH_REMATCH[2]}" +echo "FFMPEG_COMMIT=${BASH_REMATCH[3]}" +if [[ ! $asset =~ $asset_re ]]; then + echo "no linux64 GPL asset found in $dockerfile" >&2 + exit 1 +fi +echo "FFMPEG_BRANCH=${BASH_REMATCH[1]}" diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 743e947..5a76191 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -9,6 +9,11 @@ on: permissions: contents: read +env: + # BtbN win64 GPL 静态构建 zip 的 SHA-256。tag 与版本号取自 Dockerfile(与 Docker 镜像同一构建), + # 见 .github/scripts/ffmpeg-version.sh;Dockerfile 升级 FFmpeg 时同步更新这里。 + FFMPEG_WIN64_SHA256: 273abb45f3f9f76c303e35ff39f5bb6c23c163ae65f6244a32b7d4a7f6cf0616 + jobs: build: permissions: @@ -24,6 +29,62 @@ jobs: run: | npm install npm run build + # 只取 ffmpeg.exe 和许可文本,由 tauri.ffmpeg.conf.json 作为资源打进安装包(安装目录下的 ffmpeg\)。 + # GPL 对应源码不由我们托管:README.txt 和 Release 说明链接到上游固定 commit 的 FFmpeg 源码 + # 与 BtbN 构建脚本(scripts.d/ 固定了各依赖的版本与源码地址)。 + - name: Download FFmpeg + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + eval "$(bash .github/scripts/ffmpeg-version.sh)" + # tag 可以被移动,链接用完整 commit + ffmpeg_sha=$(gh api "repos/FFmpeg/FFmpeg/commits/${FFMPEG_COMMIT}" --jq .sha) + btbn_sha=$(gh api "repos/BtbN/FFmpeg-Builds/commits/${FFMPEG_TAG}" --jq .sha) + [[ $ffmpeg_sha == "${FFMPEG_COMMIT}"* && ${#btbn_sha} == 40 ]] || { + echo "cannot resolve FFmpeg ${FFMPEG_COMMIT} / BtbN ${FFMPEG_TAG}: '${ffmpeg_sha}' '${btbn_sha}'" >&2 + exit 1 + } + { + echo "FFMPEG_TAG=${FFMPEG_TAG}" + echo "FFMPEG_VERSION=${FFMPEG_VERSION}" + echo "FFMPEG_SOURCE_URL=https://github.com/FFmpeg/FFmpeg/tree/${ffmpeg_sha}" + echo "FFMPEG_BUILD_SCRIPTS_URL=https://github.com/BtbN/FFmpeg-Builds/tree/${btbn_sha}" + } >> "$GITHUB_ENV" + asset="ffmpeg-${FFMPEG_VERSION}-win64-gpl-${FFMPEG_BRANCH}" + curl -fsSL --retry 3 -o ffmpeg.zip \ + "https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_TAG}/${asset}.zip" + echo "${FFMPEG_WIN64_SHA256} ffmpeg.zip" | sha256sum -c - + dest=tauri-app/src-tauri/ffmpeg + mkdir -p "$dest" + unzip -j -o ffmpeg.zip "${asset}/bin/ffmpeg.exe" "${asset}/LICENSE.txt" -d "$dest" + cat > "$dest/README.txt" < desktop-notes.md + if grep -n '{{' desktop-notes.md; then exit 1; fi + cat desktop-notes.md - name: install Rust stable uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 @@ -41,11 +102,25 @@ jobs: # 用 releaseId 而不是 tagName:v1 找到已发布的 Release 时会用 releaseName/releaseBody 覆盖它的标题和正文 releaseId: ${{ github.event.release.id }} projectPath: tauri-app + args: --config src-tauri/tauri.ffmpeg.conf.json + - name: Add download notes to the release + if: github.event_name == 'release' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + run: | + body=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body) + if ! grep -q 'biliup-desktop-notes' <<<"$body"; then + printf '%s\n\n%s\n' "$body" "$(cat desktop-notes.md)" > notes.md + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --notes-file notes.md + fi - name: Build if: github.event_name != 'release' uses: tauri-apps/tauri-action@v1 with: projectPath: tauri-app + args: --config src-tauri/tauri.ffmpeg.conf.json - uses: actions/upload-artifact@v7 if: github.event_name != 'release' with: diff --git a/Dockerfile b/Dockerfile index bca71d7..cbc7411 100644 --- a/Dockerfile +++ b/Dockerfile @@ -82,6 +82,8 @@ RUN set -eux; \ # latest 是滚动 tag,资产每日重建,既不可复现也无法防篡改。 # 只能固定到「每月最后一天」的 autobuild:BtbN 长期保留月末构建, # 其余每日构建约两周后删除,固定到它们会让镜像构建 404。 + # Windows 桌面版打包同一构建的 win64 版:.github/scripts/ffmpeg-version.sh 从下面这行读取 + # tag 与版本号;改版本时同步更新 desktop-publish.yml 里的 FFMPEG_WIN64_SHA256。 url='https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-n8.1.2-50-g1a748fe2cd-'; \ case "$arch" in \ 'amd64') \ diff --git a/crates/biliup-cli/src/entry.rs b/crates/biliup-cli/src/entry.rs index b894e1a..30f946f 100644 --- a/crates/biliup-cli/src/entry.rs +++ b/crates/biliup-cli/src/entry.rs @@ -227,6 +227,7 @@ pub async fn dispatch(cli: Cli, log_handle: LogHandle) -> AppResult<()> { log_handle, listener: None, shutdown: None, + ffmpeg: None, }) .await? } @@ -273,6 +274,9 @@ pub struct ServeOptions { /// Stop the server when this future completes. When set, the server does /// not install its own Ctrl+C / SIGTERM handlers; the host owns signals. pub shutdown: Option>, + /// An ffmpeg shipped with the host (the desktop app passes the one in its + /// installer). Used when the `ffmpeg_path` setting is empty, before `PATH`. + pub ffmpeg: Option, } /// Runs the Web server until shutdown. It only needs a tokio runtime, so an @@ -285,6 +289,7 @@ pub async fn serve(opts: ServeOptions) -> AppResult<()> { .change_context(AppError::Unknown) .attach_with(|| format!("could not switch to work dir {}", dir.display()))?; } + crate::tools::set_bundled_ffmpeg(opts.ffmpeg); let listener = match opts.listener { Some(listener) => listener .set_nonblocking(true) diff --git a/crates/biliup-cli/src/lib.rs b/crates/biliup-cli/src/lib.rs index 4084986..bec879c 100644 --- a/crates/biliup-cli/src/lib.rs +++ b/crates/biliup-cli/src/lib.rs @@ -3,6 +3,7 @@ pub mod downloader; pub mod entry; pub mod season_cli; pub mod server; +pub mod tools; pub mod upload_lock; pub mod uploader; pub mod web_user_cli; @@ -129,6 +130,13 @@ pub async fn serve_on( } else { repositories::get_config(&conn_pool).await? }; + tools::set_configured_ffmpeg(loaded_config.ffmpeg_path.as_deref()); + if cfg!(windows) { + tracing::info!( + create_no_window = biliup::tools::hides_console_windows(), + "child processes get CREATE_NO_WINDOW only when this process has no console" + ); + } let config = Arc::new(RwLock::new(loaded_config)); let download_manager = DownloadManager::new( diff --git a/crates/biliup-cli/src/server/api/access.rs b/crates/biliup-cli/src/server/api/access.rs index 7b284bb..3964662 100644 --- a/crates/biliup-cli/src/server/api/access.rs +++ b/crates/biliup-cli/src/server/api/access.rs @@ -208,6 +208,7 @@ mod tests { ("POST", "/v1/login_by_qrcode"), ("GET", "/v1/videos"), ("GET", "/v1/status"), + ("GET", "/v1/tools"), ("POST", "/v1/uploads"), ("GET", "/static/ds_update.log"), ("GET", "/static/a.flv"), @@ -241,6 +242,7 @@ mod tests { | ("GET", "/v1/upload/streamers/1") | ("GET", "/v1/videos") | ("GET", "/v1/status") + | ("GET", "/v1/tools") | ("GET", "/static/ds_update.log") | ("GET", "/static/a.flv") | ("GET", "/v1/ws/logs") => view, @@ -294,6 +296,7 @@ mod tests { .route("/v1/login_by_qrcode", post(|| async { StatusCode::OK })) .route("/v1/videos", ok()) .route("/v1/status", ok()) + .route("/v1/tools", ok()) .route("/v1/uploads", post(|| async { StatusCode::OK })) .route("/static/{path}", ok()) .route("/v1/ws/logs", ok()) diff --git a/crates/biliup-cli/src/server/api/endpoints.rs b/crates/biliup-cli/src/server/api/endpoints.rs index defb170..315a3ee 100644 --- a/crates/biliup-cli/src/server/api/endpoints.rs +++ b/crates/biliup-cli/src/server/api/endpoints.rs @@ -365,6 +365,7 @@ pub async fn put_configuration( saved_config .validate_segment_limits() .map_err(report_to_response)?; + crate::tools::set_configured_ffmpeg(saved_config.ffmpeg_path.as_deref()); *config.write().unwrap() = saved_config; let guard = config.read().unwrap(); if let Some(loggers_level) = &guard.loggers_level { @@ -717,6 +718,11 @@ pub async fn get_videos() -> Result>, Response> { Ok(Json(file_list)) } +/// 外部工具是否可用(目前只有 ffmpeg),前端据此决定依赖 ffmpeg 的功能能否使用。 +pub async fn get_tools() -> Json { + Json(json!({ "ffmpeg": crate::tools::ffmpeg_status().await })) +} + // #[axum::debug_handler(state = ServiceRegister)] pub async fn get_status( caller: Caller, diff --git a/crates/biliup-cli/src/server/api/redact.rs b/crates/biliup-cli/src/server/api/redact.rs index 38aa8ad..7162511 100644 --- a/crates/biliup-cli/src/server/api/redact.rs +++ b/crates/biliup-cli/src/server/api/redact.rs @@ -9,6 +9,7 @@ use serde_json::Value; const VISIBLE_CONFIG_KEYS: &[&str] = &[ "downloader", "sync_save_dir", + "ffmpeg_path", "file_size", "segment_time", "filtering_threshold", diff --git a/crates/biliup-cli/src/server/config.rs b/crates/biliup-cli/src/server/config.rs index 9ff0e59..00ed0a2 100644 --- a/crates/biliup-cli/src/server/config.rs +++ b/crates/biliup-cli/src/server/config.rs @@ -31,6 +31,12 @@ pub struct Config { #[serde(default)] pub sync_save_dir: Option, + /// ffmpeg 可执行文件路径。不填时先用桌面版安装包自带的,再到 PATH 里找 `ffmpeg`。 + /// 全局生效,主播覆写里不能改。 + #[patch(skip)] + #[serde(default)] + pub ffmpeg_path: Option, + /// 文件大小限制(字节) /// /// 主播覆写里这个字段的 `null` 是“显式清除、按主播关闭大小分段”,与其它字段不同。 diff --git a/crates/biliup-cli/src/server/core/downloader/ffmpeg_downloader.rs b/crates/biliup-cli/src/server/core/downloader/ffmpeg_downloader.rs index 0e81398..c957e19 100644 --- a/crates/biliup-cli/src/server/core/downloader/ffmpeg_downloader.rs +++ b/crates/biliup-cli/src/server/core/downloader/ffmpeg_downloader.rs @@ -5,13 +5,13 @@ use crate::server::core::downloader::{ DownloadConfig, DownloadStatus, DownloaderType, SegmentEvent, SegmentInfo, }; use crate::server::errors::{AppError, AppResult}; +use crate::tools; use biliup::downloader::util::ByteCounter; use error_stack::{ResultExt, bail}; use std::path::PathBuf; use std::process::{ExitStatus, Stdio}; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Command; use tokio::sync::RwLock; use tracing::{debug, info}; @@ -209,7 +209,7 @@ impl FfmpegDownloader { let output_file = download_config.generate_output_filename(&download_config.suffix); let part_file = format!("{}.part", output_file.display()); - let mut cmd = Command::new("ffmpeg"); + let mut cmd = tools::ffmpeg_command(); cmd.args(&args) .arg(&part_file) .stdin(Stdio::null()) @@ -258,7 +258,7 @@ impl FfmpegDownloader { ) -> AppResult { let args = self.build_ffmpeg_args_internal_segment(&download_config); - let mut cmd = Command::new("ffmpeg"); + let mut cmd = tools::ffmpeg_command(); cmd.args(&args) .arg(format!( "{}.{}.part", diff --git a/crates/biliup-cli/src/server/core/downloader/streamlink.rs b/crates/biliup-cli/src/server/core/downloader/streamlink.rs index 4bd3a6d..f6c1c23 100644 --- a/crates/biliup-cli/src/server/core/downloader/streamlink.rs +++ b/crates/biliup-cli/src/server/core/downloader/streamlink.rs @@ -2,13 +2,14 @@ use crate::server::common::throughput::SubprocessProgress; use crate::server::common::util::redact_process_debug; use crate::server::core::downloader::{DownloadConfig, DownloadStatus, SegmentEvent, SegmentInfo}; use crate::server::errors::{AppError, AppResult}; +use crate::tools; use biliup::downloader::util::ByteCounter; use error_stack::ResultExt; use std::collections::HashMap; use std::process::{ExitStatus, Stdio}; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::{Child, ChildStdout, Command}; +use tokio::process::{Child, ChildStdout}; use tokio::sync::RwLock; use tokio::time::Duration; use tracing::{debug, info}; @@ -63,7 +64,7 @@ impl Streamlink { .streamlink_downloader .build_file_args(&download_config, &part_file)?; - let mut cmd = Command::new("streamlink"); + let mut cmd = tools::command("streamlink"); cmd.args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -197,7 +198,7 @@ impl StreamlinkDownloader { /// 启动streamlink进程 pub fn start(&mut self) -> AppResult { - let mut cmd = Command::new("streamlink"); + let mut cmd = tools::command("streamlink"); cmd.args(self.build_base_args()?); diff --git a/crates/biliup-cli/src/server/core/downloader/sync_downloader.rs b/crates/biliup-cli/src/server/core/downloader/sync_downloader.rs index a79f351..be5fe63 100644 --- a/crates/biliup-cli/src/server/core/downloader/sync_downloader.rs +++ b/crates/biliup-cli/src/server/core/downloader/sync_downloader.rs @@ -1,6 +1,7 @@ use crate::server::core::downloader::DownloadConfig; use crate::server::common::util::redact_process_debug; use crate::server::errors::{AppError, AppResult}; +use crate::tools; use bytes::Bytes; use error_stack::ResultExt; use std::collections::HashMap; @@ -9,7 +10,7 @@ use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdout, Command}; +use tokio::process::{Child, ChildStdout}; use tokio::sync::RwLock; use tokio::time::timeout; use tokio_util::sync::CancellationToken; @@ -120,7 +121,7 @@ impl SyncDownloader { pipe_input: bool, ) -> AppResult { let args = build_ffmpeg_args(download_config, max_file_size, pipe_input); - let mut cmd = Command::new("ffmpeg"); + let mut cmd = tools::ffmpeg_command(); cmd.args(&args) .stdin(if pipe_input { Stdio::piped() @@ -132,7 +133,8 @@ impl SyncDownloader { .kill_on_drop(true); info!(cmd = %redact_process_debug(&cmd), "Starting sync-downloader ffmpeg"); cmd.spawn().change_context(AppError::Custom( - "未安装 FFmpeg 或不在 PATH 中,边录边传无法启动".into(), + "找不到 FFmpeg,边录边传无法启动:请安装 FFmpeg 并加入 PATH,或在配置里填写 ffmpeg_path" + .into(), )) } @@ -142,7 +144,7 @@ impl SyncDownloader { max_file_size: u64, ) -> AppResult { let sl_args = build_streamlink_args(download_config); - let mut sl_cmd = Command::new("streamlink"); + let mut sl_cmd = tools::command("streamlink"); sl_cmd .args(&sl_args) .stdin(Stdio::null()) @@ -397,7 +399,7 @@ fn format_ffmpeg_headers(headers: &HashMap) -> String { } fn command_exists(name: &str, arg: &str) -> bool { - std::process::Command::new(name) + tools::std_command(name) .arg(arg) .stdout(Stdio::null()) .stderr(Stdio::null()) diff --git a/crates/biliup-cli/src/server/core/downloader/ytdlp.rs b/crates/biliup-cli/src/server/core/downloader/ytdlp.rs index 4977a80..ea680e0 100644 --- a/crates/biliup-cli/src/server/core/downloader/ytdlp.rs +++ b/crates/biliup-cli/src/server/core/downloader/ytdlp.rs @@ -3,6 +3,7 @@ use crate::server::core::downloader::{ }; use crate::server::common::util::redact_process_debug; use crate::server::errors::{AppError, AppResult}; +use crate::tools; use error_stack::{ResultExt, bail}; use std::{ path::{Path, PathBuf}, @@ -12,7 +13,7 @@ use std::{ }, time::Duration, }; -use tokio::{fs, process::Command, time::timeout}; +use tokio::{fs, time::timeout}; use tracing::{debug, info, warn}; #[derive(Clone, Debug)] @@ -214,7 +215,7 @@ impl YouTubeDownloader { "best".to_string() }; - let mut cmd = Command::new(&self.cfg.ytdlp_bin); + let mut cmd = tools::command(&self.cfg.ytdlp_bin); cmd.arg("--outtmpl") .arg(format!( "{}/{}.%(ext)s", @@ -321,7 +322,7 @@ impl YouTubeDownloader { )))?; // 在缓存目录中执行 ytarchive - let mut cmd = Command::new(&self.cfg.ytarchive_bin); + let mut cmd = tools::command(&self.cfg.ytarchive_bin); cmd.current_dir(&cache_dir) .arg(&self.cfg.webpage_url) .arg("best") diff --git a/crates/biliup-cli/src/server/infrastructure/models/hook_step.rs b/crates/biliup-cli/src/server/infrastructure/models/hook_step.rs index 65bd20a..2973f88 100644 --- a/crates/biliup-cli/src/server/infrastructure/models/hook_step.rs +++ b/crates/biliup-cli/src/server/infrastructure/models/hook_step.rs @@ -1,11 +1,11 @@ use crate::server::errors::{AppError, AppResult}; +use crate::tools; use error_stack::{ResultExt, bail}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::fs; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::process::Command; use tracing::{error, info}; /// 钩子步骤枚举:支持多种操作格式 @@ -121,7 +121,7 @@ impl HookStep { async fn ffmpeg_remux_to_mp4(src: &Path, dst: &Path) -> AppResult<()> { info!("remux ts→mp4: {} → {}", src.display(), dst.display()); let started = std::time::Instant::now(); - let status = Command::new("ffmpeg") + let status = tools::ffmpeg_command() .args([ "-hide_banner", "-loglevel", @@ -214,7 +214,7 @@ impl HookStep { // 执行自定义命令 // 解析命令和参数 // 启动子进程,配置标准输入管道 - let mut process = Command::new(shell) + let mut process = tools::command(shell) .arg(flag) .arg(cmd) .stdin(std::process::Stdio::piped()) diff --git a/crates/biliup-cli/src/server/infrastructure/permissions.rs b/crates/biliup-cli/src/server/infrastructure/permissions.rs index 8dbe67d..08fa7fc 100644 --- a/crates/biliup-cli/src/server/infrastructure/permissions.rs +++ b/crates/biliup-cli/src/server/infrastructure/permissions.rs @@ -161,6 +161,8 @@ pub fn required_permission(method: &Method, route: &str, raw_path: &str) -> Opti "/v1/uploads" if method == Method::POST => UploadSubmit, "/v1/videos" if get => FileView, "/v1/status" if get => StreamerView, + // 三个角色都有 StreamerView,等于登录即可 + "/v1/tools" if get => StreamerView, "/v1/ws/logs" if get => LogView, "/static/{path}" if get => { let name = raw_path.rsplit('/').next().unwrap_or_default(); diff --git a/crates/biliup-cli/src/server/router.rs b/crates/biliup-cli/src/server/router.rs index 1056c7c..bf97c02 100644 --- a/crates/biliup-cli/src/server/router.rs +++ b/crates/biliup-cli/src/server/router.rs @@ -4,7 +4,7 @@ use crate::server::api::bilibili_endpoints::{ use crate::server::api::endpoints::{ add_upload_streamer_endpoint, add_user_endpoint, delete_streamers_endpoint, delete_template_endpoint, delete_user_endpoint, get_configuration, get_qrcode, get_status, - get_streamer_info, get_streamer_info_files, get_streamers_endpoint, + get_streamer_info, get_streamer_info_files, get_streamers_endpoint, get_tools, get_upload_streamer_endpoint, get_upload_streamers_endpoint, get_users_endpoint, get_videos, login_by_qrcode, pause_streamers_endpoint, post_streamers_endpoint, post_uploads, put_configuration, put_streamers_endpoint, @@ -83,6 +83,7 @@ pub fn router(service_register: ServiceRegister) -> Router<()> { // 视频文件管理路由 .route("/v1/videos", get(get_videos)) // 获取视频列表 .route("/v1/status", get(get_status)) + .route("/v1/tools", get(get_tools)) // ffmpeg 是否可用、版本与许可 .route("/v1/uploads", post(post_uploads)) .route("/static/{path}", get(using_serve_file_from_a_route)) .with_state(service_register) // 注入服务注册器状态 diff --git a/crates/biliup-cli/src/tools.rs b/crates/biliup-cli/src/tools.rs new file mode 100644 index 0000000..241c67c --- /dev/null +++ b/crates/biliup-cli/src/tools.rs @@ -0,0 +1,363 @@ +//! External programs: where ffmpeg is, and how child processes are started. +//! +//! ffmpeg is looked up in this order: the `ffmpeg_path` setting, then the one +//! the embedding host ships (the desktop app passes the ffmpeg bundled in its +//! installer through `ServeOptions::ffmpeg`), then `ffmpeg` on `PATH`. + +use serde::Serialize; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Mutex, PoisonError, RwLock}; +use std::time::Duration; + +pub use biliup::tools::{command, std_command}; + +const FFMPEG: &str = "ffmpeg"; +const VERSION_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum FfmpegSource { + /// The `ffmpeg_path` setting + Config, + /// Shipped with the embedding host (the desktop installer) + Bundled, + /// `ffmpeg` on `PATH` + Path, +} + +struct Locations { + configured: Option, + bundled: Option, +} + +static LOCATIONS: RwLock = RwLock::new(Locations { + configured: None, + bundled: None, +}); + +/// Successful `ffmpeg -version` results, keyed by the program that was run. +static VERSION_CACHE: Mutex> = Mutex::new(None); + +/// Applies the `ffmpeg_path` setting; blank means unset. +pub fn set_configured_ffmpeg(path: Option<&str>) { + let path = path + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(PathBuf::from); + LOCATIONS + .write() + .unwrap_or_else(PoisonError::into_inner) + .configured = path; +} + +/// Sets the ffmpeg shipped with the embedding host. +pub fn set_bundled_ffmpeg(path: Option) { + LOCATIONS + .write() + .unwrap_or_else(PoisonError::into_inner) + .bundled = path; +} + +/// The ffmpeg program to run and where it came from. +pub fn locate_ffmpeg() -> (PathBuf, FfmpegSource) { + let locations = LOCATIONS.read().unwrap_or_else(PoisonError::into_inner); + resolve( + locations.configured.as_deref(), + locations.bundled.as_deref(), + ) +} + +/// The ffmpeg program to run. +pub fn ffmpeg() -> PathBuf { + locate_ffmpeg().0 +} + +/// A [`command`] that runs ffmpeg. +pub fn ffmpeg_command() -> tokio::process::Command { + command(ffmpeg()) +} + +fn resolve(configured: Option<&Path>, bundled: Option<&Path>) -> (PathBuf, FfmpegSource) { + if let Some(path) = configured { + (path.to_path_buf(), FfmpegSource::Config) + } else if let Some(path) = bundled { + (path.to_path_buf(), FfmpegSource::Bundled) + } else { + (PathBuf::from(FFMPEG), FfmpegSource::Path) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FfmpegVersion { + /// First line of `ffmpeg -version` + pub version: String, + /// SPDX id of the license the build is under, derived from its configure flags + pub license: Option<&'static str>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FfmpegStatus { + pub available: bool, + pub source: FfmpegSource, + /// The program that is run; `None` when it is not found on `PATH` + pub path: Option, + pub version: Option, + pub license: Option<&'static str>, + pub error: Option, +} + +/// Runs `ffmpeg -version` on the ffmpeg that would be used. Successful results +/// are cached per program, failures are retried on the next call. +pub async fn ffmpeg_status() -> FfmpegStatus { + let (program, source) = locate_ffmpeg(); + let path = match source { + FfmpegSource::Path => find_in_path(FFMPEG, std::env::var_os("PATH").as_deref()), + _ => Some(program.clone()), + }; + let cached = VERSION_CACHE + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .filter(|(cached, _)| *cached == program) + .map(|(_, version)| version.clone()); + let result = match cached { + Some(version) => Ok(version), + None => probe_version(&program, source).await.inspect(|version| { + tracing::info!( + program = %program.display(), + ?source, + version = %version.version, + "ffmpeg -version succeeded" + ); + *VERSION_CACHE.lock().unwrap_or_else(PoisonError::into_inner) = + Some((program.clone(), version.clone())); + }), + }; + match result { + Ok(version) => FfmpegStatus { + available: true, + source, + path, + version: Some(version.version), + license: version.license, + error: None, + }, + Err(error) => FfmpegStatus { + available: false, + source, + path, + version: None, + license: None, + error: Some(error), + }, + } +} + +async fn probe_version(program: &Path, source: FfmpegSource) -> Result { + let output = command(program) + .arg("-version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(); + let output = match tokio::time::timeout(VERSION_TIMEOUT, output).await { + Ok(Ok(output)) => output, + Ok(Err(err)) if err.kind() == std::io::ErrorKind::NotFound => { + let hint = match source { + FfmpegSource::Config => "请检查配置里的 ffmpeg_path", + _ => "请安装 FFmpeg 并加入 PATH,或在配置里填写 ffmpeg_path", + }; + return Err(format!("找不到 {}:{hint}", program.display())); + } + Ok(Err(err)) => return Err(format!("无法运行 {}:{err}", program.display())), + Err(_) => { + return Err(format!( + "{} -version 在 {} 秒内没有返回", + program.display(), + VERSION_TIMEOUT.as_secs() + )); + } + }; + if !output.status.success() { + return Err(format!( + "{} -version 退出码 {}", + program.display(), + output.status + )); + } + parse_version_output(&String::from_utf8_lossy(&output.stdout)) + .ok_or_else(|| format!("{} -version 的输出无法识别", program.display())) +} + +fn parse_version_output(stdout: &str) -> Option { + let version = stdout.lines().next()?.trim(); + if !version.starts_with("ffmpeg version") { + return None; + } + let license = stdout + .lines() + .find_map(|line| line.trim().strip_prefix("configuration:")) + .map(license_from_configuration); + Some(FfmpegVersion { + version: version.to_string(), + license, + }) +} + +/// Same rules as FFmpeg's `configure` uses to pick the license it reports. +fn license_from_configuration(configuration: &str) -> &'static str { + let enabled = |flag: &str| configuration.split_whitespace().any(|f| f == flag); + match ( + enabled("--enable-nonfree"), + enabled("--enable-gpl"), + enabled("--enable-version3"), + ) { + (true, _, _) => "LicenseRef-nonfree", + (false, true, true) => "GPL-3.0-or-later", + (false, true, false) => "GPL-2.0-or-later", + (false, false, true) => "LGPL-3.0-or-later", + (false, false, false) => "LGPL-2.1-or-later", + } +} + +/// Where the OS would find `program` on `path_var`, for display only. +fn find_in_path(program: &str, path_var: Option<&OsStr>) -> Option { + let names: Vec = if cfg!(windows) { + vec![format!("{program}.exe"), program.to_string()] + } else { + vec![program.to_string()] + }; + std::env::split_paths(path_var?) + .flat_map(|dir| names.iter().map(move |name| dir.join(name))) + .find(|candidate| candidate.is_file()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BTBN_WIN64: &str = "ffmpeg version n8.1.2-50-g1a748fe2cd-20260831 Copyright (c) 2000-2026 the FFmpeg developers\n\ +built with gcc 15.2.0 (crosstool-NG 1.28.0.21_3c5cc17)\n\ +configuration: --prefix=/ffbuild/prefix --pkg-config-flags=--static --enable-gpl --enable-version3 --disable-debug --enable-libx264 --extra-version=20260831\n\ +libavutil 60. 26.100 / 60. 26.100\n"; + + #[test] + fn setting_wins_over_bundled_and_path() { + let configured = Path::new("/opt/ffmpeg/bin/ffmpeg"); + let bundled = Path::new("C:/biliup/ffmpeg/ffmpeg.exe"); + assert_eq!( + resolve(Some(configured), Some(bundled)), + (configured.to_path_buf(), FfmpegSource::Config) + ); + assert_eq!( + resolve(None, Some(bundled)), + (bundled.to_path_buf(), FfmpegSource::Bundled) + ); + assert_eq!( + resolve(None, None), + (PathBuf::from("ffmpeg"), FfmpegSource::Path) + ); + } + + #[test] + fn version_line_and_license_are_read_from_the_output() { + assert_eq!( + parse_version_output(BTBN_WIN64), + Some(FfmpegVersion { + version: "ffmpeg version n8.1.2-50-g1a748fe2cd-20260831 Copyright (c) 2000-2026 the FFmpeg developers".into(), + license: Some("GPL-3.0-or-later"), + }) + ); + assert_eq!(parse_version_output("not ffmpeg\n"), None); + assert_eq!(parse_version_output(""), None); + } + + #[test] + fn license_follows_configure() { + assert_eq!( + license_from_configuration("--enable-gpl --enable-libx264"), + "GPL-2.0-or-later" + ); + assert_eq!( + license_from_configuration("--enable-version3"), + "LGPL-3.0-or-later" + ); + assert_eq!( + license_from_configuration("--prefix=/usr"), + "LGPL-2.1-or-later" + ); + assert_eq!( + license_from_configuration("--enable-gpl --enable-nonfree"), + "LicenseRef-nonfree" + ); + // A flag that merely starts with --enable-gpl is not --enable-gpl. + assert_eq!( + license_from_configuration("--enable-gpl-foo"), + "LGPL-2.1-or-later" + ); + } + + #[test] + fn path_lookup_finds_the_first_match() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let name = if cfg!(windows) { + "ffmpeg.exe" + } else { + "ffmpeg" + }; + std::fs::write(second.path().join(name), b"").unwrap(); + let path_var = std::env::join_paths([first.path(), second.path()]).unwrap(); + assert_eq!( + find_in_path("ffmpeg", Some(&path_var)), + Some(second.path().join(name)) + ); + assert_eq!(find_in_path("ffmpeg", Some(OsStr::new(""))), None); + assert_eq!(find_in_path("ffmpeg", None), None); + } + + #[cfg(unix)] + #[tokio::test] + async fn probe_runs_the_program() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let fake = dir.path().join("ffmpeg"); + std::fs::write( + &fake, + "#!/bin/sh\n[ \"$1\" = -version ] || exit 2\nprintf 'ffmpeg version 7.0-test\\nconfiguration: --enable-gpl\\n'\n", + ) + .unwrap(); + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + probe_version(&fake, FfmpegSource::Config).await, + Ok(FfmpegVersion { + version: "ffmpeg version 7.0-test".into(), + license: Some("GPL-2.0-or-later"), + }) + ); + + let missing = dir.path().join("missing"); + let err = probe_version(&missing, FfmpegSource::Config) + .await + .unwrap_err(); + assert!( + err.contains("找不到") && err.contains("请检查配置"), + "{err}" + ); + let err = probe_version(Path::new("ffmpeg-not-on-path"), FfmpegSource::Path) + .await + .unwrap_err(); + assert!(err.contains("PATH"), "{err}"); + + let failing = dir.path().join("failing"); + std::fs::write(&failing, "#!/bin/sh\nexit 3\n").unwrap(); + std::fs::set_permissions(&failing, std::fs::Permissions::from_mode(0o755)).unwrap(); + let err = probe_version(&failing, FfmpegSource::Config) + .await + .unwrap_err(); + assert!(err.contains("退出码"), "{err}"); + } +} diff --git a/crates/biliup-cli/tests/serve_on_listener.rs b/crates/biliup-cli/tests/serve_on_listener.rs index cc699d0..3fda587 100644 --- a/crates/biliup-cli/tests/serve_on_listener.rs +++ b/crates/biliup-cli/tests/serve_on_listener.rs @@ -42,6 +42,7 @@ async fn serves_on_prebound_listener_until_host_shutdown() { shutdown: Some(Box::pin(async move { let _ = stopped.await; })), + ffmpeg: None, })); let mut status = None; diff --git a/crates/biliup-cli/tests/tools_endpoint.rs b/crates/biliup-cli/tests/tools_endpoint.rs new file mode 100644 index 0000000..76b9c39 --- /dev/null +++ b/crates/biliup-cli/tests/tools_endpoint.rs @@ -0,0 +1,126 @@ +//! `GET /v1/tools` follows where ffmpeg comes from: the `ffmpeg_path` setting, +//! then the host-provided one, then `PATH`. Kept in its own test binary because +//! `work_dir` switches the working directory of the whole process. +#![cfg(unix)] + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use biliup_cli::entry::{ServeOptions, init_tracing_with, serve}; +use serde_json::Value; + +fn fake_ffmpeg(dir: &Path, version: &str) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join("ffmpeg"); + std::fs::write( + &path, + format!( + "#!/bin/sh\nprintf 'ffmpeg version {version}\\nconfiguration: --enable-gpl --enable-version3\\n'\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +async fn get_json(client: &reqwest::Client, url: &str) -> Value { + let response = client.get(url).send().await.unwrap(); + assert!( + response.status().is_success(), + "GET {url}: {}", + response.status() + ); + serde_json::from_slice(&response.bytes().await.unwrap()).unwrap() +} + +async fn set_ffmpeg_path(client: &reqwest::Client, base: &str, path: Value) { + let mut config = get_json(client, &format!("{base}/v1/configuration")).await; + config["ffmpeg_path"] = path; + let response = client + .put(format!("{base}/v1/configuration")) + .header("content-type", "application/json") + .body(serde_json::to_vec(&config).unwrap()) + .send() + .await + .unwrap(); + assert!( + response.status().is_success(), + "PUT /v1/configuration: {}", + response.status() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tools_report_follows_setting_then_host_ffmpeg() { + let work_dir = tempfile::tempdir().unwrap(); + let bundled = fake_ffmpeg(&work_dir.path().join("bundled"), "bundled-test"); + let configured = fake_ffmpeg(&work_dir.path().join("configured"), "configured-test"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); + let logging = init_tracing_with("info", false); + + let server = tokio::spawn(serve(ServeOptions { + bind: "127.0.0.1".into(), + port: 1, + auth: false, + secure_session_cookie: false, + config: None, + user_cookie: "cookies.json".into(), + work_dir: Some(work_dir.path().to_path_buf()), + log_handle: logging.handle(), + listener: Some(listener), + shutdown: Some(Box::pin(async move { + let _ = stopped.await; + })), + ffmpeg: Some(bundled.clone()), + })); + + let client = reqwest::Client::new(); + let mut ready = false; + for _ in 0..100 { + if client.get(format!("{base}/")).send().await.is_ok() { + ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(ready, "server never answered"); + let tools = format!("{base}/v1/tools"); + + let ffmpeg = get_json(&client, &tools).await["ffmpeg"].clone(); + assert_eq!(ffmpeg["available"], true, "{ffmpeg}"); + assert_eq!(ffmpeg["source"], "bundled"); + assert_eq!(ffmpeg["path"], bundled.to_str().unwrap()); + assert_eq!(ffmpeg["version"], "ffmpeg version bundled-test"); + assert_eq!(ffmpeg["license"], "GPL-3.0-or-later"); + + set_ffmpeg_path(&client, &base, configured.to_str().unwrap().into()).await; + let ffmpeg = get_json(&client, &tools).await["ffmpeg"].clone(); + assert_eq!(ffmpeg["source"], "config", "{ffmpeg}"); + assert_eq!(ffmpeg["path"], configured.to_str().unwrap()); + assert_eq!(ffmpeg["version"], "ffmpeg version configured-test"); + + let missing = work_dir.path().join("missing/ffmpeg"); + set_ffmpeg_path(&client, &base, missing.to_str().unwrap().into()).await; + let ffmpeg = get_json(&client, &tools).await["ffmpeg"].clone(); + assert_eq!(ffmpeg["available"], false, "{ffmpeg}"); + assert_eq!(ffmpeg["source"], "config"); + assert!( + ffmpeg["error"].as_str().unwrap().contains("找不到"), + "{ffmpeg}" + ); + + set_ffmpeg_path(&client, &base, " ".into()).await; + let ffmpeg = get_json(&client, &tools).await["ffmpeg"].clone(); + assert_eq!(ffmpeg["source"], "bundled", "{ffmpeg}"); + assert_eq!(ffmpeg["version"], "ffmpeg version bundled-test"); + + stop.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(10), server) + .await + .expect("server did not stop") + .unwrap() + .unwrap(); +} diff --git a/crates/biliup/src/downloader/live/general.rs b/crates/biliup/src/downloader/live/general.rs index 34a454e..2d197e8 100644 --- a/crates/biliup/src/downloader/live/general.rs +++ b/crates/biliup/src/downloader/live/general.rs @@ -2,13 +2,13 @@ use super::{ DownloaderHint, LiveError, LivePlugin, LiveRequest, LiveResult, LiveStatus, LiveStream, RuntimeOptions, YtDlpBackend, YtDlpOptions, media_ext_from_url, }; +use crate::tools; use async_trait::async_trait; use chrono::Utc; use regex::Regex; use serde_json::Value; use std::collections::HashMap; use std::process::Stdio; -use tokio::process::Command; pub struct General { re: Regex, @@ -128,7 +128,7 @@ impl GeneralLive { } async fn extract_info(&self, url: &str) -> LiveResult> { - let output = Command::new("yt-dlp") + let output = tools::command("yt-dlp") .stdin(Stdio::null()) .arg("--dump-single-json") .arg("--skip-download") @@ -151,7 +151,7 @@ impl GeneralLive { } async fn streamlink_url(&self) -> Option { - let output = Command::new("streamlink") + let output = tools::command("streamlink") .stdin(Stdio::null()) .arg("--stream-url") .arg(&self.url) diff --git a/crates/biliup/src/downloader/live/niconico.rs b/crates/biliup/src/downloader/live/niconico.rs index 08bb3ab..3de490f 100644 --- a/crates/biliup/src/downloader/live/niconico.rs +++ b/crates/biliup/src/downloader/live/niconico.rs @@ -2,6 +2,7 @@ use super::{ DownloaderHint, LiveError, LivePlugin, LiveRequest, LiveResult, LiveStatus, LiveStream, RuntimeOptions, StreamlinkOptions, StreamlinkPlatform, }; +use crate::tools; use async_trait::async_trait; use chrono::Utc; use regex::Regex; @@ -100,7 +101,7 @@ impl NiconicoLive { } async fn streamlink_available(&self) -> LiveResult { - let mut command = Command::new("streamlink"); + let mut command = tools::command("streamlink"); command.stdin(Stdio::null()).arg("--stream-url"); self.apply_streamlink_args(&mut command); let output = command diff --git a/crates/biliup/src/downloader/live/twitch.rs b/crates/biliup/src/downloader/live/twitch.rs index 3ca7fbf..cdaaeb1 100644 --- a/crates/biliup/src/downloader/live/twitch.rs +++ b/crates/biliup/src/downloader/live/twitch.rs @@ -3,6 +3,7 @@ use super::{ LiveResult, LiveStatus, LiveStream, RuntimeOptions, StreamlinkOptions, StreamlinkPlatform, YtDlpOptions, media_ext_from_url, }; +use crate::tools; use async_trait::async_trait; use chrono::Utc; use regex::Regex; @@ -18,7 +19,6 @@ use std::process::Stdio; use std::sync::{OnceLock, RwLock}; use tokio::fs; use tokio::io::AsyncWriteExt; -use tokio::process::Command; use tokio::sync::Mutex; use tokio::time::Duration; use tracing::warn; @@ -423,7 +423,7 @@ impl TwitchVideosLive { Some(token) => write_cookie_file(token).await, None => None, }; - let mut command = Command::new("yt-dlp"); + let mut command = tools::command("yt-dlp"); command .stdin(Stdio::null()) .arg("--dump-single-json") diff --git a/crates/biliup/src/downloader/live/youtube.rs b/crates/biliup/src/downloader/live/youtube.rs index c90c2e1..18ef2c0 100644 --- a/crates/biliup/src/downloader/live/youtube.rs +++ b/crates/biliup/src/downloader/live/youtube.rs @@ -2,6 +2,7 @@ use super::{ DanmakuSource, DownloaderHint, LiveError, LivePlugin, LiveRequest, LiveResult, LiveStatus, LiveStream, RuntimeOptions, YtDlpBackend, YtDlpOptions, media_ext_from_url, }; +use crate::tools; use async_trait::async_trait; use chrono::Utc; use regex::Regex; @@ -11,7 +12,6 @@ use std::future::Future; use std::pin::Pin; use std::process::Stdio; use tokio::fs; -use tokio::process::Command; pub struct Youtube { re: Regex, @@ -171,7 +171,7 @@ impl YoutubeLive { } async fn extract_info(&self, url: &str, flat: bool) -> LiveResult> { - let mut command = Command::new("yt-dlp"); + let mut command = tools::command("yt-dlp"); command .stdin(Stdio::null()) .arg("--dump-single-json") diff --git a/crates/biliup/src/lib.rs b/crates/biliup/src/lib.rs index 1fb0c8d..e6deab7 100644 --- a/crates/biliup/src/lib.rs +++ b/crates/biliup/src/lib.rs @@ -7,6 +7,7 @@ use tracing::info; pub mod client; pub mod downloader; pub mod error; +pub mod tools; pub mod uploader; pub use uploader::bilibili; diff --git a/crates/biliup/src/tools.rs b/crates/biliup/src/tools.rs new file mode 100644 index 0000000..d3ce86c --- /dev/null +++ b/crates/biliup/src/tools.rs @@ -0,0 +1,50 @@ +//! Starting external programs (ffmpeg, streamlink, yt-dlp, hook commands). + +use std::ffi::OsStr; + +/// Without this, a host that has no console (the desktop app is a +/// GUI-subsystem program) gets a new console window for every console program +/// it starts. A host running in a terminal must not use it: its children would +/// get their own hidden console instead of sharing the terminal, so Ctrl+C and +/// their output would no longer reach it. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn GetConsoleWindow() -> *mut std::ffi::c_void; +} + +/// Whether child processes are started with `CREATE_NO_WINDOW`: on Windows when +/// this process has no console window, never elsewhere. +pub fn hides_console_windows() -> bool { + #[cfg(windows)] + { + // SAFETY: takes no arguments and only reads this process's console state. + unsafe { GetConsoleWindow() }.is_null() + } + #[cfg(not(windows))] + { + false + } +} + +/// [`std::process::Command::new`] that opens no console window on Windows when +/// this process has no console of its own. +pub fn std_command(program: impl AsRef) -> std::process::Command { + #[cfg_attr(not(windows), allow(unused_mut))] + let mut command = std::process::Command::new(program); + #[cfg(windows)] + if hides_console_windows() { + use std::os::windows::process::CommandExt; + command.creation_flags(CREATE_NO_WINDOW); + } + command +} + +/// [`tokio::process::Command::new`] that opens no console window on Windows +/// when this process has no console of its own. +pub fn command(program: impl AsRef) -> tokio::process::Command { + tokio::process::Command::from(std_command(program)) +} diff --git a/crates/biliup/tests/console_detection.rs b/crates/biliup/tests/console_detection.rs new file mode 100644 index 0000000..3149635 --- /dev/null +++ b/crates/biliup/tests/console_detection.rs @@ -0,0 +1,36 @@ +//! Runs this test binary again as a child process with different consoles and +//! lets the child report what `hides_console_windows()` sees. +#![cfg(windows)] + +use std::os::windows::process::CommandExt; + +const PROBE: &str = "BILIUP_CONSOLE_PROBE"; +const TEST: &str = "children_share_the_console_only_when_there_is_one"; +const DETACHED_PROCESS: u32 = 0x0000_0008; +const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +// Distinct from the test harness's own exit codes. +const HIDES: i32 = 21; +const SHARES: i32 = 20; + +#[test] +fn children_share_the_console_only_when_there_is_one() { + if std::env::var_os(PROBE).is_some() { + let hides = biliup::tools::hides_console_windows(); + std::process::exit(if hides { HIDES } else { SHARES }); + } + let run = |flags: u32| { + std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST, "--test-threads=1"]) + .env(PROBE, "1") + .creation_flags(flags) + .status() + .unwrap() + .code() + }; + // Like the desktop app: no console at all, or a console without a window. + assert_eq!(run(DETACHED_PROCESS), Some(HIDES)); + assert_eq!(run(CREATE_NO_WINDOW), Some(HIDES)); + // Like `biliup server` in a terminal. + assert_eq!(run(CREATE_NEW_CONSOLE), Some(SHARES)); +} diff --git a/tauri-app/README.md b/tauri-app/README.md index d708402..c2a2a03 100644 --- a/tauri-app/README.md +++ b/tauri-app/README.md @@ -8,6 +8,7 @@ Tauri 壳:在本进程内启动 biliup 服务(`biliup_cli::entry::serve`) - 其他平台:`app_data_dir()`,不询问(`desktop.json` 里写了 `data_dir` 则用它)。 - 所选目录还没有 `data/` 时,会把旧版留下的 `data/` 复制过去,原处保留。旧版的位置依次找:安装目录、Windows 上旧 `bbup-app` 安装程序记录的安装目录(注册表)和 `%LOCALAPPDATA%\bbup-app`。 - `static/index.html` 只是启动页,显示「正在启动」或启动失败的原因。 +- ffmpeg:发布 workflow 下载与 Dockerfile 同一构建的 BtbN win64 GPL 静态版,把 `ffmpeg.exe`、`LICENSE.txt` 和说明放进 `src-tauri/ffmpeg/`,构建时用 `--config src-tauri/tauri.ffmpeg.conf.json` 作为资源打进安装包。程序启动时若安装目录下有 `ffmpeg/ffmpeg.exe` 就交给服务使用(配置里填了 `ffmpeg_path` 时以配置为准),否则到 PATH 里找。本地构建不加这个参数即可,不需要下载 ffmpeg。 构建(需要先在仓库根目录 `npm run build` 生成 `out/`,WebUI 会被编进程序): diff --git a/tauri-app/src-tauri/.gitignore b/tauri-app/src-tauri/.gitignore index b21bd68..0cd2108 100644 --- a/tauri-app/src-tauri/.gitignore +++ b/tauri-app/src-tauri/.gitignore @@ -5,3 +5,6 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas + +# ffmpeg downloaded by the release workflow (see tauri.ffmpeg.conf.json) +/ffmpeg/ diff --git a/tauri-app/src-tauri/Cargo.lock b/tauri-app/src-tauri/Cargo.lock index 12000c9..b171d01 100644 --- a/tauri-app/src-tauri/Cargo.lock +++ b/tauri-app/src-tauri/Cargo.lock @@ -666,6 +666,7 @@ name = "biliup-desktop" version = "0.1.0" dependencies = [ "biliup-cli", + "dunce", "serde", "serde_json", "tauri", diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index e33af41..7922679 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri = { version = "2", features = [] } tauri-plugin-single-instance = "2" tauri-plugin-dialog = "2" biliup-cli = { path = "../../crates/biliup-cli" } +dunce = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["io-util", "net", "sync", "time"] } diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 06bd107..74524db 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -26,6 +26,12 @@ const WINDOW_LABEL: &str = "main"; const STARTUP_PAGE: &str = "http://tauri.localhost/"; #[cfg(not(windows))] const STARTUP_PAGE: &str = "tauri://localhost/"; +/// Where the installer puts ffmpeg under the resource directory; the release +/// workflow adds it through `tauri.ffmpeg.conf.json`. +#[cfg(windows)] +const BUNDLED_FFMPEG: [&str; 2] = ["ffmpeg", "ffmpeg.exe"]; +#[cfg(not(windows))] +const BUNDLED_FFMPEG: [&str; 2] = ["ffmpeg", "ffmpeg"]; struct Server { stop: Mutex>>, @@ -58,7 +64,10 @@ pub fn run() { }; let legacy_roots = data_dir::legacy_roots(&install_dir); match data_dir::resolve(&app, &window, &install_dir, &legacy_roots) { - Ok(Some(data_dir)) => start(&app, &window, &data_dir, &legacy_roots), + Ok(Some(data_dir)) => { + let ffmpeg = bundled_ffmpeg(app.path().resource_dir().ok()); + start(&app, &window, &data_dir, &legacy_roots, ffmpeg) + } Ok(None) => app.exit(0), Err(message) => show_error(&window, &message), } @@ -82,7 +91,13 @@ pub fn run() { /// Moves into the data directory, migrates legacy data and starts the server. /// Failures are shown in the window instead of aborting the app. -fn start(app: &AppHandle, window: &WebviewWindow, data_dir: &Path, legacy_roots: &[PathBuf]) { +fn start( + app: &AppHandle, + window: &WebviewWindow, + data_dir: &Path, + legacy_roots: &[PathBuf], + ffmpeg: Option, +) { // The server keeps data/, ds_update.log and recordings relative to the // working directory, and the log file is opened relative to it as well. if let Err(err) = @@ -100,6 +115,10 @@ fn start(app: &AppHandle, window: &WebviewWindow, data_dir: &Path, legacy_roots: true, ); tracing::info!(data_dir = %data_dir.display(), "biliup desktop starting"); + match &ffmpeg { + Some(path) => tracing::info!(path = %path.display(), "bundled ffmpeg found"), + None => tracing::info!("no bundled ffmpeg; using the ffmpeg_path setting or PATH"), + } if let Some(legacy_dir) = data_dir::find_legacy_root(legacy_roots) { match data_dir::migrate_legacy_data(legacy_dir, data_dir) { @@ -156,6 +175,7 @@ fn start(app: &AppHandle, window: &WebviewWindow, data_dir: &Path, legacy_roots: user_cookie: PathBuf::from("cookies.json"), work_dir: None, log_handle: logging.handle(), + ffmpeg, listener: Some(listener), shutdown: Some(Box::pin(async move { let _ = stopped.await; @@ -284,6 +304,13 @@ async fn web_ui_ready(port: u16) -> bool { ) } +fn bundled_ffmpeg(resource_dir: Option) -> Option { + // On Windows resource_dir() is a `\\?\` path; this one ends up in logs and GET /v1/tools. + let mut path = dunce::simplified(&resource_dir?).to_path_buf(); + path.extend(BUNDLED_FFMPEG); + Some(path).filter(|path| path.is_file()) +} + /// Where this executable lives. The old PyInstaller sidecar used it as its /// working directory, so an older install may have left data/ there. fn install_dir() -> Option { @@ -328,4 +355,21 @@ mod tests { fn percent_encoding_round_trips_through_decode_uri_component() { assert_eq!(percent_encode("a b/ç%"), "a%20b%2F%C3%A7%25"); } + + #[test] + fn bundled_ffmpeg_is_used_only_when_installed() { + let resources = std::env::temp_dir().join(format!( + "biliup-desktop-test-resources-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&resources); + fs::create_dir_all(&resources).unwrap(); + assert_eq!(bundled_ffmpeg(Some(resources.clone())), None); + assert_eq!(bundled_ffmpeg(None), None); + let ffmpeg = resources.join("ffmpeg").join(BUNDLED_FFMPEG[1]); + fs::create_dir_all(ffmpeg.parent().unwrap()).unwrap(); + fs::write(&ffmpeg, b"").unwrap(); + assert_eq!(bundled_ffmpeg(Some(resources.clone())), Some(ffmpeg)); + fs::remove_dir_all(&resources).unwrap(); + } } diff --git a/tauri-app/src-tauri/tauri.ffmpeg.conf.json b/tauri-app/src-tauri/tauri.ffmpeg.conf.json new file mode 100644 index 0000000..50c433a --- /dev/null +++ b/tauri-app/src-tauri/tauri.ffmpeg.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": { + "ffmpeg/ffmpeg.exe": "ffmpeg/ffmpeg.exe", + "ffmpeg/LICENSE.txt": "ffmpeg/LICENSE.txt", + "ffmpeg/README.txt": "ffmpeg/README.txt" + } + } +}