跳转至

crates\nfarms-core\src\live.rs

源码路径:crates\nfarms-core\src\live.rs · 563 行

//! bililive-go 适配层 —— 对应 C# `Live.cs`。
//!
//! bililive-go 是个 Go 写的多平台直播录制工具,**自身是个本地服务**
//! (默认 `127.0.0.1:8080`)。这里不重写它,只做三件事:找到它、起停它、
//! 读改它的 `config.yml`(加/删直播间)。
//!
//! `config.yml` 用手写解析而不是引 YAML 库:我们只需要 `live_rooms:` 下面
//! 那一串 `- url: ...`,引一个 YAML 库(连带 `unsafe-libyaml` 的 C 代码)
//! 不划算。**改配置时会保留原文的其它部分** —— 只动 `live_rooms` 那一段,
//! 免得把用户手写的其它设置抹掉。
//!
//! 进程回收:起出来的子进程挂在一个「最后一个句柄关闭时结束里面所有进程」的
//! Job Object 上(Win32 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)。界面那边只拿着
//! `Child`,`Drop` 到它不会杀进程;用户不点「停止」直接关窗口,bililive-go
//! 就会一直录下去。挂到 Job 上之后,程序正常退出、崩溃、被任务管理器结束,
//! 内核都会连它一起收走。

use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
#[cfg(windows)]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

// Job Object 的封装**不在本文件里**:`observability.rs`(Grafana 那三件套)要用
// 同一份,那段 `unsafe` 抄第二份就是第二个会漂移的来源。见 `crate::job_win`。
#[cfg(windows)]
use crate::job_win;

/// 服务默认端口。
pub const PORT: u16 = 8080;
pub const RELEASES_PAGE: &str = "https://github.com/bililive-go/bililive-go/releases";
pub const VERSION: &str = "v0.8.2";
pub const LOG_NAME: &str = "bililive-go.log";

pub struct Live {
    pub dir: PathBuf,
    /// `logs\` —— 日志统一放这里,**不跟组件自己的目录混**
    pub logs: PathBuf,
    pub exe: Option<PathBuf>,
    /// 子进程共用的 Job Object 句柄,`0` = 还没建或建失败。
    ///
    /// 存在 `Live` 里(而不是每次 `start()` 新建)有两个理由:
    /// 一是只有 `Live` 的生命周期和界面状态一样长,能靠 `Drop` 收尾;
    /// 二是界面丢了 `Child` 也照样收得回来 —— 句柄在谁手上不影响内核记账。
    #[cfg(windows)]
    job: AtomicUsize,
}

impl Live {
    pub fn detect(paths: &crate::Paths) -> Self {
        let dir = paths.lib("bililive-go");
        let exe = [dir.join("bililive-go.exe")]
            .into_iter()
            .find(|p| p.is_file())
            .or_else(|| {
                // 解包时多套了一层目录也认(和 ffmpeg 那个坑一样)
                std::fs::read_dir(&dir).ok().and_then(|it| {
                    it.flatten()
                        .map(|e| e.path().join("bililive-go.exe"))
                        .find(|p| p.is_file())
                })
            });
        Self {
            dir,
            logs: paths.logs(),
            exe,
            #[cfg(windows)]
            job: AtomicUsize::new(0),
        }
    }

    pub fn available(&self) -> bool {
        self.exe.is_some()
    }

    pub fn config_path(&self) -> PathBuf {
        self.dir.join("config.yml")
    }

    /// 日志路径 —— **统一放 `logs\`**,不落在组件自己那层。
    ///
    /// 以前是 `libs\bililive-go\bililive-go.log`,和程序日志分居两处,
    /// 出问题时要翻两个地方。现在所有日志一个目录。
    pub fn log_path(&self) -> PathBuf {
        self.logs.join(LOG_NAME)
    }

    pub fn status_text(&self) -> String {
        match &self.exe {
            Some(p) => format!("bililive-go 可用: {}", p.display()),
            None => format!(
                "bililive-go 未安装:把 bililive-go.exe 放到 {} 即启用。\
                 它是独立的外部程序,程序不打包它(下载见 {RELEASES_PAGE})。",
                self.dir.display()
            ),
        }
    }

    /// 探服务在不在。返回版本/响应摘要。
    pub fn ping(&self) -> Result<String, String> {
        let url = format!("http://127.0.0.1:{PORT}/api/info");
        let agent: ureq::Agent = ureq::Agent::config_builder()
            .timeout_global(Some(Duration::from_secs(3)))
            .build()
            .into();
        match agent.get(&url).call() {
            Ok(r) => Ok(format!("HTTP {}", r.status().as_u16())),
            Err(ureq::Error::StatusCode(c)) => Ok(format!("HTTP {c}(服务在跑,这个路径不对)")),
            Err(_) => {
                // 退一步试根路径:不同版本暴露的接口不一样
                match agent.get(&format!("http://127.0.0.1:{PORT}/")).call() {
                    Ok(r) => Ok(format!("HTTP {}(根路径可访问)", r.status().as_u16())),
                    Err(_) => Err("连不上 127.0.0.1:8080 —— 服务没起来".to_string()),
                }
            }
        }
    }

    /// 起服务(输出重定向到日志文件,和 dsh 那套一样)。
    ///
    /// 子进程会被挂进随本 `Live` 一起消失的 Job Object,详见模块头。
    pub fn start(&self) -> Result<Child, String> {
        let Some(exe) = &self.exe else {
            return Err(self.status_text());
        };
        let out = std::fs::File::create(self.log_path())
            .map_err(|e| format!("建日志文件失败: {e}"))?;
        let err = out.try_clone().map_err(|e| format!("复制句柄失败: {e}"))?;
        let mut cmd = Command::new(exe);
        // 工作目录必须是它自己那层,否则 config.yml 会落在别处
        cmd.current_dir(&self.dir)
            .stdin(Stdio::null())
            .stdout(Stdio::from(out))
            .stderr(Stdio::from(err));
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            cmd.creation_flags(0x0800_0000);
        }
        self.spawn_attached(&mut cmd)
            .map_err(|e| format!("起 bililive-go 失败: {e}"))
    }

    /// 起子进程,顺手挂到 Job Object 上。
    ///
    /// `start()` 和测试都走这一条 —— 免得"测的是另一条路"。
    fn spawn_attached(&self, cmd: &mut Command) -> std::io::Result<Child> {
        let child = cmd.spawn()?;
        #[cfg(windows)]
        {
            let job = self.kill_on_close_job();
            if job != 0 {
                // 挂不上(比如本进程已经在某个不允许嵌套的 Job 里)不算致命:
                // 退回到"没人回收"的旧行为,总比起不来强。
                let _ = job_win::assign(job, &child);
            }
        }
        Ok(child)
    }

    /// 懒建 Job Object 并返回句柄(`0` = 没建成)。
    #[cfg(windows)]
    fn kill_on_close_job(&self) -> usize {
        let existing = self.job.load(Ordering::Acquire);
        if existing != 0 {
            return existing;
        }
        let fresh = match job_win::create() {
            Ok(handle) => handle,
            // 建不出来(比如 Job 已经被外层限制死)就退回"没人回收"的旧行为,
            // 不能因为回收不了就不让用户起服务
            Err(_) => return 0,
        };
        // 并发下可能已经被别的线程建好了:用它的,把自己这份空的关掉
        //(里面还没有进程,关掉不会牵连谁)
        match self
            .job
            .compare_exchange(0, fresh, Ordering::AcqRel, Ordering::Acquire)
        {
            Ok(_) => fresh,
            Err(other) => {
                job_win::close(fresh);
                other
            }
        }
    }

    /// 只给测试用:确认 Job 已经建出来了。
    #[cfg(test)]
    fn job_handle(&self) -> usize {
        #[cfg(windows)]
        {
            self.job.load(Ordering::Acquire)
        }
        #[cfg(not(windows))]
        {
            0
        }
    }

    /// 从 `config.yml` 里读直播间地址列表。
    pub fn rooms(&self) -> Vec<String> {
        match std::fs::read_to_string(self.config_path()) {
            Ok(t) => parse_rooms(&t),
            Err(_) => Vec::new(),
        }
    }

    /// 加一个直播间。返回是否真的改了(重复添加返回 false)。
    pub fn add_room(&self, url: &str) -> Result<bool, String> {
        let url = url.trim();
        if url.is_empty() {
            return Err("没填直播间地址".to_string());
        }
        let path = self.config_path();
        let text = std::fs::read_to_string(&path).unwrap_or_else(|_| default_config());
        if parse_rooms(&text).iter().any(|r| r == url) {
            return Ok(false);
        }
        let new = upsert_room(&text, url);
        std::fs::write(&path, new).map_err(|e| format!("写 config.yml 失败: {e}"))?;
        Ok(true)
    }

    /// 删一个直播间(按地址匹配;也接受序号)。
    pub fn remove_room(&self, id_or_url: &str) -> Result<bool, String> {
        let path = self.config_path();
        let text = std::fs::read_to_string(&path).map_err(|e| format!("读 config.yml 失败: {e}"))?;
        let rooms = parse_rooms(&text);
        let target = match id_or_url.trim().parse::<usize>() {
            Ok(i) if i < rooms.len() => rooms[i].clone(),
            _ => id_or_url.trim().to_string(),
        };
        if !rooms.contains(&target) {
            return Ok(false);
        }
        let new = remove_room_line(&text, &target);
        std::fs::write(&path, new).map_err(|e| format!("写 config.yml 失败: {e}"))?;
        Ok(true)
    }
}

/// 关掉 Job Object 的最后一个句柄 —— 内核随即结束里面所有进程。
///
/// 这里没有 `Child::kill()`:句柄在界面状态里,`Live` 拿不到;而且想覆盖的
/// 恰恰是"程序没能好好退出"的那些路径(崩溃 / 被结束),那些路径上只有
/// 内核收句柄这一下是必然发生的。代价是退出时不会先跟 bililive-go 打个
/// 招呼(它是录制工具,没点「停止」就退,本来也没有"录完这一段"的语义)。
#[cfg(windows)]
impl Drop for Live {
    fn drop(&mut self) {
        job_win::close(self.job.swap(0, Ordering::AcqRel));
    }
}

/// 一份最小可用的配置。
fn default_config() -> String {
    format!(
        "rpc:\n  enable: true\n  bind: 127.0.0.1:{PORT}\ndebug: false\nlive_rooms: []\nout_put_path: ./live\n"
    )
}

/// 从配置文本里抠出 `live_rooms:` 下面的 `- url: ...`。
///
/// 两种写法都要认:块状(`live_rooms:` 换行后跟 `- url: x`)和行内
/// (`live_rooms: []` 或 `live_rooms: [a, b]`)。
pub fn parse_rooms(text: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut in_block = false;
    for line in text.lines() {
        let trimmed = line.trim_end();
        if trimmed.trim().is_empty() {
            continue;
        }
        let indented = line.starts_with(' ') || line.starts_with('\t');
        let t = trimmed.trim();
        // 顶层键 = 没缩进 **且不是列表项**。
        // 这里踩过一次:`live_rooms:` 下面的 `- url: x` 也是列 0 开头,
        // 只看缩进的话它会被当成新键,整个块就被吞了。
        if !indented && !t.starts_with("- ") {
            if let Some(rest) = trimmed.strip_prefix("live_rooms:") {
                let rest = rest.trim();
                if rest.starts_with('[') {
                    let inner = rest.trim_start_matches('[').trim_end_matches(']');
                    for item in inner.split(',') {
                        let v = item.trim().trim_matches(['"', '\'']);
                        if !v.is_empty() {
                            out.push(v.to_string());
                        }
                    }
                    in_block = false;
                } else {
                    in_block = true;
                }
                continue;
            }
            in_block = false;
            continue;
        }
        if in_block {
            if let Some(item) = t.strip_prefix("- ") {
                let item = item.trim();
                // `- url: x` 或直接 `- x`
                let v = item
                    .strip_prefix("url:")
                    .map(|s| s.trim())
                    .unwrap_or(item)
                    .trim_matches(['"', '\'']);
                if !v.is_empty() {
                    out.push(v.to_string());
                }
            } else {
                in_block = false;
            }
        }
    }
    out
}

/// 往配置里加一条直播间,**保留其它内容**。
///
/// 只动 `live_rooms:` 那一处:其它键(用户手写的缓冲目录、RPC 绑定等)原样带走。
fn upsert_room(text: &str, url: &str) -> String {
    let mut out = String::new();
    let mut done = false;
    for line in text.lines() {
        if !done
            && let Some(rest) = line.trim_end().strip_prefix("live_rooms:")
        {
            let inline = rest.trim();
            out.push_str("live_rooms:\n");
            if inline.starts_with('[') {
                // 行内数组 → 展开成块状,原有项保留
                for r in parse_rooms(text) {
                    out.push_str(&format!("- url: {r}\n"));
                }
            }
            // 块状时原有项在后面的行里,不用动;新项插在键名之后即可
            //(YAML 列表项之间没有顺序语义)
            out.push_str(&format!("- url: {url}\n"));
            done = true;
            continue;
        }
        out.push_str(line);
        out.push('\n');
    }
    if !done {
        out.push_str(&format!("\nlive_rooms:\n- url: {url}\n"));
    }
    out
}

/// 删掉匹配的那一行。
fn remove_room_line(text: &str, url: &str) -> String {
    text.lines()
        .filter(|l| {
            let t = l.trim();
            if !t.starts_with("- ") {
                return true;
            }
            let item = t[2..].trim();
            let v = item
                .strip_prefix("url:")
                .map(|s| s.trim())
                .unwrap_or(item)
                .trim_matches(['"', '\'']);
            v != url
        })
        .map(|l| format!("{l}\n"))
        .collect()
}

/// 最近一次录制的文件(在 `out_put_path` 下找最新的媒体文件)。
pub fn newest_recording(dir: &Path, max_age_minutes: i64) -> Option<PathBuf> {
    const EXT: [&str; 4] = ["flv", "mp4", "ts", "mkv"];
    let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
    fn walk(d: &Path, depth: usize, best: &mut Option<(std::time::SystemTime, PathBuf)>) {
        if depth == 0 {
            return;
        }
        let Ok(it) = std::fs::read_dir(d) else { return };
        for e in it.flatten() {
            let p = e.path();
            if p.is_dir() {
                walk(&p, depth - 1, best);
                continue;
            }
            let is_media = p
                .extension()
                .and_then(|x| x.to_str())
                .map(|x| EXT.contains(&x.to_ascii_lowercase().as_str()))
                .unwrap_or(false);
            if !is_media {
                continue;
            }
            if let Ok(m) = e.metadata()
                && let Ok(t) = m.modified()
                && best.as_ref().map(|(bt, _)| t > *bt).unwrap_or(true)
            {
                *best = Some((t, p));
            }
        }
    }
    walk(dir, 4, &mut best);
    let (t, p) = best?;
    let age = std::time::SystemTime::now()
        .duration_since(t)
        .map(|d| d.as_secs() as i64 / 60)
        .unwrap_or(0);
    if age <= max_age_minutes { Some(p) } else { None }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_block_style_rooms() {
        let y = "rpc:\n  enable: true\nlive_rooms:\n- url: https://live.bilibili.com/1\n- url: https://live.bilibili.com/2\nout_put_path: ./live\n";
        assert_eq!(
            parse_rooms(y),
            vec![
                "https://live.bilibili.com/1",
                "https://live.bilibili.com/2"
            ]
        );
    }

    #[test]
    fn parses_inline_and_empty_arrays() {
        assert!(parse_rooms("live_rooms: []\n").is_empty());
        assert_eq!(
            parse_rooms("live_rooms: [a, b]\n"),
            vec!["a".to_string(), "b".to_string()]
        );
    }

    #[test]
    fn stops_at_the_next_top_level_key() {
        let y = "live_rooms:\n- url: a\nrpc:\n- url: should-not-appear\n";
        assert_eq!(parse_rooms(y), vec!["a"]);
    }

    #[test]
    fn add_room_preserves_other_lines() {
        let y = default_config();
        let out = upsert_room(&y, "https://live.bilibili.com/9");
        assert!(out.contains("rpc:"), "其它配置不能被抹掉");
        assert!(out.contains("out_put_path: ./live"));
        assert_eq!(parse_rooms(&out), vec!["https://live.bilibili.com/9"]);
        // 再加一条,两条都在
        let out2 = upsert_room(&out, "https://live.bilibili.com/10");
        assert_eq!(parse_rooms(&out2).len(), 2);
    }

    #[test]
    fn remove_room_drops_only_that_line() {
        let y = "live_rooms:\n- url: a\n- url: b\nout_put_path: ./live\n";
        let out = remove_room_line(y, "a");
        assert_eq!(parse_rooms(&out), vec!["b"]);
        assert!(out.contains("out_put_path"));
    }

    #[test]
    fn missing_binary_reports_how_to_install() {
        let dir = std::env::temp_dir().join("nfarms-live-test");
        let _ = std::fs::create_dir_all(&dir);
        let live = Live::detect(&crate::Paths::from_exe_dir(&dir));
        // 临时目录下肯定没有 exe(除非用户恰好放了一个)
        if !live.available() {
            assert!(live.status_text().contains("未安装"));
            assert!(live.rooms().is_empty());
            assert!(live.start().is_err());
        }
    }

    /// 临时目录里的一个 `Live`,不依赖机器上装没装 bililive-go。
    #[cfg(windows)]
    fn temp_live(tag: &str) -> Live {
        let dir = std::env::temp_dir().join(format!("nfarms-live-{tag}"));
        let _ = std::fs::create_dir_all(&dir);
        Live::detect(&crate::Paths::from_exe_dir(&dir))
    }

    /// Job 只建一次:句柄要能复用,不然每起一次进程就攒一个句柄。
    ///
    /// 那条 ABI 布局断言(`ExtendedLimitInformation` 必须 144 字节)跟着实现
    /// 搬去了 `crate::job_win::tests` —— 两件服务共用同一份结构体,断言也得
    /// 只留一份。这里只管 `Live` 自己的复用逻辑。
    #[test]
    #[cfg(windows)]
    fn job_object_is_built_once_and_reused() {
        let live = temp_live("job-reuse");
        assert_eq!(live.job_handle(), 0, "还没起进程,不该先建 Job");
        let first = live.kill_on_close_job();
        assert_ne!(first, 0, "Job Object 没建出来");
        assert_eq!(
            live.kill_on_close_job(),
            first,
            "第二次应该复用同一个 Job"
        );
        assert_eq!(live.job_handle(), first, "句柄要记在 Live 上,Drop 才关得掉");
    }

    /// 回归:`Live` 被丢掉(用户不点「停止」直接关窗口)时,子进程必须跟着走。
    ///
    /// 改之前 `Child` 只在界面状态里,`Drop` 它等于放手 —— bililive-go 变成
    /// 孤儿继续录。这条测试盯的就是"关掉 Job 句柄 → 进程消失"。
    #[test]
    #[cfg(windows)]
    fn dropping_live_kills_the_started_child() {
        use std::os::windows::process::CommandExt;
        use std::time::Instant;

        // bililive-go.exe 不保证这台机器上有,拿个必定存在的长跑进程当替身:
        // ping 自己发 15 次(每秒一次)≈ 15 秒,足够看出它有没有被回收。
        let ping = PathBuf::from(std::env::var("SystemRoot").unwrap_or_default())
            .join("System32")
            .join("ping.exe");
        if !ping.is_file() {
            eprintln!("跳过:{} 不存在,拿不到长跑替身", ping.display());
            return;
        }

        let live = temp_live("kill-on-drop");
        let mut cmd = Command::new(&ping);
        cmd.args(["-n", "15", "127.0.0.1"])
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .creation_flags(0x0800_0000);
        let mut child = live.spawn_attached(&mut cmd).expect("替身进程没起来");

        // 先确认它真被挂进 Job 了 —— 没挂进去的话这个测试等于什么都没测
        let job = live.job_handle();
        assert_ne!(job, 0, "Job Object 没建出来");
        assert!(
            job_win::is_member(job, &child),
            "替身进程没进 Job(AssignProcessToJobObject 失败)"
        );
        assert!(
            child.try_wait().expect("try_wait 失败").is_none(),
            "替身进程应该还在跑"
        );

        // 相当于界面状态被丢掉
        drop(live);

        let deadline = Instant::now() + Duration::from_secs(5);
        loop {
            if child.try_wait().expect("try_wait 失败").is_some() {
                return; // 内核把它收走了
            }
            if Instant::now() >= deadline {
                let _ = child.kill();
                let _ = child.wait();
                panic!("关掉 Job Object 后替身进程还活着 —— 孤儿进程又回来了");
            }
            std::thread::sleep(Duration::from_millis(50));
        }
    }
}