change core library to libilibili

This commit is contained in:
2026-07-18 12:57:36 -07:00
parent f599672a30
commit 1598d3c403
34 changed files with 795 additions and 12219 deletions
-9
View File
@@ -27,12 +27,3 @@ data/
Thumbs.db Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
# Only the vendored blivedm crate source and its manifests are required to
# compile the local patch. Exclude its upstream docs, examples and tooling.
vendor/blivedm/*
!vendor/blivedm/Cargo.toml
!vendor/blivedm/Cargo.lock
!vendor/blivedm/LICENSE
!vendor/blivedm/src/
!vendor/blivedm/src/**
+1 -1
View File
@@ -2,7 +2,7 @@
## Project Structure & Module Organization ## Project Structure & Module Organization
`apps/server-rust/` contains the Axum backend, live-event adapters, authentication, persistence, and WebSocket delivery. Database migrations live in `apps/server-rust/migrations/`; Rust unit and async tests are normally kept beside the modules they exercise. `apps/overlay/src/` contains the React/Vite control and OBS interfaces, with PWA code in `apps/overlay/pwa/` and static assets in `apps/overlay/public/`. Architecture, protocol, security, and component notes belong in `docs/`; deployment support lives in `deploy/`, `Dockerfile`, and `compose.yaml`. `vendor/blivedm/` is an intentionally patched dependency—edit it only for a targeted upstream integration change. `apps/server-rust/` contains the Axum backend, live-event adapters, authentication, persistence, and WebSocket delivery. Database migrations live in `apps/server-rust/migrations/`; Rust unit and async tests are normally kept beside the modules they exercise. `apps/overlay/src/` contains the React/Vite control and OBS interfaces, with PWA code in `apps/overlay/pwa/` and static assets in `apps/overlay/public/`. Architecture, protocol, security, and component notes belong in `docs/`; deployment support lives in `deploy/`, `Dockerfile`, and `compose.yaml`. The backend uses the sibling `../libilibili` checkout as a path dependency; coordinate API changes in that crate instead of copying it here.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
+5 -6
View File
@@ -6,10 +6,12 @@ COPY apps/overlay ./
RUN npm run build RUN npm run build
FROM rust:1.97-bookworm AS rust-build FROM rust:1.97-bookworm AS rust-build
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libasound2-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/ COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/
COPY vendor/blivedm ./vendor/blivedm # `libilibili` is a sibling checkout in local development. Compose exposes it
# as a named build context so the image always compiles the same local source
# without copying a second, drifting crate into this repository.
COPY --from=libilibili . /libilibili
COPY apps/server-rust/src ./apps/server-rust/src COPY apps/server-rust/src ./apps/server-rust/src
COPY apps/server-rust/migrations ./apps/server-rust/migrations COPY apps/server-rust/migrations ./apps/server-rust/migrations
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
@@ -18,11 +20,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl libasound2 libssl3 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# The upstream client logs full upstream HTTP responses at INFO, which can include
# account metadata and short-lived connection tokens. Keep application lifecycle
# logs while suppressing dependency INFO output by default.
COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server
COPY --from=web-build /app/apps/overlay/dist /app/web COPY --from=web-build /app/apps/overlay/dist /app/web
EXPOSE 9719 EXPOSE 9719
+14 -5
View File
@@ -3,9 +3,10 @@
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request` 这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request`
点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在 直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
`vendor/blivedm` crate。它负责 Cookie/WBI
固定了保留原始 JSON、可观测发送失败和安全重连所需的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃,也让宿主在 socket 恢复失败后重建客户端。 API、直播 WebSocket 认证、心跳以及 JSON、zlib、brotli 包解析;本项目的 provider
adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。crate 尚未建模的少量兼容命令只在 provider 边界解析,原始包不会进入组件协议。
## 文档索引 ## 文档索引
@@ -36,6 +37,14 @@
## 部署 ## 部署
源码目录需要保持为相邻 checkout,Compose 会把 `libilibili` 作为独立 BuildKit 上下文传入镜像:
```text
source/
├── libilibili/
└── lxc-streamutils/
```
先复制配置并生成独立密钥: 先复制配置并生成独立密钥:
```sh ```sh
@@ -82,8 +91,8 @@ git diff --check
``` ```
`.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git `.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git
ignore 会排除依赖、构建产物、第三方 vendor、PNG/SVG 和包含真实 Secret 的 `config.toml`。不要对 ignore 会排除依赖、构建产物、PNG/SVG 和包含真实 Secret 的 `config.toml`。`libilibili`
`vendor/blivedm` 做无关的批量风格改写,以便继续审查上游补丁。 是独立 crate;协议解析能力应在该项目中维护,本仓库只维护领域事件适配。
## 首次初始化与登录 ## 首次初始化与登录
+146 -2409
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -7,14 +7,12 @@ edition = "2024"
axum = { version = "0.8", features = ["ws", "json"] } axum = { version = "0.8", features = ["ws", "json"] }
async-trait = "0.1" async-trait = "0.1"
base64 = "0.22" base64 = "0.22"
# Patched local copy of the published blivedm_rs crate. The patch preserves
# the upstream raw payload so the application can retain UID, price and event
# identifiers for atomic accounting and gift de-duplication.
blivedm = { path = "../../vendor/blivedm", default-features = false }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
deadpool-postgres = "0.14" deadpool-postgres = "0.14"
futures-channel = "0.3" # Kept as a sibling checkout during development. Docker Compose supplies the
# same directory as a named BuildKit context at `/libilibili`.
libilibili = { path = "../../../libilibili" }
rand = "0.9" rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+3 -2
View File
@@ -27,7 +27,7 @@ crate 导出。
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。 - handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。 - tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。 - provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
- Bilibili listener 必须保持 20 秒心跳;socket 内部重连失败后由 adapter 重建完整客户端。 - `libilibili` listener 必须保持 20 秒心跳;断线后由 adapter 重新获取弹幕 host/token 并重建 socket。
- projection 无副作用;可靠业务动作必须使用幂等 handler。 - projection 无副作用;可靠业务动作必须使用幂等 handler。
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。 - token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
- EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。 - EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。
@@ -42,7 +42,8 @@ cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
``` ```
第三方 `vendor/blivedm` 不作为本项目风格重写目标;项目只维护保留原始 JSON 所需的小补丁。 直播协议、WBI 与 WebSocket 解包由相邻的 `libilibili`
crate 维护;本项目只测试强类型命令到领域事件的映射。
更多设计说明: 更多设计说明:
+1 -1
View File
@@ -218,7 +218,7 @@ impl Config {
legacy_obs_access_token: file.obs.access_token, legacy_obs_access_token: file.obs.access_token,
legacy_overlay_defaults: overlay_defaults(file.overlay), legacy_overlay_defaults: overlay_defaults(file.overlay),
log_filter: file.logging.filter.unwrap_or_else(|| { log_filter: file.logging.filter.unwrap_or_else(|| {
"lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into() "lxc_stream_server=info,libilibili=warn,tokio_postgres=warn".into()
}), }),
gift_refresh_seconds: file gift_refresh_seconds: file
.gifts .gifts
+567 -198
View File
@@ -1,19 +1,21 @@
//! Bilibili `blivedm_rs` adapter and raw-command normalization. //! Bilibili `libilibili` adapter and canonical event normalization.
//! //!
//! The adapter authenticates with a CookieCloud-derived cookie, refreshes gift //! The adapter authenticates with a CookieCloud-derived cookie, refreshes gift
//! and emoticon catalogs, and converts supported Bilibili commands into bounded //! and emoticon catalogs, drives the asynchronous live WebSocket heartbeat, and
//! domain payloads. Unknown commands expose only sanitized metadata—never the //! converts supported commands into bounded domain payloads. Commands not yet
//! original unbounded packet or authentication material. //! modeled by `libilibili` are interpreted only inside this provider boundary;
//! unknown events expose sanitized command names, never raw packets or secrets.
use std::{ use std::{sync::Arc, time::Duration};
sync::Arc,
thread,
time::{Duration, Instant},
};
use async_trait::async_trait; use async_trait::async_trait;
use blivedm::client::{models::BiliMessage, websocket::BiliLiveClient}; use libilibili::{
use futures_channel::mpsc as futures_mpsc; Client, Credentials,
websocket::{
ComboSendMessage, DanmuMessage, GiftMessage, InteractWordMessage, LikeClickMessage,
LiveCommand, LiveEvent as UpstreamLiveEvent, LiveWebSocket, SuperChatMessage,
},
};
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -146,12 +148,21 @@ impl BilibiliProvider {
viewer, viewer,
name, name,
gift_id, gift_id,
coin_type,
battery, battery,
quantity, quantity,
event_id, event_id,
} => LiveEventPayload::Gift(GiftEvent { } => LiveEventPayload::Gift(GiftEvent {
viewer, viewer,
gift: gift_details(&self.gift_catalog, name, gift_id, battery, quantity).await, gift: gift_details(
&self.gift_catalog,
name,
gift_id,
coin_type,
battery,
quantity,
)
.await,
quantity: quantity.max(1), quantity: quantity.max(1),
source_event_id: event_id, source_event_id: event_id,
}), }),
@@ -159,12 +170,21 @@ impl BilibiliProvider {
viewer, viewer,
name, name,
gift_id, gift_id,
coin_type,
battery, battery,
quantity, quantity,
combo_id, combo_id,
} => LiveEventPayload::GiftCombo(GiftComboEvent { } => LiveEventPayload::GiftCombo(GiftComboEvent {
viewer, viewer,
gift: gift_details(&self.gift_catalog, name, gift_id, battery, quantity).await, gift: gift_details(
&self.gift_catalog,
name,
gift_id,
coin_type,
battery,
quantity,
)
.await,
quantity: quantity.max(1), quantity: quantity.max(1),
combo_id, combo_id,
}), }),
@@ -227,151 +247,149 @@ impl LiveProvider for BilibiliProvider {
self.initial_catalogs(&context.room_id).await; self.initial_catalogs(&context.room_id).await;
self.spawn_catalog_refreshes(context.room_id.clone(), cancel.clone()); self.spawn_catalog_refreshes(context.room_id.clone(), cancel.clone());
let (raw_sender, mut raw_receiver) = mpsc::channel::<ProviderEvent>(256); let room_id = context
let cookie = self.cookie.to_string(); .room_id
let room_id = context.room_id.clone(); .parse::<u64>()
let listener_cancel = cancel.clone(); .map_err(|_| "Bilibili room ID must be an unsigned integer".to_owned())?;
let listener_status = status.clone(); let client = Client::with_credentials(Credentials::from_cookie_header(&*self.cookie))
let listener = tokio::task::spawn_blocking(move || -> Result<(), String> { .map_err(|error| format!("cannot create libilibili client: {error}"))?;
// A full-client retry loop complements blivedm_rs' short socket
// reconnect loop. If all in-place attempts fail, recreating the 'provider: loop {
// client refreshes the danmaku host and authentication token. let connection = tokio::select! {
while !listener_cancel.is_cancelled() { _ = cancel.cancelled() => break 'provider,
let (upstream_sender, mut upstream_receiver) = futures_mpsc::channel(256); result = LiveWebSocket::connect(&client, room_id) => result,
let mut client = };
match BiliLiveClient::new_auto(Some(&cookie), &room_id, upstream_sender) { let mut socket = match connection {
Ok(client) => client, Ok(socket) => socket,
Err(error) => { Err(error) => {
warn!(%error, %room_id, "Bilibili listener creation failed; retrying"); warn!(%error, room_id = %context.room_id, "Bilibili connection failed; retrying");
let _ = listener_status.send(SourceStatus { send_status(
source_id: context.source_id, &status,
room_id: room_id.clone(), &context,
connected: false, false,
cookie_cloud: true, format!("Bilibili connection failed; retrying: {error}"),
detail: format!("Bilibili connection failed; retrying: {error}"), );
}); if wait_for_retry(&cancel).await {
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF); break 'provider;
}
continue; continue;
} }
}; };
if let Err(error) = client.set_read_timeout(Some(Duration::from_secs(1))) {
client.close(); // `connect` sends the authentication packet. An immediate heartbeat
return Err(format!("cannot configure Bilibili socket timeout: {error}")); // matches the web client and starts the server's liveness window.
if let Err(error) = socket.heartbeat().await {
warn!(%error, room_id = %context.room_id, "initial Bilibili heartbeat failed");
send_status(
&status,
&context,
false,
format!("Bilibili heartbeat failed; retrying: {error}"),
);
if wait_for_retry(&cancel).await {
break 'provider;
} }
// Bilibili closes otherwise healthy danmaku sockets after
// roughly one minute without a heartbeat. The upstream CLI
// sends one every 20 seconds; embedded users must do the same.
if !client.send_auth() || !client.send_heart_beat() {
client.close();
let _ = listener_status.send(SourceStatus {
source_id: context.source_id,
room_id: room_id.clone(),
connected: false,
cookie_cloud: true,
detail: "Bilibili authentication failed; retrying".into(),
});
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF);
continue; continue;
} }
let _ = listener_status.send(SourceStatus { send_status(
source_id: context.source_id, &status,
room_id: room_id.clone(), &context,
connected: true, true,
cookie_cloud: true, "Bilibili socket connected; waiting for live events",
detail: "Bilibili socket connected; waiting for live events".into(), );
}); info!(room_id = %context.room_id, "libilibili live socket connected and heartbeat started");
info!(%room_id, "Bilibili live socket connected and heartbeat started");
let mut last_heartbeat = Instant::now(); let mut next_heartbeat = tokio::time::Instant::now() + HEARTBEAT_INTERVAL;
let mut received_event = false; let mut received_event = false;
let disconnect_error = loop { let disconnect_error = loop {
if listener_cancel.is_cancelled() { let until_heartbeat =
client.close(); next_heartbeat.saturating_duration_since(tokio::time::Instant::now());
return Ok(()); let upstream = tokio::select! {
_ = cancel.cancelled() => break 'provider,
result = tokio::time::timeout(until_heartbeat, socket.next_event()) => result,
};
let upstream = match upstream {
Err(_) => {
if let Err(error) = socket.heartbeat().await {
break format!("heartbeat failed: {error}");
} }
if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL { next_heartbeat = tokio::time::Instant::now() + HEARTBEAT_INTERVAL;
if !client.send_heart_beat() { continue;
break "heartbeat recovery failed".to_owned();
} }
last_heartbeat = Instant::now(); Ok(Err(error)) => break error.to_string(),
} Ok(Ok(None)) => break "upstream closed the WebSocket".to_owned(),
if let Err(error) = client.receive() { Ok(Ok(Some(event))) => event,
};
if let Some(error) = authentication_error(&upstream) {
break error; break error;
} }
while let Ok(message) = upstream_receiver.try_recv() { let Some(raw) = normalize_upstream(upstream) else {
let Some(message) = normalize(message) else {
continue; continue;
}; };
if !received_event { if !received_event {
received_event = true; received_event = true;
info!(%room_id, "Bilibili listener received its first live event"); info!(room_id = %context.room_id, "libilibili listener received its first live event");
let _ = listener_status.send(SourceStatus { send_status(
source_id: context.source_id, &status,
room_id: room_id.clone(), &context,
connected: true, true,
cookie_cloud: true, "Connected and receiving live events",
detail: "Connected and receiving live events".into(), );
});
}
if raw_sender.blocking_send(message).is_err() {
client.close();
return Ok(());
} }
let event = Arc::new(self.enrich(&context, raw).await);
if events.send(event).await.is_err() {
break 'provider;
} }
}; };
client.close(); warn!(error = %disconnect_error, room_id = %context.room_id, "Bilibili listener disconnected; rebuilding libilibili socket");
warn!(error = %disconnect_error, %room_id, "Bilibili listener disconnected; rebuilding client"); send_status(
let _ = listener_status.send(SourceStatus { &status,
source_id: context.source_id, &context,
room_id: room_id.clone(), false,
connected: false, format!("Bilibili disconnected; retrying: {disconnect_error}"),
cookie_cloud: true, );
detail: format!("Bilibili disconnected; retrying: {disconnect_error}"), if wait_for_retry(&cancel).await {
}); break 'provider;
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF);
}
Ok(())
});
loop {
tokio::select! {
_ = cancel.cancelled() => break,
value = raw_receiver.recv() => {
let Some(value) = value else { break };
let event = Arc::new(self.enrich(&context, value).await);
if events.send(event).await.is_err() { break; }
}
} }
} }
cancel.cancel(); cancel.cancel();
listener send_status(&status, &context, false, "Listener stopped");
.await
.map_err(|error| format!("listener task failed: {error}"))??;
let _ = status.send(SourceStatus {
source_id: context.source_id,
room_id: context.room_id,
connected: false,
cookie_cloud: true,
detail: "Listener stopped".into(),
});
Ok(()) Ok(())
} }
} }
/// Blocking listener tasks cannot await a cancellation token. Sleeping in fn send_status(
/// short slices keeps shutdown/reconfiguration latency bounded. status: &watch::Sender<SourceStatus>,
fn wait_for_retry(cancel: &CancellationToken, duration: Duration) { context: &SourceContext,
let deadline = Instant::now() + duration; connected: bool,
while !cancel.is_cancelled() { detail: impl Into<String>,
let remaining = deadline.saturating_duration_since(Instant::now()); ) {
if remaining.is_zero() { let _ = status.send(SourceStatus {
break; source_id: context.source_id,
} room_id: context.room_id.clone(),
thread::sleep(remaining.min(Duration::from_millis(200))); connected,
cookie_cloud: true,
detail: detail.into(),
});
}
/// Returns `true` when cancellation won the retry wait.
async fn wait_for_retry(cancel: &CancellationToken) -> bool {
tokio::select! {
_ = cancel.cancelled() => true,
_ = tokio::time::sleep(RECONNECT_BACKOFF) => false,
} }
} }
fn authentication_error(event: &UpstreamLiveEvent) -> Option<String> {
let UpstreamLiveEvent::Auth(payload) = event else {
return None;
};
let code = payload.get("code").and_then(Value::as_i64).unwrap_or(-1);
(code != 0).then(|| format!("Bilibili WebSocket authentication rejected with code {code}"))
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct EmoticonHint { struct EmoticonHint {
text: String, text: String,
@@ -397,6 +415,7 @@ enum ProviderEvent {
viewer: PlatformViewer, viewer: PlatformViewer,
name: String, name: String,
gift_id: Option<i64>, gift_id: Option<i64>,
coin_type: Option<String>,
battery: i32, battery: i32,
quantity: i32, quantity: i32,
event_id: String, event_id: String,
@@ -405,6 +424,7 @@ enum ProviderEvent {
viewer: PlatformViewer, viewer: PlatformViewer,
name: String, name: String,
gift_id: Option<i64>, gift_id: Option<i64>,
coin_type: Option<String>,
battery: i32, battery: i32,
quantity: i32, quantity: i32,
combo_id: String, combo_id: String,
@@ -432,13 +452,249 @@ enum ProviderEvent {
}, },
} }
fn normalize(message: BiliMessage) -> Option<ProviderEvent> { fn normalize_upstream(event: UpstreamLiveEvent) -> Option<ProviderEvent> {
let raw = match message { let UpstreamLiveEvent::Command(command) = event else {
BiliMessage::Raw(value) => value, return None;
_ => return None,
}; };
normalize_command(*command)
}
fn normalize_command(command: LiveCommand) -> Option<ProviderEvent> {
match command {
LiveCommand::Danmu(message) => normalize_danmaku(&message),
LiveCommand::Gift(message) => normalize_gift(&message),
LiveCommand::ComboSend(message) => normalize_combo(&message),
LiveCommand::LikeClick(message) => normalize_like(&message),
LiveCommand::LikeUpdate(message) => unknown(message.cmd),
LiveCommand::LikeNotice(message) => unknown(message.cmd),
LiveCommand::GuardBuy(message) => Some(ProviderEvent::Guard {
viewer: PlatformViewer {
uid: message.uid.to_string(),
name: message.username,
},
name: message.gift_name,
quantity: bounded_i32(message.num).max(1),
price: message.extra.get("price").and_then(value_i64).unwrap_or(0),
}),
LiveCommand::SuperChat(message) => normalize_superchat(&message),
LiveCommand::InteractWord(message) => normalize_interaction(&message),
// Invalid/unknown commands retain raw JSON inside libilibili. We only
// use that fallback for legacy aliases or malformed versions of known
// commands; every other raw payload is discarded at this boundary.
LiveCommand::Unknown { command, raw }
| LiveCommand::Invalid {
command,
raw,
error: _,
} => normalize_raw(&raw).or_else(|| {
Some(ProviderEvent::Unknown {
command: sanitized_command(command.as_deref()),
})
}),
LiveCommand::OnlineRankCount(message) => unknown(message.cmd),
LiveCommand::WatchedChange(message) => unknown(message.cmd),
LiveCommand::StopLiveRoomList(message) => unknown(message.cmd),
LiveCommand::GiftStarProcess(message) => unknown(message.cmd),
LiveCommand::OnlineRankV3(message) => unknown(message.cmd),
LiveCommand::InteractWordV2(message) => unknown(message.cmd),
LiveCommand::RoomChange(message) => unknown(message.cmd),
LiveCommand::Live(message) => unknown(message.cmd),
LiveCommand::Preparing(message) => unknown(message.cmd),
}
}
fn normalize_danmaku(message: &DanmuMessage) -> Option<ProviderEvent> {
let text = message.text.clone()?;
let uid = message.sender.uid?;
Some(ProviderEvent::Danmaku {
viewer: PlatformViewer {
uid: uid.to_string(),
name: message
.sender
.uname
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
},
emoticons: parse_danmaku_emoticons(message, &text),
text,
})
}
fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
let uid = message.data.uid?;
let quantity = bounded_i32(message.data.num.unwrap_or(1)).max(1);
let unit_price = message.data.price.or_else(|| {
message
.data
.total_coin
.map(|total| total / u64::try_from(quantity).unwrap_or(1))
});
let event_id = message
.message_id
.clone()
.or_else(|| {
message
.data
.extra
.get("tid")
.and_then(value_lossless_string)
})
.unwrap_or_else(|| {
let stable_time = message
.data
.extra
.get("timestamp")
.and_then(value_lossless_string)
.or_else(|| message.sent_at.map(|value| value.to_string()))
.unwrap_or_else(|| "unknown-time".into());
format!(
"gift-{uid}-{}-{stable_time}",
message.data.gift_id.unwrap_or_default()
)
});
Some(ProviderEvent::Gift {
viewer: PlatformViewer {
uid: uid.to_string(),
name: message
.data
.uname
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
},
name: message
.data
.gift_name
.clone()
.unwrap_or_else(|| "礼物".into()),
gift_id: message.data.gift_id.and_then(bounded_i64),
coin_type: message.data.coin_type.clone(),
battery: bounded_i32(unit_price.unwrap_or_default()),
quantity,
event_id,
})
}
fn normalize_combo(message: &ComboSendMessage) -> Option<ProviderEvent> {
let uid = message.data.uid?;
let quantity = message
.data
.combo_num
.or(message.data.batch_combo_num)
.or(message.data.num)
.map(bounded_i32)
.unwrap_or(1)
.max(1);
let unit_price = message.data.price.or_else(|| {
message
.data
.total_coin
.map(|total| total / message.data.num.unwrap_or(1).max(1))
});
let gift_id = message.data.gift_id.and_then(bounded_i64);
let combo_id = message
.data
.combo_id
.clone()
.or_else(|| message.data.batch_combo_id.clone())
.unwrap_or_else(|| format!("combo-{uid}-{}", message.data.gift_id.unwrap_or_default()));
Some(ProviderEvent::GiftCombo {
viewer: PlatformViewer {
uid: uid.to_string(),
name: message
.data
.uname
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
},
name: message
.data
.gift_name
.clone()
.unwrap_or_else(|| "礼物".into()),
gift_id,
coin_type: message.data.coin_type.clone(),
battery: bounded_i32(unit_price.unwrap_or_default()),
quantity,
combo_id,
})
}
fn normalize_like(message: &LikeClickMessage) -> Option<ProviderEvent> {
if message.data.is_like == Some(false) {
return None;
}
let uid = message.data.uid?;
Some(ProviderEvent::Like {
viewer: PlatformViewer {
uid: uid.to_string(),
name: message
.data
.uname
.clone()
.unwrap_or_else(|| format!("UID {uid}")),
},
})
}
fn normalize_superchat(message: &SuperChatMessage) -> Option<ProviderEvent> {
let uid = message.data.uid;
let name = message
.data
.extra
.get("user_info")
.and_then(|value| value.get("uname"))
.and_then(Value::as_str)
.or_else(|| message.data.extra.get("uname").and_then(Value::as_str))
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("UID {uid}"));
let event_id = message
.data
.extra
.get("id")
.and_then(value_lossless_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
Some(ProviderEvent::SuperChat {
viewer: PlatformViewer {
uid: uid.to_string(),
name,
},
message: message.data.message.clone(),
price: bounded_i64(message.data.price).unwrap_or(i64::MAX),
event_id,
})
}
fn normalize_interaction(message: &InteractWordMessage) -> Option<ProviderEvent> {
let viewer = PlatformViewer {
uid: message.data.uid.to_string(),
name: message.data.uname.clone(),
};
match message.data.msg_type {
1 => Some(ProviderEvent::Enter { viewer }),
3 => Some(ProviderEvent::Share { viewer }),
_ => unknown(message.cmd.clone()),
}
}
fn unknown(command: String) -> Option<ProviderEvent> {
Some(ProviderEvent::Unknown {
command: sanitized_command(Some(&command)),
})
}
fn sanitized_command(command: Option<&str>) -> String {
command
.and_then(|value| value.split(':').next())
.filter(|value| !value.is_empty())
.unwrap_or("UNKNOWN")
.chars()
.take(80)
.collect()
}
fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
let command = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned(); let command = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned();
let data = raw.get("data").unwrap_or(&raw); let data = raw.get("data").unwrap_or(raw);
let viewer = |uid: &Value, name: &Value| { let viewer = |uid: &Value, name: &Value| {
Some(PlatformViewer { Some(PlatformViewer {
uid: uid uid: uid
@@ -458,15 +714,6 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
) )
}; };
match command.as_str() { match command.as_str() {
"DANMU_MSG" => {
let info = raw.get("info")?.as_array()?;
let text = info.get(1)?.as_str()?.to_owned();
Some(ProviderEvent::Danmaku {
viewer: viewer(info.get(2)?.get(0)?, info.get(2)?.get(1)?)?,
emoticons: parse_danmaku_emoticons(info, &text),
text,
})
}
"SEND_GIFT" => Some(ProviderEvent::Gift { "SEND_GIFT" => Some(ProviderEvent::Gift {
viewer: data_viewer(data)?, viewer: data_viewer(data)?,
name: data name: data
@@ -478,12 +725,23 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
.get("giftId") .get("giftId")
.or_else(|| data.get("gift_id")) .or_else(|| data.get("gift_id"))
.and_then(Value::as_i64), .and_then(Value::as_i64),
battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32, coin_type: data
quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32, .get("coin_type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
battery: data
.get("price")
.and_then(value_i64)
.map(bounded_signed_i32)
.unwrap_or(0),
quantity: data
.get("num")
.and_then(value_i64)
.map(bounded_signed_i32)
.unwrap_or(1),
event_id: data event_id: data
.get("tid") .get("tid")
.and_then(Value::as_str) .and_then(value_lossless_string)
.map(str::to_owned)
.unwrap_or_else(|| { .unwrap_or_else(|| {
format!( format!(
"{}-{}", "{}-{}",
@@ -492,32 +750,6 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
) )
}), }),
}), }),
"COMBO_SEND" => Some(ProviderEvent::GiftCombo {
viewer: data_viewer(data)?,
name: data
.get("gift_name")
.or_else(|| data.get("giftName"))
.and_then(Value::as_str)
.unwrap_or("礼物")
.to_owned(),
gift_id: data
.get("gift_id")
.or_else(|| data.get("giftId"))
.and_then(Value::as_i64),
battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32,
quantity: data
.get("combo_num")
.or_else(|| data.get("total_num"))
.and_then(Value::as_i64)
.unwrap_or(1) as i32,
combo_id: data
.get("combo_id")
.map(Value::to_string)
.unwrap_or_default(),
}),
"INTERACT_WORD" => Some(ProviderEvent::Enter {
viewer: data_viewer(data)?,
}),
"GUARD_BUY" => Some(ProviderEvent::Guard { "GUARD_BUY" => Some(ProviderEvent::Guard {
viewer: data_viewer(data)?, viewer: data_viewer(data)?,
name: data name: data
@@ -526,8 +758,12 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("舰长") .unwrap_or("舰长")
.to_owned(), .to_owned(),
quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32, quantity: data
price: data.get("price").and_then(Value::as_i64).unwrap_or(0), .get("num")
.and_then(value_i64)
.map(bounded_signed_i32)
.unwrap_or(1),
price: data.get("price").and_then(value_i64).unwrap_or(0),
}), }),
"SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => Some(ProviderEvent::SuperChat { "SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => Some(ProviderEvent::SuperChat {
viewer: data_viewer(data)?, viewer: data_viewer(data)?,
@@ -536,22 +772,50 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("") .unwrap_or("")
.to_owned(), .to_owned(),
price: data.get("price").and_then(Value::as_i64).unwrap_or(0), price: data.get("price").and_then(value_i64).unwrap_or(0),
event_id: data event_id: data
.get("id") .get("id")
.map(Value::to_string) .and_then(value_lossless_string)
.unwrap_or_else(|| Uuid::new_v4().to_string()), .unwrap_or_else(|| Uuid::new_v4().to_string()),
}), }),
"LIKE_INFO_V3_CLICK" => Some(ProviderEvent::Like {
viewer: data_viewer(data)?,
}),
"SHARE" => Some(ProviderEvent::Share { "SHARE" => Some(ProviderEvent::Share {
viewer: data_viewer(data)?, viewer: data_viewer(data)?,
}), }),
_ => Some(ProviderEvent::Unknown { command }), _ => None,
} }
} }
fn value_i64(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_u64().and_then(bounded_i64))
.or_else(|| value.as_str()?.parse().ok())
}
fn value_lossless_string(value: &Value) -> Option<String> {
value
.as_str()
.map(ToOwned::to_owned)
.or_else(|| value.as_i64().map(|number| number.to_string()))
.or_else(|| value.as_u64().map(|number| number.to_string()))
}
fn bounded_i32(value: u64) -> i32 {
i32::try_from(value).unwrap_or(i32::MAX)
}
fn bounded_signed_i32(value: i64) -> i32 {
i32::try_from(value).unwrap_or(if value.is_negative() {
i32::MIN
} else {
i32::MAX
})
}
fn bounded_i64(value: u64) -> Option<i64> {
i64::try_from(value).ok()
}
fn json_object(value: &Value) -> Option<Value> { fn json_object(value: &Value) -> Option<Value> {
match value { match value {
Value::Object(_) => Some(value.clone()), Value::Object(_) => Some(value.clone()),
@@ -605,16 +869,15 @@ fn emoticon_hint(value: &Value, text: &str, standalone: bool) -> Option<Emoticon
}) })
} }
fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> { fn parse_danmaku_emoticons(message: &DanmuMessage, text: &str) -> Vec<EmoticonHint> {
let Some(header) = info.first().and_then(Value::as_array) else {
return Vec::new();
};
let mut hints = Vec::new(); let mut hints = Vec::new();
if let Some(direct) = header if let Some(direct) = message
.get(13) .metadata
.extra
.get(&13)
.and_then(|value| emoticon_hint(value, text, true)) .and_then(|value| emoticon_hint(value, text, true))
.or_else(|| { .or_else(|| {
header.iter().find_map(|value| { message.metadata.extra.values().find_map(|value| {
let object = json_object(value)?; let object = json_object(value)?;
object.get("url")?; object.get("url")?;
emoticon_hint(&object, text, true) emoticon_hint(&object, text, true)
@@ -623,11 +886,32 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
{ {
hints.push(direct); hints.push(direct);
} }
let extra = header.iter().find_map(|value| {
let object = json_object(value)?; // libilibili preserves unmodeled metadata indices and extension JSON, so
json_object(object.get("extra")?) // evolving emoticon layouts remain recoverable without leaking the raw
}); // DANMU_MSG array into the application domain.
if let Some(extra) = extra { for value in message.metadata.extra.values() {
let Some(object) = json_object(value) else {
continue;
};
push_emoticon_extra(&object, text, &mut hints);
if let Some(extra) = object.get("extra").and_then(json_object) {
push_emoticon_extra(&extra, text, &mut hints);
}
}
if let Some(extra) = message
.extension
.as_ref()
.and_then(|extension| extension.extra_json.as_deref())
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.and_then(|value| json_object(&value))
{
push_emoticon_extra(&extra, text, &mut hints);
}
hints
}
fn push_emoticon_extra(extra: &Value, text: &str, hints: &mut Vec<EmoticonHint>) {
if let Some(emoticons) = extra.get("emots").and_then(json_object) if let Some(emoticons) = extra.get("emots").and_then(json_object)
&& let Some(emoticons) = emoticons.as_object() && let Some(emoticons) = emoticons.as_object()
{ {
@@ -656,8 +940,6 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1), standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
}); });
} }
}
hints
} }
#[derive(Clone)] #[derive(Clone)]
@@ -782,6 +1064,7 @@ async fn gift_details(
catalog: &GiftCatalog, catalog: &GiftCatalog,
name: String, name: String,
gift_id: Option<i64>, gift_id: Option<i64>,
coin_type: Option<String>,
battery: i32, battery: i32,
quantity: i32, quantity: i32,
) -> GiftDetails { ) -> GiftDetails {
@@ -797,9 +1080,8 @@ async fn gift_details(
.as_ref() .as_ref()
.map(|gift| gift.name.clone()) .map(|gift| gift.name.clone())
.unwrap_or(name), .unwrap_or(name),
coin_type: metadata coin_type: coin_type
.as_ref() .or_else(|| metadata.as_ref().map(|gift| gift.coin_type.clone()))
.map(|gift| gift.coin_type.clone())
.unwrap_or_else(|| "gold".into()), .unwrap_or_else(|| "gold".into()),
unit_price, unit_price,
total_price, total_price,
@@ -816,10 +1098,11 @@ async fn gift_details(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use libilibili::websocket::parse_command;
#[test] #[test]
fn normalizes_raw_danmaku_to_provider_event() { fn normalizes_libilibili_danmaku_to_provider_event() {
let message = normalize(BiliMessage::Raw( let message = normalize_command(parse_command(
json!({"cmd":"DANMU_MSG:4:0:2","info":[[],"晚上好",[12345,"观众"]]}), json!({"cmd":"DANMU_MSG:4:0:2","info":[[],"晚上好",[12345,"观众"]]}),
)) ))
.unwrap(); .unwrap();
@@ -835,7 +1118,7 @@ mod tests {
#[test] #[test]
fn unknown_events_do_not_forward_raw_payload() { fn unknown_events_do_not_forward_raw_payload() {
let event = normalize(BiliMessage::Raw(json!({ let event = normalize_command(parse_command(json!({
"cmd":"FUTURE_SECRET_EVENT", "cmd":"FUTURE_SECRET_EVENT",
"data":{"cookie":"must-not-cross-provider-boundary"} "data":{"cookie":"must-not-cross-provider-boundary"}
}))) })))
@@ -845,4 +1128,90 @@ mod tests {
_ => panic!("expected unknown event"), _ => panic!("expected unknown event"),
} }
} }
#[test]
fn preserves_typed_gift_identity_value_and_coin_type() {
let event = normalize_command(parse_command(json!({
"cmd":"SEND_GIFT",
"data":{
"uid":123,
"uname":"送礼观众",
"giftId":42,
"giftName":"小花花",
"num":3,
"price":100,
"total_coin":300,
"coin_type":"gold",
"tid":"gift-event-1"
}
})))
.unwrap();
match event {
ProviderEvent::Gift {
viewer,
gift_id,
coin_type,
battery,
quantity,
event_id,
..
} => {
assert_eq!(viewer.uid, "123");
assert_eq!(gift_id, Some(42));
assert_eq!(coin_type.as_deref(), Some("gold"));
assert_eq!(battery, 100);
assert_eq!(quantity, 3);
assert_eq!(event_id, "gift-event-1");
}
_ => panic!("expected gift"),
}
}
#[test]
fn normalizes_typed_combo_commands() {
let event = normalize_command(parse_command(json!({
"cmd":"COMBO_SEND",
"data":{
"uid":123,
"uname":"送礼观众",
"gift_id":42,
"gift_name":"小花花",
"combo_num":8,
"price":100,
"coin_type":"gold",
"combo_id":"combo-1"
}
})))
.unwrap();
match event {
ProviderEvent::GiftCombo {
quantity, combo_id, ..
} => {
assert_eq!(quantity, 8);
assert_eq!(combo_id, "combo-1");
}
_ => panic!("expected gift combo"),
}
}
#[test]
fn normalizes_typed_like_clicks_with_a_viewer() {
let event = normalize_command(parse_command(json!({
"cmd":"LIKE_INFO_V3_CLICK",
"data":{
"uid":456,
"uname":"点赞观众",
"is_like":true,
"like_count":12
}
})))
.unwrap();
match event {
ProviderEvent::Like { viewer } => {
assert_eq!(viewer.uid, "456");
assert_eq!(viewer.name, "点赞观众");
}
_ => panic!("expected like"),
}
}
} }
+5 -1
View File
@@ -1,6 +1,10 @@
services: services:
app: app:
build: . build:
context: .
additional_contexts:
# Cargo resolves apps/server-rust/../../../libilibili to this checkout.
libilibili: ../libilibili
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
user: '${APP_UID:-1000}:${APP_GID:-1000}' user: '${APP_UID:-1000}:${APP_GID:-1000}'
+3 -3
View File
@@ -115,7 +115,7 @@ guard = true
like = false like = false
share = false share = false
# 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。 # 默认记录本服务生命周期信息,并保持依赖库仅输出警告。
# 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。 # libilibili 的凭据 Debug 已脱敏,但仍不要记录 Cookie 或原始认证载荷。
[logging] [logging]
filter = "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn" filter = "lxc_stream_server=info,libilibili=warn,tokio_postgres=warn"
+3 -2
View File
@@ -30,8 +30,8 @@ flowchart LR
``` ```
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。 1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据获取 Cookie,并把原始消息转换成 2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据构造 `libilibili`
`LiveEvent`。 客户端;crate 负责 WBI、WebSocket 与压缩包解析,adapter 再把强类型命令转换成 `LiveEvent`。
3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。 3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。 4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。 5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
@@ -71,6 +71,7 @@ flowchart LR
- 容器使用 host network,但默认只监听 `127.0.0.1:9719`。 - 容器使用 host network,但默认只监听 `127.0.0.1:9719`。
- Nginx 负责公网 TLS、域名和 WebSocket upgrade。 - Nginx 负责公网 TLS、域名和 WebSocket upgrade。
- CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。 - CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。
- `libilibili` 是源码树中的相邻 crate,由 Compose named build context 注入 Rust 构建阶段。
## 代码导航 ## 代码导航
+1 -1
View File
@@ -42,7 +42,7 @@ token。以下规则是实现约束,而不是可选部署建议。
- Key 编码成单一路径段,不能注入额外路径。 - Key 编码成单一路径段,不能注入额外路径。
- HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。 - HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。
- 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。 - 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。
- 日志不得把 `blivedm` 调到可能打印认证响应的详细级别。 - `libilibili::Credentials` 的 `Debug` 已脱敏,但应用仍不得记录 Cookie、原始认证响应或弹幕 token。
## HTTP、WebSocket 与 OBS ## HTTP、WebSocket 与 OBS
Generated Vendored
-3934
View File
File diff suppressed because it is too large Load Diff
-172
View File
@@ -1,172 +0,0 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
name = "blivedm"
version = "0.5.6"
authors = ["isomo <jiahaoxing2000@gmail.com>"]
build = false
publish = true
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Bilibili live room danmaku WebSocket client with TTS and plugin support"
readme = "README.md"
keywords = [
"bilibili",
"danmaku",
"live",
"websocket",
"tts",
]
categories = [
"command-line-utilities",
"network-programming",
]
license = "MIT OR Apache-2.0"
repository = "https://github.com/isomoes/blivedm_rs"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = [
"--cfg",
"docsrs",
]
[features]
browser_cookies = [
"dep:sqlite",
"dep:directories",
"dep:chrono",
]
default = ["browser_cookies"]
[lib]
name = "blivedm"
path = "src/lib.rs"
[[bin]]
name = "blivedm"
path = "src/main.rs"
[[example]]
name = "integration_bili_live_client"
path = "examples/integration_bili_live_client.rs"
[[example]]
name = "simple_client"
path = "examples/simple_client.rs"
[[example]]
name = "tts_example"
path = "examples/tts_example.rs"
[dependencies.arboard]
version = "3.4"
features = ["wayland-data-control"]
[dependencies.base64]
version = "0.21"
[dependencies.brotlic]
version = "0.8.1"
[dependencies.chrono]
version = "0.4"
optional = true
[dependencies.clap]
version = "4.0"
features = ["derive"]
[dependencies.clap_complete]
version = "4.0"
[dependencies.crossterm]
version = "0.28"
[dependencies.directories]
version = "5.0"
optional = true
[dependencies.dirs]
version = "5.0"
[dependencies.env_logger]
version = "0.11.8"
[dependencies.futures]
version = "0.3"
[dependencies.futures-channel]
version = "0.3.28"
[dependencies.http]
version = "0.2.11"
[dependencies.log]
version = "0.4"
[dependencies.md5]
version = "0.7"
[dependencies.native-tls]
version = "0.2.0"
[dependencies.ratatui]
version = "0.29"
[dependencies.reqwest]
version = "0.11.17"
features = [
"blocking",
"cookies",
"rustls-tls",
"json",
"stream",
]
default-features = false
[dependencies.rodio]
version = "0.17"
[dependencies.serde]
version = "1.0"
features = ["derive"]
[dependencies.serde_json]
version = "1.0"
[dependencies.sqlite]
version = "0.36"
optional = true
[dependencies.tokio]
version = "1"
features = [
"rt-multi-thread",
"macros",
]
[dependencies.toml]
version = "0.8"
[dependencies.tungstenite]
version = "0.20.1"
[dependencies.unicode-width]
version = "0.2.0"
[dependencies.url]
version = "2.3.1"
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 isomo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-537
View File
@@ -1,537 +0,0 @@
// src/client/auth.rs
//! Authentication helpers for Bilibili live danmaku WebSocket client
use md5;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use serde::Deserialize;
use std::time::{SystemTime, UNIX_EPOCH};
// Add browser cookie support
#[cfg(feature = "browser_cookies")]
use crate::browser_cookies;
/// Get Bilibili cookies from browser (preferred, newest), then fallback to provided cookie string
pub fn get_cookies_or_browser(provided_cookie: Option<&str>) -> Option<String> {
#[cfg(feature = "browser_cookies")]
{
// First try browser cookies as they are the newest
log::info!("Searching for Bilibili cookies in browser (newest)...");
if let Some(browser_cookie) = browser_cookies::find_bilibili_cookies_as_string() {
log::info!("Found Bilibili cookies in browser (using newest)");
return Some(browser_cookie);
}
log::info!("No Bilibili cookies found in browser, checking provided cookie...");
}
#[cfg(not(feature = "browser_cookies"))]
{
log::debug!("Browser cookie feature not enabled. Skip to find cookies in browser...");
}
if let Some(cookie) = provided_cookie {
if !cookie.is_empty() && cookie != "dummy_sessdata" && cookie.len() > 20 {
log::info!("Using provided cookie as fallback");
return Some(cookie.to_string());
}
}
log::warn!("No valid Bilibili cookies found in browser or provided input");
None
}
pub fn init_uid(headers: HeaderMap) -> (StatusCode, String) {
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers;
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
let response = client.get(UID_INIT_URL).headers(request_headers).send();
log::debug!("init uid response: {:?}", response);
let stat: StatusCode;
let body: String;
match response {
Ok(resp) => {
stat = resp.status();
body = resp.text().unwrap();
log::info!("init uid response: {:?}", body);
}
Err(_) => {
panic!("init uid failed");
}
}
(stat, body)
}
/// Initializes the buvid by sending a request and extracting the 'buvid3' cookie.
///
/// Note: This function is not used for document creation.
///
/// # Panics
///
/// Panics if the request fails.
pub fn init_buvid(headers: HeaderMap) -> (StatusCode, String) {
// Not used for document creation.
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers;
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
let response = client.get(BUVID_INIT_URL).headers(request_headers).send();
let stat: StatusCode;
let mut buvid: String = "".to_string();
match response {
Ok(resp) => {
stat = resp.status();
let cookies = resp.cookies();
for i in cookies {
log::debug!("init buvid response cookie : {:?}", i);
if "buvid3".eq(i.name()) {
buvid = i.value().to_string();
log::info!("init buvid response: {:?}", buvid);
}
}
}
Err(_) => {
panic!("init buvid failed");
}
}
(stat, buvid)
}
/// Initializes the room by sending a request with the given room ID.
///
/// Note: This function should NOT be used for document creation.
///
/// # Panics
///
/// Panics if the request fails.
pub fn init_room(headers: HeaderMap, temp_room_id: &str) -> (StatusCode, String) {
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers;
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
let url = format!("{}?room_id={}", ROOM_INIT_URL, temp_room_id);
let response = client.get(url).headers(request_headers).send();
let stat: StatusCode;
let body: String;
match response {
Ok(resp) => {
stat = resp.status();
body = resp.text().unwrap();
log::info!("init room response: {:?}", body);
}
Err(_) => {
panic!("init buvid failed");
}
}
(stat, body)
}
pub fn init_host_server(headers: HeaderMap, room_id: u64) -> (StatusCode, String) {
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers.clone();
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
// Get WBI keys for signing
let wbi_keys = match get_wbi_keys(request_headers.clone()) {
Ok(keys) => keys,
Err(e) => {
log::error!("Failed to get WBI keys: {:?}", e);
panic!("Failed to get WBI keys");
}
};
// Prepare parameters for signing
let params = vec![
("id", room_id.to_string()),
("type", "0".to_string()),
("web_location", "444.8".to_string()),
];
// Generate signed query string
let signed_query = encode_wbi(params, wbi_keys);
// Construct final URL
let url = format!("{}?{}", DANMAKU_SERVER_CONF_URL, signed_query);
// debug log the total request
let response = client.get(url).headers(request_headers).send();
log::debug!("init host server response: {:?}", response);
let stat: StatusCode;
let body: String;
match response {
Ok(resp) => {
stat = resp.status();
body = resp.text().unwrap();
log::info!("init host server response body: {:?}", body);
}
Err(_) => {
panic!("init host server failed");
}
}
(stat, body)
}
// WBI signing constants and functions
const MIXIN_KEY_ENC_TAB: [usize; 64] = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29,
28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25,
54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
];
#[derive(Deserialize)]
struct WbiImg {
img_url: String,
sub_url: String,
}
#[derive(Deserialize)]
struct Data {
wbi_img: WbiImg,
}
#[derive(Deserialize)]
struct ResWbi {
data: Data,
}
// 对 imgKey 和 subKey 进行字符顺序打乱编码
fn get_mixin_key(orig: &[u8]) -> String {
MIXIN_KEY_ENC_TAB
.iter()
.take(32)
.map(|&i| orig[i] as char)
.collect::<String>()
}
fn get_url_encoded(s: &str) -> String {
s.chars()
.filter_map(|c| match c.is_ascii_alphanumeric() || "-_.~".contains(c) {
true => Some(c.to_string()),
false => {
// 过滤 value 中的 "!'()*" 字符
if "!'()*".contains(c) {
return None;
}
let encoded = c
.encode_utf8(&mut [0; 4])
.bytes()
.fold("".to_string(), |acc, b| acc + &format!("%{:02X}", b));
Some(encoded)
}
})
.collect::<String>()
}
// 为请求参数进行 wbi 签名
fn encode_wbi(params: Vec<(&str, String)>, (img_key, sub_key): (String, String)) -> String {
let cur_time = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(t) => t.as_secs(),
Err(_) => panic!("SystemTime before UNIX EPOCH!"),
};
_encode_wbi(params, (img_key, sub_key), cur_time)
}
fn _encode_wbi(
mut params: Vec<(&str, String)>,
(img_key, sub_key): (String, String),
timestamp: u64,
) -> String {
let mixin_key = get_mixin_key((img_key + &sub_key).as_bytes());
// 添加当前时间戳
params.push(("wts", timestamp.to_string()));
// 重新排序
params.sort_by(|a, b| a.0.cmp(b.0));
// 拼接参数
let query = params
.iter()
.map(|(k, v)| format!("{}={}", get_url_encoded(k), get_url_encoded(v)))
.collect::<Vec<_>>()
.join("&");
// 计算签名
let web_sign = format!("{:x}", md5::compute(query.clone() + &mixin_key));
// 返回最终的 query
query + &format!("&w_rid={}", web_sign)
}
fn get_wbi_keys(headers: HeaderMap) -> Result<(String, String), reqwest::Error> {
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers;
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
let response = client
.get("https://api.bilibili.com/x/web-interface/nav")
.headers(request_headers)
.send()?;
let res_wbi: ResWbi = response.json()?;
Ok((
take_filename(res_wbi.data.wbi_img.img_url).unwrap(),
take_filename(res_wbi.data.wbi_img.sub_url).unwrap(),
))
}
fn take_filename(url: String) -> Option<String> {
url.rsplit_once('/')
.and_then(|(_, s)| s.rsplit_once('.'))
.map(|(s, _)| s.to_string())
}
pub const UID_INIT_URL: &str = "https://api.bilibili.com/x/web-interface/nav";
pub const BUVID_INIT_URL: &str = "https://data.bilibili.com/v/";
pub const ROOM_INIT_URL: &str =
"https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom";
pub const DANMAKU_SERVER_CONF_URL: &str =
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo";
pub const USER_AGENT: &str =
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uid_url_constant() {
assert!(UID_INIT_URL.contains("bilibili.com"));
}
#[test]
fn test_take_filename() {
assert_eq!(
take_filename(
"https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string()
),
Some("7cd084941338484aae1ad9425b84077c".to_string())
);
assert_eq!(
take_filename(
"https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string()
),
Some("4932caff0ff746eab6f01bf08b70ac45".to_string())
);
// Test edge case with no extension
assert_eq!(
take_filename("https://example.com/path/file".to_string()),
None
);
}
#[test]
fn test_encode_wbi_with_known_values() {
let params = vec![
("foo", String::from("114")),
("bar", String::from("514")),
("zab", String::from("1919810")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1702204169,
);
assert_eq!(
result,
"bar=514&foo=114&wts=1702204169&zab=1919810&w_rid=8f6f2b5b3d485fe1886cec6a0be8c5d4"
);
}
#[test]
fn test_encode_wbi_bilibili_danmu_params() {
// Test with the actual Bilibili danmu parameters from the example
let params = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
// Using the timestamp from the example URL (1748308267)
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1748308267,
);
// The result should contain the correct parameters and w_rid
assert!(result.contains("id=24779526"));
assert!(result.contains("type=0"));
assert!(result.contains("web_location=444.8"));
assert!(result.contains("wts=1748308267"));
assert!(result.contains("w_rid="));
// Check the parameter order (should be alphabetical)
let expected_order = "id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=";
assert!(result.starts_with(expected_order));
}
#[test]
fn test_wbi_signature_consistency() {
// Test that the same parameters always generate the same signature
let params1 = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let params2 = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let keys = (
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
);
let timestamp = 1748308267;
let result1 = _encode_wbi(params1, keys.clone(), timestamp);
let result2 = _encode_wbi(params2, keys, timestamp);
assert_eq!(result1, result2);
}
#[test]
fn test_wbi_parameter_sorting() {
// Test that parameters are properly sorted alphabetically
let params = vec![
("z_param", String::from("last")),
("a_param", String::from("first")),
("m_param", String::from("middle")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1748308267,
);
// Check that parameters appear in alphabetical order
let parts: Vec<&str> = result.split('&').collect();
assert!(parts[0].starts_with("a_param="));
assert!(parts[1].starts_with("m_param="));
assert!(parts[2].starts_with("wts="));
assert!(parts[3].starts_with("z_param="));
assert!(parts[4].starts_with("w_rid="));
}
#[test]
fn test_correct_bilibili_url_signature() {
// Test the exact URL from the working example:
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=884cf361b8ad4e239b4a9dbbb7134679"
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=d1e619744b4977f88ed67524a1f567cc&wts=1751072897"
let params = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1751072897,
);
// Expected complete query string from working URL
let expected = "id=24779526&type=0&web_location=444.8&wts=1751072897&w_rid=d1e619744b4977f88ed67524a1f567cc";
assert_eq!(result, expected);
// Extract and verify the w_rid specifically
let w_rid = result.split("w_rid=").nth(1).unwrap();
assert_eq!(w_rid, "d1e619744b4977f88ed67524a1f567cc");
}
#[test]
fn test_second_bilibili_url_signature() {
// Test the second URL example:
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=fa20533eb27334ba6f2ec7263721319a&wts=1748311635"
let params = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1748311635,
);
// Expected complete query string from working URL
let expected = "id=24779526&type=0&web_location=444.8&wts=1748311635&w_rid=fa20533eb27334ba6f2ec7263721319a";
assert_eq!(result, expected);
// Extract and verify the w_rid specifically
let w_rid = result.split("w_rid=").nth(1).unwrap();
assert_eq!(w_rid, "fa20533eb27334ba6f2ec7263721319a");
}
#[test]
fn test_third_bilibili_url_signature() {
// Test the third URL example from README:
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507"
let params = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1748312554,
);
// Expected complete query string from working URL
let expected = "id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507";
assert_eq!(result, expected);
// Extract and verify the w_rid specifically
let w_rid = result.split("w_rid=").nth(1).unwrap();
assert_eq!(w_rid, "30f250e8abd9effea1bcb88aab416507");
}
}
-416
View File
@@ -1,416 +0,0 @@
// src/client/browser_cookies.rs
//! Browser cookie reading functionality for automatic SESSDATA detection
use chrono::{DateTime, TimeZone, Utc};
use directories::UserDirs;
use log::{debug, info, warn};
use sqlite::Connection;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub expires: Option<DateTime<Utc>>,
pub secure: bool,
pub http_only: bool,
}
#[derive(Debug)]
pub enum Browser {
Chrome,
Firefox,
Edge,
Chromium,
Opera,
}
impl Browser {
pub fn get_cookie_db_path(&self) -> Option<PathBuf> {
let user_dirs = UserDirs::new()?;
let home_dir = user_dirs.home_dir();
match self {
Browser::Chrome => {
#[cfg(target_os = "linux")]
{
Some(home_dir.join(".config/google-chrome/Default/Cookies"))
}
#[cfg(target_os = "macos")]
{
Some(home_dir.join("Library/Application Support/Google/Chrome/Default/Cookies"))
}
#[cfg(target_os = "windows")]
{
Some(
home_dir
.join("AppData/Local/Google/Chrome/User Data/Default/Network/Cookies"),
)
}
}
Browser::Firefox => {
#[cfg(target_os = "linux")]
{
let firefox_dir = vec![
home_dir.join(".mozilla/firefox"),
home_dir.join("snap/firefox/common/.mozilla/firefox"),
home_dir.join(".var/app/org.mozilla.firefox/.mozilla/firefox"),
home_dir.join("snap/firefox/current/.mozilla/firefox"),
]
.into_iter()
.find(|p| p.exists())?;
Self::find_firefox_profile_cookies(&firefox_dir)
}
#[cfg(target_os = "macos")]
{
let firefox_dir = home_dir.join("Library/Application Support/Firefox/Profiles");
Self::find_firefox_profile_cookies(&firefox_dir)
}
#[cfg(target_os = "windows")]
{
let firefox_dir = home_dir.join("AppData/Roaming/Mozilla/Firefox/Profiles");
Self::find_firefox_profile_cookies(&firefox_dir)
}
}
Browser::Edge => {
#[cfg(target_os = "linux")]
{
Some(home_dir.join(".config/microsoft-edge/Default/Cookies"))
}
#[cfg(target_os = "macos")]
{
Some(
home_dir.join("Library/Application Support/Microsoft Edge/Default/Cookies"),
)
}
#[cfg(target_os = "windows")]
{
Some(
home_dir
.join("AppData/Local/Microsoft/Edge/User Data/Default/Network/Cookies"),
)
}
}
Browser::Chromium => {
#[cfg(target_os = "linux")]
{
Some(home_dir.join(".config/chromium/Default/Cookies"))
}
#[cfg(target_os = "macos")]
{
Some(home_dir.join("Library/Application Support/Chromium/Default/Cookies"))
}
#[cfg(target_os = "windows")]
{
Some(home_dir.join("AppData/Local/Chromium/User Data/Default/Network/Cookies"))
}
}
Browser::Opera => {
#[cfg(target_os = "linux")]
{
Some(home_dir.join(".config/opera/Default/Cookies"))
}
#[cfg(target_os = "macos")]
{
Some(home_dir.join(
"Library/Application Support/com.operasoftware.Opera/Default/Cookies",
))
}
#[cfg(target_os = "windows")]
{
Some(
home_dir
.join("AppData/Roaming/Opera Software/Opera Stable/Network/Cookies"),
)
}
}
}
}
fn find_firefox_profile_cookies(firefox_dir: &Path) -> Option<PathBuf> {
if !firefox_dir.exists() {
return None;
}
// Look for the default profile directory
let entries = fs::read_dir(firefox_dir).ok()?;
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name()?.to_str()?;
if dir_name.contains(".default") || dir_name.contains(".default-release") {
let cookies_path = path.join("cookies.sqlite");
if cookies_path.exists() {
return Some(cookies_path);
}
}
}
}
}
None
}
pub fn get_all_supported() -> Vec<Browser> {
vec![
Browser::Chrome,
Browser::Firefox,
Browser::Edge,
Browser::Chromium,
Browser::Opera,
]
}
}
/// Read cookies from a browser's cookie database
pub fn read_cookies_from_browser(
browser: &Browser,
domain_filter: Option<&str>,
) -> Result<Vec<Cookie>, String> {
let db_path = browser
.get_cookie_db_path()
.ok_or_else(|| "Could not determine cookie database path".to_string())?;
if !db_path.exists() {
return Err(format!("Cookie database not found at: {:?}", db_path));
}
debug!("Reading cookies from: {:?}", db_path);
// Create a temporary copy of the database since browsers might have it locked
let temp_path = std::env::temp_dir().join(format!("temp_cookies_{}.db", std::process::id()));
if let Err(e) = fs::copy(&db_path, &temp_path) {
return Err(format!("Failed to copy cookie database: {}", e));
}
let result = match browser {
Browser::Firefox => read_firefox_cookies(&temp_path, domain_filter),
_ => read_chromium_cookies(&temp_path, domain_filter),
};
// Clean up temporary file
let _ = fs::remove_file(&temp_path);
result
}
fn read_chromium_cookies(
db_path: &Path,
domain_filter: Option<&str>,
) -> Result<Vec<Cookie>, String> {
let connection =
Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
let mut query =
"SELECT name, value, host_key, path, expires_utc, is_secure, is_httponly FROM cookies"
.to_string();
if let Some(domain) = domain_filter {
query.push_str(&format!(" WHERE host_key LIKE '%{}'", domain));
}
let mut cookies = Vec::new();
connection
.iterate(query, |pairs| {
let mut cookie_data = HashMap::new();
for &(column, value) in pairs.iter() {
cookie_data.insert(column, value.unwrap_or(""));
}
let expires = if let Some(expires_str) = cookie_data.get("expires_utc") {
if let Ok(expires_microseconds) = expires_str.parse::<i64>() {
// Chrome stores time as microseconds since Windows epoch (1601-01-01)
// Convert to Unix timestamp (seconds since 1970-01-01)
let windows_epoch_offset = 11644473600_i64; // seconds between 1601 and 1970
let unix_timestamp = (expires_microseconds / 1_000_000) - windows_epoch_offset;
Utc.timestamp_opt(unix_timestamp, 0).single()
} else {
None
}
} else {
None
};
let cookie = Cookie {
name: cookie_data.get("name").unwrap_or(&"").to_string(),
value: cookie_data.get("value").unwrap_or(&"").to_string(),
domain: cookie_data.get("host_key").unwrap_or(&"").to_string(),
path: cookie_data.get("path").unwrap_or(&"").to_string(),
expires,
secure: cookie_data.get("is_secure").unwrap_or(&"0") == &"1",
http_only: cookie_data.get("is_httponly").unwrap_or(&"0") == &"1",
};
cookies.push(cookie);
true
})
.map_err(|e| format!("Failed to query cookies: {}", e))?;
Ok(cookies)
}
fn read_firefox_cookies(
db_path: &Path,
domain_filter: Option<&str>,
) -> Result<Vec<Cookie>, String> {
let connection =
Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
let mut query =
"SELECT name, value, host, path, expiry, isSecure, isHttpOnly FROM moz_cookies".to_string();
if let Some(domain) = domain_filter {
query.push_str(&format!(" WHERE host LIKE '%{}'", domain));
}
let mut cookies = Vec::new();
connection
.iterate(query, |pairs| {
let mut cookie_data = HashMap::new();
for &(column, value) in pairs.iter() {
cookie_data.insert(column, value.unwrap_or(""));
}
let expires = if let Some(expires_str) = cookie_data.get("expiry") {
if let Ok(expires_timestamp) = expires_str.parse::<i64>() {
Utc.timestamp_opt(expires_timestamp, 0).single()
} else {
None
}
} else {
None
};
let cookie = Cookie {
name: cookie_data.get("name").unwrap_or(&"").to_string(),
value: cookie_data.get("value").unwrap_or(&"").to_string(),
domain: cookie_data.get("host").unwrap_or(&"").to_string(),
path: cookie_data.get("path").unwrap_or(&"").to_string(),
expires,
secure: cookie_data.get("isSecure").unwrap_or(&"0") == &"1",
http_only: cookie_data.get("isHttpOnly").unwrap_or(&"0") == &"1",
};
cookies.push(cookie);
true
})
.map_err(|e| format!("Failed to query cookies: {}", e))?;
Ok(cookies)
}
/// Find SESSDATA cookie from all supported browsers
pub fn find_bilibili_cookies_as_string() -> Option<String> {
let browsers = Browser::get_all_supported();
let mut all_cookies = vec![];
for browser in browsers {
info!("Checking browser: {:?}", browser);
if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
all_cookies.extend(cookies);
}
}
let mut valid_cookies = all_cookies
.into_iter()
.filter(|cookie| {
if let Some(expires) = cookie.expires {
if Utc::now() > expires {
warn!(
"Found expired {} cookie, expires: {:?}",
cookie.name, expires
);
return false;
}
}
true
})
.collect::<Vec<_>>();
// Deduplicate cookies, keeping the one with the latest expiry
valid_cookies.sort_by(|a, b| {
if a.name != b.name {
a.name.cmp(&b.name)
} else {
b.expires.cmp(&a.expires) // None is smaller
}
});
valid_cookies.dedup_by(|a, b| a.name == b.name);
if valid_cookies.is_empty() {
warn!("No valid bilibili cookies found in any browser");
return None;
}
info!("Found {} valid bilibili cookies", valid_cookies.len());
let cookie_string = valid_cookies
.iter()
.map(|c| format!("{}={}", c.name, c.value))
.collect::<Vec<String>>()
.join("; ");
if cookie_string.contains("SESSDATA") {
Some(cookie_string)
} else {
warn!("No SESSDATA cookie found among the valid cookies");
None
}
}
/// Get all bilibili cookies from browsers for debugging
pub fn get_all_bilibili_cookies() -> HashMap<String, String> {
let mut all_cookies = HashMap::new();
let browsers = Browser::get_all_supported();
for browser in browsers {
if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
for cookie in cookies {
// Only include non-expired cookies
if let Some(expires) = cookie.expires {
if Utc::now() > expires {
continue;
}
}
// Use the most recent cookie if duplicates exist
let key = format!("{}_{}", cookie.name, cookie.domain);
all_cookies.insert(key, cookie.value);
}
}
}
all_cookies
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_browser_path_detection() {
for browser in Browser::get_all_supported() {
let path = browser.get_cookie_db_path();
println!("{:?} cookie path: {:?}", browser, path);
}
}
#[test]
fn test_find_sessdata() {
// This test will only work if you have bilibili cookies in your browser
if let Some(sessdata) = find_bilibili_cookies_as_string() {
println!("Found SESSDATA: {}", &sessdata[..20.min(sessdata.len())]);
assert!(!sessdata.is_empty());
} else {
println!("No SESSDATA found - this is normal if you're not logged into bilibili");
}
}
}
-12
View File
@@ -1,12 +0,0 @@
// src/client/lib.rs
//! Library entry for the client package
pub mod auth;
#[cfg(feature = "browser_cookies")]
pub mod browser_cookies;
pub mod models;
pub mod scheduler;
pub mod websocket;
// Re-export commonly used functions
pub use auth::get_cookies_or_browser;
-97
View File
@@ -1,97 +0,0 @@
// src/client/models.rs
//! Data models for Bilibili live danmaku WebSocket client
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug)]
pub struct DanmuServer {
pub host: String,
pub port: i32,
pub wss_port: i32,
pub ws_port: i32,
}
impl Default for DanmuServer {
fn default() -> Self {
Self {
host: String::from("broadcastlv.chat.bilibili.com"),
port: 2243,
wss_port: 443,
ws_port: 2244,
}
}
}
#[derive(Copy, Clone, Debug)]
pub struct MsgHead {
pub pack_len: u32,
pub raw_header_size: u16,
pub ver: u16,
pub operation: u32,
pub seq_id: u32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthMessage {
pub uid: u64,
pub roomid: u64,
pub protover: i32,
pub platform: String,
pub type_: i32,
pub key: String,
}
impl AuthMessage {
pub fn from(map: &HashMap<String, String>) -> AuthMessage {
AuthMessage {
uid: map.get("uid").unwrap().parse::<u64>().unwrap(),
roomid: map.get("room_id").unwrap().parse::<u64>().unwrap(),
protover: 3,
platform: "web".to_string(),
type_: 2,
key: map.get("token").unwrap().to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum BiliMessage {
Danmu {
user: String,
text: String,
},
Gift {
user: String,
gift: String,
num: String,
},
/// Online rank count message (ONLINE_RANK_COUNT)
OnlineRankCount {
/// Number of high-energy users in the live room
count: u64,
/// Number of online users in the live room
online_count: u64,
},
// Add more variants as needed
Raw(serde_json::Value),
#[deprecated(note = "Use Raw variant instead")]
Unsupported,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_message_from_map() {
let mut map = std::collections::HashMap::new();
map.insert("uid".to_string(), "12345".to_string());
map.insert("room_id".to_string(), "67890".to_string());
map.insert("token".to_string(), "test_token".to_string());
let auth = AuthMessage::from(&map);
assert_eq!(auth.uid, 12345);
assert_eq!(auth.roomid, 67890);
assert_eq!(auth.key, "test_token");
}
}
-192
View File
@@ -1,192 +0,0 @@
// In Cargo.toml, ensure you have: client = { path = "../client" }
use models::BiliMessage;
use std::sync::Arc;
use crate::models;
/// Context information passed to event handlers
#[derive(Debug, Clone)]
pub struct EventContext {
/// Bilibili cookies for authentication
pub cookies: Option<String>,
/// Room ID where the event occurred
pub room_id: u64,
}
impl EventContext {
/// Create a new EventContext with automatic cookie detection
pub fn new_with_auto_cookies(room_id: u64) -> Self {
let cookies = crate::auth::get_cookies_or_browser(None);
Self { cookies, room_id }
}
/// Create a new EventContext with provided cookies
pub fn new(cookies: Option<String>, room_id: u64) -> Self {
Self { cookies, room_id }
}
}
/// Trait for event handlers (plugins) that process BiliMessage.
pub trait EventHandler: Send + Sync {
fn handle(&self, msg: &BiliMessage, context: &EventContext);
}
/// Scheduling mode: Parallel or Sequential.
pub enum ScheduleMode {
Parallel,
Sequential,
}
/// Scheduler struct: manages event handlers and dispatches messages.
pub struct Scheduler {
/// Each stage is a Vec of handlers to run in parallel; stages run sequentially.
stages: Vec<Vec<Arc<dyn EventHandler>>>,
/// Context information for event handlers
context: EventContext,
}
impl Scheduler {
pub fn new(context: EventContext) -> Self {
Scheduler {
stages: Vec::new(),
context,
}
}
/// Add a new stage (group of handlers to run in parallel)
pub fn add_stage(&mut self, handlers: Vec<Arc<dyn EventHandler>>) {
self.stages.push(handlers);
}
/// Add a single handler as a new sequential stage
pub fn add_sequential_handler(&mut self, handler: Arc<dyn EventHandler>) {
self.stages.push(vec![handler]);
}
/// Trigger all stages with the given BiliMessage.
pub fn trigger(&self, msg: BiliMessage) {
for stage in &self.stages {
let mut handles = vec![];
for handler in stage {
let msg = msg.clone();
let context = self.context.clone();
let handler = Arc::clone(handler);
handles.push(std::thread::spawn(move || {
handler.handle(&msg, &context);
}));
}
// Wait for all handlers in this stage to finish before next stage
for handle in handles {
let _ = handle.join();
}
}
}
}
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use crate::models::BiliMessage;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, mpsc};
struct AssertHandler {
called: Arc<AtomicBool>,
last_msg: Arc<Mutex<Option<BiliMessage>>>,
}
impl super::EventHandler for AssertHandler {
fn handle(&self, msg: &BiliMessage, _context: &super::EventContext) {
self.called.store(true, Ordering::SeqCst);
let mut lock = self.last_msg.lock().unwrap();
*lock = Some(msg.clone());
}
}
#[test]
fn test_scheduler_with_mpsc_channel() {
let (tx, rx) = mpsc::channel();
let called = Arc::new(AtomicBool::new(false));
let last_msg = Arc::new(Mutex::new(None));
let handler = AssertHandler {
called: Arc::clone(&called),
last_msg: Arc::clone(&last_msg),
};
let context = super::EventContext {
cookies: Some("test_cookies".to_string()),
room_id: 12345,
};
let mut scheduler = super::Scheduler::new(context);
scheduler.add_sequential_handler(Arc::new(handler));
// Send a test message
let test_msg = BiliMessage::Danmu {
user: "user1".to_string(),
text: "hello".to_string(),
};
tx.send(test_msg.clone()).unwrap();
// Simulate receiving and triggering
if let Ok(msg) = rx.recv() {
scheduler.trigger(msg);
}
// Assert handler was called and message matches
assert!(called.load(Ordering::SeqCst), "Handler was not called");
let lock = last_msg.lock().unwrap();
assert!(lock.is_some(), "No message stored in handler");
assert_eq!(lock.as_ref().unwrap(), &test_msg, "Message does not match");
}
#[test]
fn test_scheduler_add_stage_and_sequential_handler() {
use crate::models::BiliMessage;
struct CounterHandler {
counter: Arc<AtomicUsize>,
}
impl super::EventHandler for CounterHandler {
fn handle(&self, _msg: &BiliMessage, _context: &super::EventContext) {
self.counter.fetch_add(1, Ordering::SeqCst);
}
}
let counter1 = Arc::new(AtomicUsize::new(0));
let counter2 = Arc::new(AtomicUsize::new(0));
let counter3 = Arc::new(AtomicUsize::new(0));
let handler1 = Arc::new(CounterHandler {
counter: Arc::clone(&counter1),
});
let handler2 = Arc::new(CounterHandler {
counter: Arc::clone(&counter2),
});
let handler3 = Arc::new(CounterHandler {
counter: Arc::clone(&counter3),
});
let context = super::EventContext {
cookies: Some("test_cookies".to_string()),
room_id: 12345,
};
let mut scheduler = super::Scheduler::new(context);
// Add a parallel stage (handler1 and handler2)
scheduler.add_stage(vec![handler1, handler2]);
// Add a sequential stage (handler3)
scheduler.add_sequential_handler(handler3);
let test_msg = BiliMessage::Danmu {
user: "user2".to_string(),
text: "test".to_string(),
};
scheduler.trigger(test_msg);
// Both handler1 and handler2 should be called once (parallel stage)
assert_eq!(counter1.load(Ordering::SeqCst), 1, "Handler1 not called");
assert_eq!(counter2.load(Ordering::SeqCst), 1, "Handler2 not called");
// handler3 should be called once (sequential stage)
assert_eq!(counter3.load(Ordering::SeqCst), 1, "Handler3 not called");
}
}
-544
View File
@@ -1,544 +0,0 @@
// src/client/websocket.rs
//! WebSocket client for Bilibili live danmaku messages (refactored from bili_live_dm)
use native_tls::TlsStream;
use serde_json::Value;
use std::io::ErrorKind;
use std::net::TcpStream;
use std::panic;
use tungstenite::{client, Message, WebSocket};
use url::Url;
use futures_channel::mpsc::Sender;
use http::Response;
use std::collections::HashMap;
use std::thread;
use std::time::Duration;
use crate::auth::*;
use crate::models::{AuthMessage, BiliMessage, DanmuServer, MsgHead};
pub struct BiliLiveClient {
ws: WebSocket<TlsStream<TcpStream>>,
cookies: String,
room_id: String,
auth_msg: String,
ss: Sender<BiliMessage>,
}
impl BiliLiveClient {
pub fn new(cookies: &str, room_id: &str, r: Sender<BiliMessage>) -> Self {
let (ws, auth_msg) = Self::connect_with_auth(cookies, room_id)
.unwrap_or_else(|e| panic!("Failed to create websocket client: {}", e));
BiliLiveClient {
ws,
cookies: cookies.to_string(),
room_id: room_id.to_string(),
auth_msg,
ss: r,
}
}
/// Create a new client with automatic browser cookie detection
/// If cookies is None or empty, it will try to find cookies from browser
pub fn new_auto(
cookies: Option<&str>,
room_id: &str,
r: Sender<BiliMessage>,
) -> Result<Self, String> {
let resolved_cookies = get_cookies_or_browser(cookies)
.ok_or_else(|| "No cookies found in provided value or browser cookies. Please log into bilibili.com in your browser or provide cookies manually.".to_string())?;
let (ws, auth_msg) = Self::connect_with_auth(&resolved_cookies, room_id)?;
Ok(BiliLiveClient {
ws,
cookies: resolved_cookies,
room_id: room_id.to_string(),
auth_msg,
ss: r,
})
}
/// Send authentication, including the built-in reconnect fallback.
///
/// The boolean lets embedding applications rebuild the whole client when
/// all socket-level recovery attempts fail. Existing CLI callers may
/// safely ignore it.
pub fn send_auth(&mut self) -> bool {
match self.send_auth_internal() {
Ok(()) => true,
Err(e) => {
log::error!("failed to send auth packet: {}", e);
false
}
}
}
/// Send a heartbeat and report whether socket-level recovery succeeded.
pub fn send_heart_beat(&mut self) -> bool {
match self.send_heart_beat_internal() {
Ok(()) => true,
Err(e) => {
log::error!("failed to send heartbeat: {}", e);
false
}
}
}
pub fn parse_ws_message(&mut self, resv: Vec<u8>) {
let mut offset = 0;
let header = &resv[0..16];
let mut head_1 = get_msg_header(header);
if head_1.operation == 5 || head_1.operation == 8 {
loop {
let body: &[u8] = &resv[offset + 16..offset + (head_1.pack_len as usize)];
self.parse_business_message(head_1, body);
offset += head_1.pack_len as usize;
if offset >= resv.len() {
break;
}
let temp_head = &resv[offset..(offset + 16)];
head_1 = get_msg_header(temp_head);
}
} else if head_1.operation == 3 {
let mut body: [u8; 4] = [0, 0, 0, 0];
body[0] = resv[16];
body[1] = resv[17];
body[2] = resv[18];
body[3] = resv[19];
let popularity = i32::from_be_bytes(body);
log::info!("popularity:{}", popularity);
} else {
log::error!(
"unknown message operation={:?}, header={:?}}}",
head_1.operation,
head_1
)
}
}
pub fn parse_business_message(&mut self, h: MsgHead, b: &[u8]) {
if h.operation == 5 {
if h.ver == 3 {
let res: Vec<u8> = decompress(b).unwrap();
self.parse_ws_message(res);
} else if h.ver == 0 {
let s = String::from_utf8(b.to_vec()).unwrap();
let res_json: Value = serde_json::from_str(s.as_str()).unwrap();
if let Some(msg) = handle(res_json) {
let _ = self.ss.try_send(msg);
}
} else {
log::error!("Unknown compression format");
}
} else if h.operation == 8 {
self.send_heart_beat();
} else {
log::error!("Unknown message format {}", h.operation);
}
}
pub fn receive(&mut self) -> Result<(), String> {
if self.ws.can_read() {
let msg = self.ws.read();
match msg {
Ok(m) => {
let res = m.into_data();
if res.len() >= 16 {
self.parse_ws_message(res);
}
Ok(())
}
Err(tungstenite::Error::Io(error))
if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) =>
{
// A short read timeout lets an embedding application check
// its cancellation token without treating idle rooms as a
// disconnected WebSocket.
Ok(())
}
Err(e) => {
let msg = format!("read msg error: {}", e);
log::warn!("{}", msg);
self.reconnect().map_err(|reconnect_err| {
format!("{}; reconnect failed: {}", msg, reconnect_err)
})
}
}
} else {
Ok(())
}
}
/// Bound a blocking read so supervisors can stop/restart one tenant's
/// listener without leaking the previous connection.
pub fn set_read_timeout(&mut self, timeout: Option<Duration>) -> Result<(), String> {
self.ws
.get_mut()
.get_mut()
.set_read_timeout(timeout)
.map_err(|error| error.to_string())
}
pub fn close(&mut self) {
let _ = self.ws.close(None);
}
fn connect_with_auth(
cookies: &str,
room_id: &str,
) -> Result<(WebSocket<TlsStream<TcpStream>>, String), String> {
panic::catch_unwind(|| {
let (v, auth) = init_server(cookies, room_id);
let (ws, _res) = connect_result(v["host_list"].clone())?;
let auth_msg = serde_json::to_string(&auth)
.map_err(|e| format!("serialize auth payload failed: {}", e))?;
Ok((ws, auth_msg))
})
.map_err(|_| format!("websocket setup panicked for room {}", room_id))?
}
fn send_auth_internal(&mut self) -> Result<(), String> {
match self.ws.send(Message::Binary(make_packet(
self.auth_msg.as_str(),
Operation::AUTH,
))) {
Ok(()) => Ok(()),
Err(e) => {
let msg = format!("send auth error: {}", e);
log::warn!("{}", msg);
self.reconnect()?;
self.ws
.send(Message::Binary(make_packet(
self.auth_msg.as_str(),
Operation::AUTH,
)))
.map_err(|retry_err| format!("{}; resend auth failed: {}", msg, retry_err))
}
}
}
fn send_heart_beat_internal(&mut self) -> Result<(), String> {
match self
.ws
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)))
{
Ok(()) => Ok(()),
Err(e) => {
let msg = format!("send heartbeat error: {}", e);
log::warn!("{}", msg);
self.reconnect()?;
self.ws
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)))
.map_err(|retry_err| format!("{}; resend heartbeat failed: {}", msg, retry_err))
}
}
}
fn reconnect(&mut self) -> Result<(), String> {
let backoff = [1_u64, 2, 5];
let mut last_err = None;
for (idx, delay_secs) in backoff.iter().enumerate() {
if idx > 0 {
thread::sleep(Duration::from_secs(*delay_secs));
}
match Self::connect_with_auth(&self.cookies, &self.room_id) {
Ok((ws, auth_msg)) => {
self.ws = ws;
self.auth_msg = auth_msg;
let auth_resend = self.ws.send(Message::Binary(make_packet(
self.auth_msg.as_str(),
Operation::AUTH,
)));
let heartbeat_resend = self
.ws
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)));
match (auth_resend, heartbeat_resend) {
(Ok(()), Ok(())) => {
log::info!(
"websocket reconnected on attempt {} for room {}",
idx + 1,
self.room_id
);
return Ok(());
}
(auth_result, heartbeat_result) => {
let auth_err = auth_result.err().map(|e| e.to_string());
let heartbeat_err = heartbeat_result.err().map(|e| e.to_string());
let reconnect_err = match (auth_err, heartbeat_err) {
(Some(auth_err), Some(heartbeat_err)) => format!(
"reconnected socket but auth resend failed: {}; heartbeat resend failed: {}",
auth_err, heartbeat_err
),
(Some(auth_err), None) => {
format!("reconnected socket but auth resend failed: {}", auth_err)
}
(None, Some(heartbeat_err)) => format!(
"reconnected socket but heartbeat resend failed: {}",
heartbeat_err
),
(None, None) => unreachable!(),
};
log::warn!(
"websocket reconnect attempt {} did not fully recover for room {}: {}",
idx + 1,
self.room_id,
reconnect_err
);
last_err = Some(reconnect_err);
}
}
}
Err(e) => {
log::warn!(
"websocket reconnect attempt {} failed for room {}: {}",
idx + 1,
self.room_id,
e
);
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| "unknown reconnect failure".to_string()))
}
}
pub fn gen_damu_list(list: &Value) -> Vec<DanmuServer> {
let server_list = list.as_array().unwrap();
let mut res: Vec<DanmuServer> = Vec::new();
if server_list.len() == 0 {
let d = DanmuServer::default();
res.push(d);
}
for s in server_list {
res.push(DanmuServer {
host: s["host"].as_str().unwrap().to_string(),
port: s["port"].as_u64().unwrap() as i32,
wss_port: s["wss_port"].as_u64().unwrap() as i32,
ws_port: s["ws_port"].as_u64().unwrap() as i32,
});
}
res
}
fn find_server(vd: Vec<DanmuServer>) -> (String, String, String) {
let (host, wss_port) = (vd.get(0).unwrap().host.clone(), vd.get(0).unwrap().wss_port);
(
host.clone(),
format!("{}:{}", host.clone(), wss_port),
format!("wss://{}:{}/sub", host, wss_port),
)
}
pub fn init_server(cookies: &str, room_id: &str) -> (Value, AuthMessage) {
let mut auth_map = HashMap::new();
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::COOKIE,
reqwest::header::HeaderValue::from_str(cookies).unwrap(),
);
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(crate::auth::USER_AGENT),
);
log::debug!("headers: {:?}", headers);
// Extract SESSDATA from cookies for authentication
let sessdata = cookies
.split(';')
.find_map(|kv| {
let mut parts = kv.trim().splitn(2, '=');
let key = parts.next()?.trim();
let value = parts.next()?.trim();
if key == "SESSDATA" {
Some(value.to_string())
} else {
None
}
})
.unwrap_or_else(|| "".to_string());
if !sessdata.is_empty() {
let (_, body1) = init_uid(headers.clone());
let body1_v: Value = serde_json::from_str(body1.as_str()).unwrap();
// Check if the authentication was successful
if let Some(mid) = body1_v["data"]["mid"].as_i64() {
auth_map.insert("uid".to_string(), mid.to_string());
log::info!("Successfully authenticated with uid: {}", mid);
} else {
log::warn!("Authentication failed - SESSDATA may be invalid or expired");
log::debug!("Auth response: {}", body1);
auth_map.insert("uid".to_string(), "0".to_string());
}
} else {
auth_map.insert("uid".to_string(), "0".to_string());
}
// here the live room id is easily obtained, so we not get it by url.
auth_map.insert("room_id".to_string(), room_id.to_string());
let room_id_num = room_id.parse::<u64>().expect("room_id must be a valid u64");
let (_, body4) = init_host_server(headers.clone(), room_id_num);
let body4_res: Value = serde_json::from_str(body4.as_str()).unwrap();
let server_info = &body4_res["data"];
let token = &body4_res["data"]["token"].as_str().unwrap();
auth_map.insert("token".to_string(), token.to_string());
let auth_msg = AuthMessage::from(&auth_map);
(server_info.clone(), auth_msg)
}
pub fn connect(v: Value) -> (WebSocket<TlsStream<TcpStream>>, Response<Option<Vec<u8>>>) {
connect_result(v).expect("Can't connect")
}
pub fn connect_result(
v: Value,
) -> Result<(WebSocket<TlsStream<TcpStream>>, Response<Option<Vec<u8>>>), String> {
let danmu_server = gen_damu_list(&v);
let (host, url, ws_url) = find_server(danmu_server);
let connector: native_tls::TlsConnector =
native_tls::TlsConnector::new().map_err(|e| format!("tls init failed: {}", e))?;
let stream: TcpStream = TcpStream::connect(url.as_str())
.map_err(|e| format!("tcp connect to {} failed: {}", url, e))?;
let stream: native_tls::TlsStream<TcpStream> = connector
.connect(host.as_str(), stream)
.map_err(|e| format!("tls connect to {} failed: {}", host, e))?;
let parsed_url =
Url::parse(ws_url.as_str()).map_err(|e| format!("invalid websocket url: {}", e))?;
client(parsed_url, stream).map_err(|e| format!("websocket handshake failed: {}", e))
}
pub enum Operation {
AUTH,
HEARTBEAT,
}
pub fn make_packet(body: &str, ops: Operation) -> Vec<u8> {
let json: Value = serde_json::from_str(body).unwrap();
let temp = json.to_string();
let body_content: &[u8] = temp.as_bytes();
let pack_len: [u8; 4] = ((16 + body.len()) as u32).to_be_bytes();
let raw_header_size: [u8; 2] = (16 as u16).to_be_bytes();
let ver: [u8; 2] = (1 as u16).to_be_bytes();
let operation: [u8; 4] = match ops {
Operation::AUTH => (7 as u32).to_be_bytes(),
Operation::HEARTBEAT => (2 as u32).to_be_bytes(),
};
let seq_id: [u8; 4] = (1 as u32).to_be_bytes();
let mut res = pack_len.to_vec();
res.append(&mut raw_header_size.to_vec());
res.append(&mut ver.to_vec());
res.append(&mut operation.to_vec());
res.append(&mut seq_id.to_vec());
res.append(&mut body_content.to_vec());
res
}
pub fn get_msg_header(v_s: &[u8]) -> MsgHead {
let mut pack_len: [u8; 4] = [0; 4];
let mut raw_header_size: [u8; 2] = [0; 2];
let mut ver: [u8; 2] = [0; 2];
let mut operation: [u8; 4] = [0; 4];
let mut seq_id: [u8; 4] = [0; 4];
for (i, v) in v_s.iter().enumerate() {
if i < 4 {
pack_len[i] = *v;
continue;
}
if i < 6 {
raw_header_size[i - 4] = *v;
continue;
}
if i < 8 {
ver[i - 6] = *v;
continue;
}
if i < 12 {
operation[i - 8] = *v;
continue;
}
if i < 16 {
seq_id[i - 12] = *v;
continue;
}
}
MsgHead {
pack_len: u32::from_be_bytes(pack_len),
raw_header_size: u16::from_be_bytes(raw_header_size),
ver: u16::from_be_bytes(ver),
operation: u32::from_be_bytes(operation),
seq_id: u32::from_be_bytes(seq_id),
}
}
pub fn decompress(body: &[u8]) -> std::io::Result<Vec<u8>> {
use brotlic::DecompressorReader;
use std::io::Read;
let mut decompressed_reader: DecompressorReader<&[u8]> = DecompressorReader::new(body);
let mut decoded_input = Vec::new();
let _ = decompressed_reader.read_to_end(&mut decoded_input)?;
Ok(decoded_input)
}
/// here we detail [info format is online](https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/live/message_stream.md)
/// .
pub fn handle(json: Value) -> Option<BiliMessage> {
let category = json["cmd"].as_str().unwrap_or("");
match category {
// Preserve all fields for callers that need a stable UID, gift price
// and upstream event identifier. The previous typed variants discard
// those values, which makes reliable accounting impossible.
"DANMU_MSG" | "SEND_GIFT" => Some(BiliMessage::Raw(json)),
"ONLINE_RANK_COUNT" => Some(BiliMessage::OnlineRankCount {
count: json["data"]["count"].as_u64().unwrap_or(0),
online_count: json["data"]["online_count"].as_u64().unwrap_or(0),
}),
// Add more cases for other types as needed
_ => Some(BiliMessage::Raw(json)),
}
}
/// Enhanced init_server that can automatically detect cookies from browser
pub fn init_server_auto(
provided_cookies: Option<&str>,
room_id: &str,
) -> Result<(Value, AuthMessage), String> {
// Try to get cookies from provided value or browser cookies
let cookies = get_cookies_or_browser(provided_cookies)
.ok_or_else(|| "No cookies found in provided value or browser cookies. Please log into bilibili.com in your browser or provide cookies manually.".to_string())?;
log::info!(
"Using cookies for authentication: {}...",
&cookies[..10.min(cookies.len())]
);
let result = init_server(&cookies, room_id);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use futures_channel::mpsc::channel;
#[test]
fn test_bili_live_client_connect() {
// Always enable debug log output for test
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Debug)
.try_init();
// Get cookies from environment variable for real test
let cookies =
std::env::var("Cookie").unwrap_or_else(|_| "SESSDATA=dummy_sessdata".to_string());
let room_id = "24779526";
let (tx, _rx) = channel(10);
let _client = BiliLiveClient::new(&cookies, room_id, tx);
}
}
-276
View File
@@ -1,276 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub connection: Option<ConnectionConfig>,
#[serde(default)]
pub tts: Option<TtsConfig>,
#[serde(default)]
pub auto_reply: Option<AutoReplyConfig>,
#[serde(default)]
pub debug: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConnectionConfig {
pub cookies: Option<String>,
pub room_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TtsConfig {
pub server: Option<String>,
pub voice: Option<String>,
pub backend: Option<String>,
pub quality: Option<String>,
pub format: Option<String>,
pub sample_rate: Option<u32>,
pub volume: Option<f32>,
pub command: Option<String>,
pub args: Option<String>,
/// Alibaba DashScope TTS configuration
pub ali_api_key: Option<String>,
pub ali_model: Option<String>,
pub ali_voice: Option<String>,
pub ali_language_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerConfig {
pub keywords: Vec<String>,
pub response: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoReplyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_cooldown")]
pub cooldown_seconds: u64,
#[serde(default)]
pub triggers: Vec<TriggerConfig>,
}
impl Default for AutoReplyConfig {
fn default() -> Self {
Self {
enabled: false,
cooldown_seconds: default_cooldown(),
triggers: vec![],
}
}
}
fn default_cooldown() -> u64 {
5
}
impl AutoReplyConfig {
/// Convert to blivedm::plugins::auto_reply::AutoReplyConfig
pub fn to_plugin_config(&self) -> blivedm::plugins::auto_reply::AutoReplyConfig {
blivedm::plugins::auto_reply::AutoReplyConfig {
enabled: self.enabled,
cooldown_seconds: self.cooldown_seconds,
triggers: self
.triggers
.iter()
.map(|t| t.to_plugin_trigger())
.collect(),
}
}
}
impl TriggerConfig {
/// Convert to blivedm::plugins::auto_reply::TriggerConfig
pub fn to_plugin_trigger(&self) -> blivedm::plugins::auto_reply::TriggerConfig {
blivedm::plugins::auto_reply::TriggerConfig {
keywords: self.keywords.clone(),
response: self.response.clone(),
}
}
}
impl Config {
/// Load configuration from file with fallback locations
pub fn load_from_file(config_path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
let config_file = if let Some(path) = config_path {
// Use provided path
path.to_path_buf()
} else {
// Try current directory first
let current_dir_config = PathBuf::from("config.toml");
if current_dir_config.exists() {
current_dir_config
} else {
// Try XDG config directory
Self::get_default_config_path()?
}
};
if !config_file.exists() {
log::debug!("Config file {:?} not found", config_file);
// Create config file if it doesn't exist and we're using default locations
if config_path.is_none() {
match Self::create_example_config(&config_file) {
Ok(()) => {
println!("Created configuration file: {:?}", config_file);
println!("You can customize it as needed.");
}
Err(e) => {
log::warn!("Failed to create config file: {}", e);
return Ok(Config::default());
}
}
} else {
return Ok(Config::default());
}
}
log::info!("Loading configuration from {:?}", config_file);
let content = fs::read_to_string(&config_file)
.map_err(|e| format!("Failed to read config file {:?}: {}", config_file, e))?;
let config: Config = toml::from_str(&content)
.map_err(|e| format!("Failed to parse config file {:?}: {}", config_file, e))?;
Ok(config)
}
/// Get the default configuration file path (~/.config/blivedm_rs/config.toml)
fn get_default_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
let config_dir = dirs::config_dir()
.ok_or("Unable to determine config directory")?
.join("blivedm_rs");
// Create config directory if it doesn't exist
if !config_dir.exists() {
fs::create_dir_all(&config_dir).map_err(|e| {
format!("Failed to create config directory {:?}: {}", config_dir, e)
})?;
}
Ok(config_dir.join("config.toml"))
}
/// Create an example configuration file
pub fn create_example_config(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let example_config = Config {
connection: None,
tts: Some(TtsConfig {
server: Some("http://localhost:8000".to_string()),
voice: None,
backend: None,
quality: None,
format: None,
sample_rate: None,
volume: None,
command: None,
args: None,
ali_api_key: None,
ali_model: None,
ali_voice: None,
ali_language_type: None,
}),
auto_reply: Some(AutoReplyConfig {
enabled: false,
cooldown_seconds: 5,
triggers: vec![
TriggerConfig {
keywords: vec!["你好".to_string(), "hello".to_string()],
response: "欢迎来到直播间!".to_string(),
},
TriggerConfig {
keywords: vec!["谢谢".to_string(), "thanks".to_string()],
response: "不客气~".to_string(),
},
],
}),
debug: None,
};
let toml_string = toml::to_string_pretty(&example_config)
.map_err(|e| format!("Failed to serialize example config: {}", e))?;
fs::write(path, toml_string)
.map_err(|e| format!("Failed to write example config to {:?}: {}", path, e))?;
Ok(())
}
/// Print the effective configuration (for debugging)
#[allow(clippy::too_many_arguments)]
pub fn print_effective_config(
cookies: &Option<String>,
room_id: &str,
tts_server: &Option<String>,
tts_voice: &Option<String>,
tts_backend: &Option<String>,
tts_quality: &Option<String>,
tts_format: &Option<String>,
tts_sample_rate: &Option<u32>,
tts_volume: &Option<f32>,
tts_command: &Option<String>,
tts_args: &Option<String>,
ali_api_key: &Option<String>,
ali_model: &Option<String>,
ali_voice: &Option<String>,
ali_language_type: &Option<String>,
auto_reply: &Option<AutoReplyConfig>,
debug: bool,
) {
println!("=== Effective Configuration ===");
println!("Connection:");
println!(" room_id: {}", room_id);
if let Some(cookies_val) = cookies {
println!(
" cookies: {}...",
&cookies_val.chars().take(20).collect::<String>()
);
} else {
println!(" cookies: None (will auto-detect)");
}
println!("TTS (REST API):");
println!(" server: {:?}", tts_server);
println!(" voice: {:?}", tts_voice);
println!(" backend: {:?}", tts_backend);
println!(" quality: {:?}", tts_quality);
println!(" format: {:?}", tts_format);
println!(" sample_rate: {:?}", tts_sample_rate);
println!(" volume: {:?}", tts_volume);
println!(" command: {:?}", tts_command);
println!(" args: {:?}", tts_args);
println!("TTS (Alibaba DashScope):");
if let Some(key) = ali_api_key {
println!(
" api_key: {}...",
&key.chars().take(10).collect::<String>()
);
} else {
println!(" api_key: None");
}
println!(" model: {:?}", ali_model);
println!(" voice: {:?}", ali_voice);
println!(" language_type: {:?}", ali_language_type);
println!("Auto Reply:");
if let Some(auto_reply_config) = auto_reply {
println!(" enabled: {}", auto_reply_config.enabled);
println!(" cooldown_seconds: {}", auto_reply_config.cooldown_seconds);
println!(
" triggers: {} configured",
auto_reply_config.triggers.len()
);
} else {
println!(" enabled: false (not configured)");
}
println!("Debug: {}", debug);
println!("===============================");
}
}
-17
View File
@@ -1,17 +0,0 @@
// src/lib.rs
//! Bilibili live room danmaku WebSocket client library with TTS and plugin support
pub mod client;
pub mod plugins;
pub mod tui;
// Re-export commonly used items from client
pub use client::{auth, get_cookies_or_browser, models, scheduler, websocket};
#[cfg(feature = "browser_cookies")]
pub use client::browser_cookies;
// Re-export plugin modules and helpers
pub use plugins::{
auto_reply, auto_reply_handler, terminal_display, terminal_display_handler, tts, tts_handler,
tts_handler_command, tts_handler_default,
};
-481
View File
@@ -1,481 +0,0 @@
// src/main.rs
// Standalone binary to test integration of the terminal display plugin with the BiliLiveClient
mod config;
use blivedm::client::get_cookies_or_browser;
use blivedm::client::scheduler::{EventContext, Scheduler};
use blivedm::client::websocket::BiliLiveClient;
use blivedm::plugins::terminal_display::TerminalDisplayHandler;
use blivedm::plugins::tts::TtsHandler;
use blivedm::tui::{TuiApp, TuiLogger, run_tui};
use clap::{CommandFactory, Parser};
use clap_complete::{Shell, generate};
use config::Config;
use futures::channel::mpsc;
use futures::stream::StreamExt;
use std::collections::VecDeque;
use std::env;
use std::path::PathBuf;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tokio::runtime::Runtime;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Path to configuration file
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
/// Print effective configuration and exit
#[arg(long)]
print_config: bool,
/// Cookies for Bilibili authentication (optional - will auto-detect from browser if not provided)
#[arg(long, value_name = "COOKIES")]
cookies: Option<String>,
/// Room ID to connect to
#[arg(long, value_name = "ROOM_ID")]
room_id: Option<String>,
/// TTS REST API server URL
#[arg(long, value_name = "URL")]
tts_server: Option<String>,
/// TTS voice ID (e.g., "zh-CN-XiaoxiaoNeural")
#[arg(long, value_name = "VOICE")]
tts_voice: Option<String>,
/// TTS backend ("edge", "xtts", "piper")
#[arg(long, value_name = "BACKEND")]
tts_backend: Option<String>,
/// TTS audio quality ("low", "medium", "high")
#[arg(long, value_name = "QUALITY")]
tts_quality: Option<String>,
/// TTS audio format (e.g., "wav")
#[arg(long, value_name = "FORMAT")]
tts_format: Option<String>,
/// TTS sample rate (e.g., 22050, 44100)
#[arg(long, value_name = "RATE")]
tts_sample_rate: Option<u32>,
/// TTS audio volume (0.0 to 1.0)
#[arg(long, value_name = "VOLUME")]
tts_volume: Option<f32>,
/// Local TTS command (e.g., "say", "espeak-ng")
#[arg(long, value_name = "COMMAND")]
tts_command: Option<String>,
/// Comma-separated arguments for TTS command
#[arg(long, value_name = "ARGS", allow_hyphen_values = true)]
tts_args: Option<String>,
/// Alibaba DashScope API key for ali-tts (can also use DASHSCOPE_API_KEY env)
#[arg(long, value_name = "KEY")]
ali_api_key: Option<String>,
/// Alibaba TTS model (e.g., "qwen3-tts-flash")
#[arg(long, value_name = "MODEL")]
ali_model: Option<String>,
/// Alibaba TTS voice (e.g., "Cherry", "Chelsie")
#[arg(long, value_name = "VOICE")]
ali_voice: Option<String>,
/// Alibaba TTS language type (e.g., "Chinese", "English")
#[arg(long, value_name = "LANG")]
ali_language_type: Option<String>,
/// Enable debug logging
#[arg(long)]
debug: bool,
/// Enable auto reply plugin
#[arg(long)]
auto_reply: bool,
/// Generate shell completion script (bash, zsh, fish, powershell, elvish)
#[arg(long, value_name = "SHELL")]
generate_completion: Option<Shell>,
}
fn main() {
let args = Args::parse();
// Handle shell completion generation early (before any other processing)
if let Some(shell) = args.generate_completion {
let mut cmd = Args::command();
generate(shell, &mut cmd, "blivedm", &mut std::io::stdout());
return;
}
// Load configuration from file first
let config = match Config::load_from_file(args.config.as_deref()) {
Ok(config) => config,
Err(e) => {
eprintln!("Error loading configuration: {}", e);
std::process::exit(1);
}
};
// Initialize logging with precedence: CLI args > env vars > config file
let debug_enabled =
args.debug || env::var("DEBUG").unwrap_or_default() == "1" || config.debug.unwrap_or(false);
// Load cookies and room_id with precedence: CLI args > env vars > config file > defaults
let cookies = args
.cookies
.or_else(|| {
env::var("Cookie")
.ok()
.filter(|s| !s.is_empty() && s != "SESSDATA=dummy_sessdata")
})
.or_else(|| config.connection.as_ref().and_then(|c| c.cookies.clone()));
// If no manual cookies provided, try browser auto-detection
let cookies = if cookies.is_none() {
if debug_enabled {
log::info!("No manual cookies provided, attempting browser auto-detection...");
}
get_cookies_or_browser(None)
} else {
if debug_enabled {
log::info!("Using manually provided cookies");
}
cookies
};
let room_id = args
.room_id
.or_else(|| env::var("ROOM_ID").ok())
.or_else(|| config.connection.as_ref().and_then(|c| c.room_id.clone()))
.unwrap_or_else(|| "24779526".to_string());
// Configure TTS with precedence: CLI args > config file
let tts_server = args
.tts_server
.or_else(|| config.tts.as_ref().and_then(|t| t.server.clone()));
let tts_voice = args
.tts_voice
.or_else(|| config.tts.as_ref().and_then(|t| t.voice.clone()));
let tts_backend = args
.tts_backend
.or_else(|| config.tts.as_ref().and_then(|t| t.backend.clone()));
let tts_quality = args
.tts_quality
.or_else(|| config.tts.as_ref().and_then(|t| t.quality.clone()));
let tts_format = args
.tts_format
.or_else(|| config.tts.as_ref().and_then(|t| t.format.clone()));
let tts_sample_rate = args
.tts_sample_rate
.or_else(|| config.tts.as_ref().and_then(|t| t.sample_rate));
let tts_volume = args
.tts_volume
.or_else(|| config.tts.as_ref().and_then(|t| t.volume));
let tts_command = args
.tts_command
.or_else(|| config.tts.as_ref().and_then(|t| t.command.clone()));
let tts_args = args
.tts_args
.or_else(|| config.tts.as_ref().and_then(|t| t.args.clone()));
// Configure Alibaba TTS with precedence: CLI args > env vars > config file
let ali_api_key = args
.ali_api_key
.or_else(|| env::var("DASHSCOPE_API_KEY").ok())
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_api_key.clone()));
let ali_model = args
.ali_model
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_model.clone()));
let ali_voice = args
.ali_voice
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_voice.clone()));
let ali_language_type = args.ali_language_type.or_else(|| {
config
.tts
.as_ref()
.and_then(|t| t.ali_language_type.clone())
});
// Configure auto reply with precedence: CLI args > config file
let auto_reply_config = if let Some(config_auto_reply) = &config.auto_reply {
// Use config file settings, but allow CLI flag to override enabled
let mut plugin_config = config_auto_reply.to_plugin_config();
if args.auto_reply {
plugin_config.enabled = true;
}
plugin_config
} else {
// No config file section, use defaults with CLI flag
let mut default_config = blivedm::plugins::auto_reply::AutoReplyConfig::default();
default_config.enabled = args.auto_reply;
default_config
};
// If user wants to see config, print and exit
if args.print_config {
// Create a temporary config struct for display that reflects the effective settings
let effective_auto_reply = if auto_reply_config.enabled {
Some(config::AutoReplyConfig {
enabled: auto_reply_config.enabled,
cooldown_seconds: auto_reply_config.cooldown_seconds,
triggers: auto_reply_config
.triggers
.iter()
.map(|t| config::TriggerConfig {
keywords: t.keywords.clone(),
response: t.response.clone(),
})
.collect(),
})
} else {
None
};
Config::print_effective_config(
&cookies,
&room_id,
&tts_server,
&tts_voice,
&tts_backend,
&tts_quality,
&tts_format,
&tts_sample_rate,
&tts_volume,
&tts_command,
&tts_args,
&ali_api_key,
&ali_model,
&ali_voice,
&ali_language_type,
&effective_auto_reply,
debug_enabled,
);
std::process::exit(0);
}
// Initialize TuiLogger to capture logs into a shared buffer for the TUI logs panel.
// When debug is enabled, capture Debug level; otherwise capture Info level.
let log_level = if debug_enabled {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
let log_buffer = TuiLogger::init(log_level);
// Create client with automatic browser cookie detection
let (tx, mut rx) = mpsc::channel(64);
let mut client = match BiliLiveClient::new_auto(cookies.as_deref(), &room_id, tx) {
Ok(client) => {
log::info!("Successfully created client with automatic cookie detection");
client
}
Err(e) => {
eprintln!("Failed to create client: {}", e);
eprintln!(
"Please ensure you are logged into bilibili.com in your browser, or provide cookies manually."
);
std::process::exit(1);
}
};
client.send_auth();
client.send_heart_beat();
let shared_client: Arc<Mutex<BiliLiveClient>> = Arc::new(Mutex::new(client));
let heart_beats: Arc<Mutex<BiliLiveClient>> = Arc::clone(&shared_client);
thread::spawn(move || {
loop {
match heart_beats.lock() {
Ok(mut heart_beats_c) => {
heart_beats_c.send_heart_beat();
}
Err(e) => {
eprintln!("Error acquiring lock on stream: {}", e);
break;
}
}
thread::sleep(Duration::new(20, 0));
}
});
let rec_msg: Arc<Mutex<BiliLiveClient>> = Arc::clone(&shared_client);
thread::spawn(move || {
loop {
match rec_msg.lock() {
Ok(mut rec_c) => {
if let Err(e) = rec_c.receive() {
log::error!("{}", e);
}
}
Err(e) => {
eprintln!("Error acquiring lock on stream: {}", e);
break;
}
}
thread::sleep(Duration::from_millis(10)); // instead of 10 microseconds
}
});
// Set up the scheduler with context and add the terminal display handler
if debug_enabled {
match &cookies {
Some(cookie_str) => {
log::debug!(
"Cookies found and passed to context: {}...",
&cookie_str.chars().take(50).collect::<String>()
);
}
None => {
log::warn!(
"No cookies found for EventContext - auto-reply will not be able to send messages"
);
}
}
}
// Create shared message buffer for TUI
let message_buffer: Arc<Mutex<VecDeque<String>>> = Arc::new(Mutex::new(VecDeque::new()));
// Create shared online count for TUI title display
let online_count: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
let context = EventContext::new(cookies.clone(), room_id.parse::<u64>().unwrap_or(0));
let mut scheduler = Scheduler::new(context);
let terminal_handler = Arc::new(TerminalDisplayHandler::with_online_count(
Arc::clone(&message_buffer),
Arc::clone(&online_count),
));
scheduler.add_sequential_handler(terminal_handler);
if let Some(server_url) = tts_server {
// REST API TTS configuration
let tts_handler = Arc::new(TtsHandler::new_rest_api_with_volume(
server_url,
tts_voice,
tts_backend,
tts_quality,
tts_format,
tts_sample_rate,
tts_volume,
));
scheduler.add_sequential_handler(tts_handler);
println!("TTS configured with REST API server");
} else if let Some(api_key) = ali_api_key {
// Alibaba DashScope TTS configuration
let model = ali_model.unwrap_or_else(|| "qwen3-tts-flash".to_string());
let voice = ali_voice.unwrap_or_else(|| "Cherry".to_string());
let tts_handler = Arc::new(TtsHandler::new_ali_tts(
api_key,
model.clone(),
voice.clone(),
ali_language_type,
tts_volume,
));
scheduler.add_sequential_handler(tts_handler);
println!(
"TTS configured with Alibaba DashScope (model: {}, voice: {})",
model, voice
);
} else if let Some(tts_cmd) = tts_command {
// Command-line TTS configuration
let cmd_args = tts_args
.map(|s| s.split(',').map(|s| s.to_string()).collect())
.unwrap_or_default();
let tts_handler = Arc::new(TtsHandler::new_command(tts_cmd, cmd_args));
scheduler.add_sequential_handler(tts_handler);
println!("TTS configured with local command");
} else {
println!(
"No TTS configuration provided. Use --ali-api-key, --tts-server, or --tts-command to enable TTS."
);
}
// Add auto reply plugin if enabled
if auto_reply_config.enabled {
let auto_reply_handler = blivedm::plugins::auto_reply_handler(auto_reply_config);
scheduler.add_sequential_handler(auto_reply_handler);
println!("Auto reply plugin enabled");
} else {
println!(
"Auto reply plugin disabled. Use --auto-reply or configure in config file to enable."
);
}
// Add initial system message to buffer
TuiApp::add_message(&message_buffer, format!("[System] Bilibili Danmu Client"));
TuiApp::add_message(
&message_buffer,
format!("[System] Connected to room: {}", room_id),
);
if let Some(cookies_val) = &cookies {
TuiApp::add_message(
&message_buffer,
format!(
"[System] Using provided cookies: {}...",
&cookies_val.chars().take(30).collect::<String>()
),
);
} else {
TuiApp::add_message(
&message_buffer,
"[System] Using auto-detected cookies from browser".to_string(),
);
}
// create a thread to process the rx channel messages using tokio runtime and pass to scheduler
let rt = Arc::new(Runtime::new().unwrap());
let rt_clone = Arc::clone(&rt);
rt.spawn(async move {
while let Some(msg) = rx.next().await {
scheduler.trigger(msg);
}
});
// Create TUI app
let mut tui_app = TuiApp::with_online_count(
Arc::clone(&message_buffer),
room_id.clone(),
Arc::clone(&online_count),
);
tui_app.set_log_buffer(log_buffer);
let context_for_chat = EventContext::new(cookies.clone(), room_id.parse::<u64>().unwrap_or(0));
let message_buffer_for_feedback = Arc::clone(&message_buffer);
// Run TUI with message sending callback
let tui_result = run_tui(tui_app, move |message| {
let context_clone = context_for_chat.clone();
let rt_for_send = Arc::clone(&rt_clone);
let buffer_clone = Arc::clone(&message_buffer_for_feedback);
rt_for_send.spawn(async move {
if let Err(e) = blivedm::plugins::send_danmaku_message(&message, &context_clone).await {
TuiApp::add_message(
&buffer_clone,
format!("[System] Error sending message: {}", e),
);
}
});
});
if let Err(e) = tui_result {
eprintln!("TUI error: {}", e);
}
// close the client
match shared_client.lock() {
Ok(mut _client) => {}
Err(e) => {
eprintln!("Error acquiring lock on stream: {}", e);
}
}
// wait for the threads to finish
thread::sleep(Duration::new(1, 0));
}
-478
View File
@@ -1,478 +0,0 @@
use crate::client::models::BiliMessage;
use crate::client::scheduler::{EventContext, EventHandler};
use log::{debug, error, info, warn};
use reqwest::header::{HeaderMap, HeaderValue};
use serde::Serialize;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::runtime::Runtime;
/// Configuration for keyword-response triggers
#[derive(Debug, Clone)]
pub struct TriggerConfig {
/// Keywords that trigger this response
pub keywords: Vec<String>,
/// Response message to send
pub response: String,
}
/// Configuration for the auto reply plugin
#[derive(Debug, Clone)]
pub struct AutoReplyConfig {
/// Whether the plugin is enabled
pub enabled: bool,
/// Minimum cooldown between replies in seconds
pub cooldown_seconds: u64,
/// List of trigger configurations
pub triggers: Vec<TriggerConfig>,
}
impl Default for AutoReplyConfig {
fn default() -> Self {
Self {
enabled: false,
cooldown_seconds: 5,
triggers: vec![
TriggerConfig {
keywords: vec!["你好".to_string(), "hello".to_string()],
response: "欢迎来到直播间!".to_string(),
},
TriggerConfig {
keywords: vec!["谢谢".to_string(), "thanks".to_string()],
response: "不客气~".to_string(),
},
],
}
}
}
/// Parameters for sending a danmaku message to Bilibili API
#[derive(Serialize, Debug)]
struct SendDanmakuRequest {
csrf: String,
roomid: u64,
msg: String,
rnd: u64,
fontsize: u32,
color: u32,
mode: u32,
bubble: u32,
room_type: u32,
jumpfrom: u32,
reply_mid: u32,
reply_attr: u32,
reply_uname: String,
replay_dmid: String,
statistics: String,
csrf_token: String,
}
/// Extract CSRF token from cookies string
pub fn extract_csrf_token(cookies: &str) -> Option<String> {
for cookie in cookies.split(';') {
let cookie = cookie.trim();
if cookie.starts_with("bili_jct=") {
return Some(cookie[9..].to_string());
}
}
None
}
/// Send a danmaku message to the Bilibili live room
///
/// # Arguments
/// * `message` - The text message to send
/// * `context` - Event context containing cookies and room_id
///
/// # Returns
/// Returns Ok(()) on success, or an error if the request fails
pub async fn send_danmaku_message(
message: &str,
context: &EventContext,
) -> Result<(), Box<dyn std::error::Error>> {
let cookies = match &context.cookies {
Some(cookies) => cookies,
None => {
return Err("No cookies available for sending danmaku".into());
}
};
let csrf_token = match extract_csrf_token(cookies) {
Some(token) => token,
None => {
return Err("Could not extract CSRF token from cookies".into());
}
};
// Current timestamp
let rnd = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let request = SendDanmakuRequest {
csrf: csrf_token.clone(),
roomid: context.room_id,
msg: message.to_string(),
rnd,
fontsize: 25,
color: 16777215, // White color
mode: 1, // Scroll mode
bubble: 0,
room_type: 0,
jumpfrom: 0,
reply_mid: 0,
reply_attr: 0,
reply_uname: String::new(),
replay_dmid: String::new(),
statistics: r#"{"appId":100,"platform":5}"#.to_string(),
csrf_token,
};
// Set up headers
let mut headers = HeaderMap::new();
headers.insert("Cookie", HeaderValue::from_str(cookies)?);
headers.insert(
"User-Agent",
HeaderValue::from_static(
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0",
),
);
headers.insert(
"Referer",
HeaderValue::from_str(&format!("https://live.bilibili.com/{}", context.room_id))?,
);
debug!("Sending danmaku: {}", message);
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = http_client
.post("https://api.live.bilibili.com/msg/send")
.headers(headers)
.form(&request)
.send()
.await?;
if response.status().is_success() {
info!("Successfully sent danmaku: {}", message);
Ok(())
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
warn!("Failed to send danmaku, status: {}", status);
debug!("Response body: {}", body);
Err(format!("Failed to send danmaku: {} - {}", status, body).into())
}
}
/// Auto reply handler that monitors danmaku for keywords and sends responses
pub struct AutoReplyHandler {
config: AutoReplyConfig,
last_reply: Arc<Mutex<Option<Instant>>>,
http_client: reqwest::Client,
runtime: Arc<Runtime>,
}
impl AutoReplyHandler {
/// Create a new auto reply handler with the given configuration
pub fn new(config: AutoReplyConfig) -> Self {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client");
let runtime = Arc::new(Runtime::new().expect("Failed to create tokio runtime"));
Self {
config,
last_reply: Arc::new(Mutex::new(None)),
http_client,
runtime,
}
}
/// Check if any keyword matches the message text
fn find_matching_trigger(&self, text: &str) -> Option<&TriggerConfig> {
let text_lower = text.to_lowercase();
for trigger in &self.config.triggers {
for keyword in &trigger.keywords {
if text_lower.contains(&keyword.to_lowercase()) {
return Some(trigger);
}
}
}
None
}
/// Get the response from the trigger
fn select_response(&self, trigger: &TriggerConfig) -> Option<String> {
if trigger.response.is_empty() {
return None;
}
Some(trigger.response.clone())
}
/// Check if enough time has passed since the last reply
fn check_cooldown(&self) -> bool {
let last_reply = self.last_reply.lock().unwrap();
match *last_reply {
Some(last_time) => {
let elapsed = last_time.elapsed();
elapsed >= Duration::from_secs(self.config.cooldown_seconds)
}
None => true,
}
}
/// Update the last reply timestamp
fn update_last_reply(&self) {
let mut last_reply = self.last_reply.lock().unwrap();
*last_reply = Some(Instant::now());
}
/// Extract CSRF token from cookies
fn extract_csrf_token(&self, cookies: &str) -> Option<String> {
extract_csrf_token(cookies)
}
/// Send a danmaku message to the Bilibili API
async fn send_danmaku(
&self,
message: &str,
context: &EventContext,
) -> Result<(), reqwest::Error> {
let cookies = match &context.cookies {
Some(cookies) => cookies,
None => {
warn!("No cookies available for sending danmaku");
return Ok(());
}
};
let csrf_token = match self.extract_csrf_token(cookies) {
Some(token) => token,
None => {
error!("Could not extract CSRF token from cookies");
return Ok(());
}
};
// Current timestamp
let rnd = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let request = SendDanmakuRequest {
csrf: csrf_token.clone(),
roomid: context.room_id,
msg: message.to_string(),
rnd,
fontsize: 25,
color: 16777215, // White color
mode: 1, // Scroll mode
bubble: 0,
room_type: 0,
jumpfrom: 0,
reply_mid: 0,
reply_attr: 0,
reply_uname: String::new(),
replay_dmid: String::new(),
statistics: r#"{"appId":100,"platform":5}"#.to_string(),
csrf_token,
};
// Set up headers
let mut headers = HeaderMap::new();
headers.insert("Cookie", HeaderValue::from_str(cookies).unwrap());
headers.insert(
"User-Agent",
HeaderValue::from_static(
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0",
),
);
headers.insert(
"Referer",
HeaderValue::from_str(&format!("https://live.bilibili.com/{}", context.room_id))
.unwrap(),
);
debug!("Sending danmaku: {}", message);
let response = self
.http_client
.post("https://api.live.bilibili.com/msg/send")
.headers(headers)
.form(&request)
.send()
.await?;
if response.status().is_success() {
info!("Successfully sent danmaku: {}", message);
} else {
warn!("Failed to send danmaku, status: {}", response.status());
let body = response.text().await.unwrap_or_default();
debug!("Response body: {}", body);
}
Ok(())
}
}
impl EventHandler for AutoReplyHandler {
fn handle(&self, msg: &BiliMessage, context: &EventContext) {
if !self.config.enabled {
return;
}
// Only process danmaku messages
if let BiliMessage::Danmu { user: _, text } = msg {
// Check for keyword match
if let Some(trigger) = self.find_matching_trigger(text) {
// Check cooldown
if !self.check_cooldown() {
debug!("Auto reply on cooldown, skipping");
return;
}
// Select response
if let Some(response) = self.select_response(trigger) {
debug!(
"Auto reply triggered by '{}', responding with '{}'",
text, response
);
// Update cooldown
self.update_last_reply();
// Send the reply asynchronously
let runtime = Arc::clone(&self.runtime);
let _http_client = self.http_client.clone();
let response_msg = response.clone();
let context_clone = context.clone();
let handler = self.clone();
runtime.spawn(async move {
if let Err(e) = handler.send_danmaku(&response_msg, &context_clone).await {
error!("Failed to send auto reply: {}", e);
}
});
}
}
}
}
}
// Implement Clone for AutoReplyHandler
impl Clone for AutoReplyHandler {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
last_reply: Arc::clone(&self.last_reply),
http_client: self.http_client.clone(),
runtime: Arc::clone(&self.runtime),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::models::BiliMessage;
use crate::client::scheduler::{EventContext, EventHandler};
#[test]
fn test_keyword_matching() {
let config = AutoReplyConfig::default();
let handler = AutoReplyHandler::new(config);
// Test keyword matching
assert!(handler.find_matching_trigger("你好世界").is_some());
assert!(handler.find_matching_trigger("Hello world").is_some());
assert!(handler.find_matching_trigger("谢谢大家").is_some());
assert!(handler.find_matching_trigger("Thanks everyone").is_some());
assert!(handler.find_matching_trigger("random text").is_none());
}
#[test]
fn test_response_selection() {
let config = AutoReplyConfig::default();
let handler = AutoReplyHandler::new(config);
let trigger = &handler.config.triggers[0];
let response = handler.select_response(trigger);
assert!(response.is_some());
assert_eq!(response.unwrap(), trigger.response);
}
#[test]
fn test_cooldown() {
let config = AutoReplyConfig {
enabled: true,
cooldown_seconds: 1,
triggers: vec![],
};
let handler = AutoReplyHandler::new(config);
// Initial check should pass
assert!(handler.check_cooldown());
// Update timestamp
handler.update_last_reply();
// Should be on cooldown now
assert!(!handler.check_cooldown());
// Wait for cooldown
std::thread::sleep(Duration::from_secs(2));
// Should be off cooldown now
assert!(handler.check_cooldown());
}
#[test]
fn test_csrf_extraction() {
let config = AutoReplyConfig::default();
let handler = AutoReplyHandler::new(config);
let cookies = "SESSDATA=abc123; bili_jct=csrf_token_here; other=value";
let csrf = handler.extract_csrf_token(cookies);
assert_eq!(csrf, Some("csrf_token_here".to_string()));
let cookies_no_csrf = "SESSDATA=abc123; other=value";
let csrf = handler.extract_csrf_token(cookies_no_csrf);
assert_eq!(csrf, None);
}
#[test]
fn test_event_handler() {
let config = AutoReplyConfig {
enabled: true,
cooldown_seconds: 0, // No cooldown for testing
triggers: vec![TriggerConfig {
keywords: vec!["test".to_string()],
response: "test response".to_string(),
}],
};
let handler = AutoReplyHandler::new(config);
let context = EventContext {
cookies: Some("bili_jct=test_csrf; SESSDATA=test".to_string()),
room_id: 12345,
};
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "this is a test message".to_string(),
};
// This should trigger the auto reply (but won't actually send due to test environment)
handler.handle(&msg, &context);
}
}
-58
View File
@@ -1,58 +0,0 @@
pub mod auto_reply;
pub mod terminal_display;
pub mod tts;
use crate::client::scheduler::EventHandler;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
// Re-export danmaku sending utility for easy access
pub use auto_reply::send_danmaku_message;
/// Helper to create the handler as Arc<dyn EventHandler>
pub fn terminal_display_handler(
message_buffer: Arc<Mutex<VecDeque<String>>>,
) -> Arc<dyn EventHandler> {
Arc::new(terminal_display::TerminalDisplayHandler::new(
message_buffer,
))
}
/// Helper to create the TTS handler as Arc<dyn EventHandler>
/// Uses default Chinese voice settings with REST API
pub fn tts_handler_default(server_url: String) -> Arc<dyn EventHandler> {
Arc::new(tts::TtsHandler::new_rest_api_default(server_url))
}
/// Helper to create the TTS handler with REST API and custom configuration as Arc<dyn EventHandler>
pub fn tts_handler(
server_url: String,
voice: Option<String>,
backend: Option<String>,
quality: Option<String>,
format: Option<String>,
sample_rate: Option<u32>,
) -> Arc<dyn EventHandler> {
Arc::new(tts::TtsHandler::new_rest_api(
server_url,
voice,
backend,
quality,
format,
sample_rate,
))
}
/// Helper to create the command-based TTS handler as Arc<dyn EventHandler>
/// For local TTS commands like `say` on macOS or `espeak-ng` on Linux
pub fn tts_handler_command(tts_command: String, tts_args: Vec<String>) -> Arc<dyn EventHandler> {
Arc::new(tts::TtsHandler::new_command(tts_command, tts_args))
}
/// Helper to create the auto reply handler as Arc<dyn EventHandler>
pub fn auto_reply_handler(config: auto_reply::AutoReplyConfig) -> Arc<dyn EventHandler> {
Arc::new(auto_reply::AutoReplyHandler::new(config))
}
#[cfg(test)]
mod tests {}
-125
View File
@@ -1,125 +0,0 @@
use crate::client::models::BiliMessage;
use crate::client::scheduler::{EventContext, EventHandler};
use std::collections::VecDeque;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Mutex};
/// A plugin that adds BiliMessages to a shared message buffer for TUI display.
pub struct TerminalDisplayHandler {
/// Shared message buffer for TUI
message_buffer: Arc<Mutex<VecDeque<String>>>,
/// Shared online count for TUI title display
online_count: Arc<AtomicU64>,
}
impl TerminalDisplayHandler {
/// Create a new TerminalDisplayHandler with a shared message buffer
pub fn new(message_buffer: Arc<Mutex<VecDeque<String>>>) -> Self {
Self {
message_buffer,
online_count: Arc::new(AtomicU64::new(0)),
}
}
/// Create a new TerminalDisplayHandler with shared message buffer and online count
pub fn with_online_count(
message_buffer: Arc<Mutex<VecDeque<String>>>,
online_count: Arc<AtomicU64>,
) -> Self {
Self {
message_buffer,
online_count,
}
}
}
impl EventHandler for TerminalDisplayHandler {
fn handle(&self, msg: &BiliMessage, _context: &EventContext) {
let formatted_msg = match msg {
BiliMessage::Danmu { user, text } => {
format!("[Danmu] {}: {}", user, text)
}
BiliMessage::Gift { user, gift , num} => {
format!("[Gift] {} sent a gift: {} X {}", user, gift, num)
}
BiliMessage::OnlineRankCount { online_count, .. } => {
// Update the shared online count for TUI title display
crate::tui::app::TuiApp::set_online_count(&self.online_count, *online_count);
// Don't add to message buffer - just update the title counter
return;
}
BiliMessage::Raw(json) => {
format!("[Raw] {}", json["cmd"].as_str().unwrap_or("Unknown"))
}
#[allow(deprecated)]
BiliMessage::Unsupported => "[Unsupported message type]".to_string(),
};
// Add message to buffer using the TuiApp helper method
crate::tui::app::TuiApp::add_message(&self.message_buffer, formatted_msg);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::models::BiliMessage;
use crate::client::scheduler::EventHandler;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
#[test]
fn test_terminal_display_handler_adds_danmu() {
let buffer = Arc::new(Mutex::new(VecDeque::new()));
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "hello world".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
let messages = buffer.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "[Danmu] test_user: hello world");
}
#[test]
fn test_terminal_display_handler_adds_gift() {
let buffer = Arc::new(Mutex::new(VecDeque::new()));
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
let msg = BiliMessage::Gift {
user: "gift_user".to_string(),
gift: "rocket".to_string(),
num: "count".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
let messages = buffer.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "[Gift] gift_user sent a gift: rocket");
}
#[test]
fn test_terminal_display_handler_adds_unsupported() {
let buffer = Arc::new(Mutex::new(VecDeque::new()));
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
let msg = BiliMessage::Unsupported;
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
let messages = buffer.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "[Unsupported message type]");
}
}
-856
View File
@@ -1,856 +0,0 @@
use crate::client::models::BiliMessage;
use crate::client::scheduler::{EventContext, EventHandler};
use base64::{Engine as _, engine::general_purpose};
use log::{debug, error, info, warn};
use rodio::{Decoder, OutputStream, Sink};
use serde::{Deserialize, Serialize};
use std::io::Cursor;
use std::process::Command;
use std::sync::mpsc::{self, Sender};
use std::thread;
use std::thread::JoinHandle;
#[derive(Serialize, Debug)]
struct TtsRequest {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
backend: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
quality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sample_rate: Option<u32>,
}
#[derive(Deserialize, Debug)]
struct TtsResponse {
audio_data: String,
metadata: TtsMetadata,
#[allow(dead_code)]
cached: bool,
}
#[derive(Deserialize, Debug)]
struct TtsMetadata {
#[allow(dead_code)]
backend: String,
#[allow(dead_code)]
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
duration: Option<f64>,
#[allow(dead_code)]
#[serde(skip_serializing_if = "Option::is_none")]
sample_rate: Option<u32>,
#[allow(dead_code)]
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[allow(dead_code)]
#[serde(skip_serializing_if = "Option::is_none")]
size_bytes: Option<u64>,
}
/// Alibaba DashScope TTS request structure
#[derive(Serialize, Debug)]
struct AliTtsRequest {
model: String,
input: AliTtsInput,
}
#[derive(Serialize, Debug)]
struct AliTtsInput {
text: String,
voice: String,
#[serde(skip_serializing_if = "Option::is_none")]
language_type: Option<String>,
}
/// Alibaba DashScope TTS SSE response structure
#[derive(Deserialize, Debug)]
struct AliTtsResponse {
output: Option<AliTtsOutput>,
#[allow(dead_code)]
request_id: Option<String>,
}
#[derive(Deserialize, Debug)]
struct AliTtsOutput {
#[serde(default)]
audio: Option<AliTtsAudio>,
/// Finish reason: "null" for intermediate, "stop" for final
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize, Debug)]
struct AliTtsAudio {
/// Base64 encoded audio data chunk (may be empty)
#[serde(default)]
data: Option<String>,
/// Audio URL (only in the final response when finish_reason is "stop")
#[serde(default)]
url: Option<String>,
#[allow(dead_code)]
#[serde(default)]
id: Option<String>,
#[allow(dead_code)]
#[serde(default)]
expires_at: Option<u64>,
}
/// TTS backend configuration
#[derive(Debug, Clone)]
pub enum TtsMode {
/// Use REST API for TTS with advanced neural voices
RestApi {
/// The base URL of the TTS server (e.g., "http://localhost:8000")
server_url: String,
/// Voice ID to use for TTS (e.g., "zh-CN-XiaoxiaoNeural")
voice: Option<String>,
/// TTS backend to use (e.g., "edge", "xtts", "piper")
backend: Option<String>,
/// Audio quality ("low", "medium", "high")
quality: Option<String>,
/// Audio format (e.g., "wav")
format: Option<String>,
/// Sample rate for audio
sample_rate: Option<u32>,
/// Audio volume (0.0 to 1.0, default is 1.0)
volume: Option<f32>,
},
/// Use Alibaba DashScope TTS API (qwen3-tts)
AliTts {
/// DashScope API key (from DASHSCOPE_API_KEY env or config)
api_key: String,
/// Model to use (e.g., "qwen3-tts-flash")
model: String,
/// Voice ID to use (e.g., "Cherry", "Chelsie", etc.)
voice: String,
/// Language type (e.g., "Chinese", "English")
language_type: Option<String>,
/// Audio volume (0.0 to 1.0, default is 1.0)
volume: Option<f32>,
},
/// Use local command-line TTS programs
Command {
/// The TTS command to use (e.g., "say" on macOS, "espeak-ng" on Linux)
tts_command: String,
/// Optional extra arguments for the TTS command (e.g., ["-v", "SinJi"])
tts_args: Vec<String>,
},
}
/// A plugin that sends Danmaku text to a TTS service and plays the audio sequentially.
///
/// This handler supports two modes:
/// 1. REST API mode: Sends text to a TTS REST API server, receives base64-encoded audio data,
/// decodes it and plays through the system's audio output
/// 2. Command mode: Uses local command-line TTS programs (like `say` on macOS or `espeak-ng` on Linux)
///
/// Messages are processed sequentially to avoid overlapping audio.
pub struct TtsHandler {
/// TTS configuration (either REST API or command-based)
#[allow(dead_code)]
mode: TtsMode,
/// Channel sender for queuing TTS messages
sender: Sender<String>,
/// Background thread handle for TTS processing
_worker_handle: JoinHandle<()>,
}
impl TtsHandler {
/// Create a new TTS handler with the specified mode
pub fn new(mode: TtsMode) -> Self {
let (sender, receiver) = mpsc::channel::<String>();
// Clone the mode for the worker thread
let mode_clone = mode.clone();
// Spawn worker thread to process TTS queue sequentially
let worker_handle = thread::spawn(move || match &mode_clone {
TtsMode::RestApi { .. } => {
Self::run_rest_api_worker(receiver, mode_clone);
}
TtsMode::AliTts { .. } => {
Self::run_ali_tts_worker(receiver, mode_clone);
}
TtsMode::Command { .. } => {
Self::run_command_worker(receiver, mode_clone);
}
});
TtsHandler {
mode,
sender,
_worker_handle: worker_handle,
}
}
/// Create a new TTS handler with REST API using default Chinese voice settings
pub fn new_rest_api_default(server_url: String) -> Self {
Self::new_rest_api_default_with_volume(server_url, 1.0)
}
/// Create a new TTS handler with REST API using default Chinese voice settings and custom volume
pub fn new_rest_api_default_with_volume(server_url: String, volume: f32) -> Self {
let mode = TtsMode::RestApi {
server_url,
voice: Some("zh-CN-XiaoxiaoNeural".to_string()),
backend: Some("edge".to_string()),
quality: Some("medium".to_string()),
format: Some("wav".to_string()),
sample_rate: Some(22050),
volume: Some(volume),
};
Self::new(mode)
}
/// Create a new TTS handler with REST API and custom configuration
pub fn new_rest_api(
server_url: String,
voice: Option<String>,
backend: Option<String>,
quality: Option<String>,
format: Option<String>,
sample_rate: Option<u32>,
) -> Self {
Self::new_rest_api_with_volume(
server_url,
voice,
backend,
quality,
format,
sample_rate,
None,
)
}
/// Create a new TTS handler with REST API and custom configuration including volume
pub fn new_rest_api_with_volume(
server_url: String,
voice: Option<String>,
backend: Option<String>,
quality: Option<String>,
format: Option<String>,
sample_rate: Option<u32>,
volume: Option<f32>,
) -> Self {
let mode = TtsMode::RestApi {
server_url,
voice,
backend,
quality,
format,
sample_rate,
volume,
};
Self::new(mode)
}
/// Create a new TTS handler with command-line TTS
pub fn new_command(tts_command: String, tts_args: Vec<String>) -> Self {
let mode = TtsMode::Command {
tts_command,
tts_args,
};
Self::new(mode)
}
/// Create a new TTS handler with Alibaba DashScope TTS using default settings
pub fn new_ali_tts_default(api_key: String) -> Self {
Self::new_ali_tts(
api_key,
"qwen3-tts-flash".to_string(),
"Cherry".to_string(),
Some("Chinese".to_string()),
None,
)
}
/// Create a new TTS handler with Alibaba DashScope TTS and custom configuration
pub fn new_ali_tts(
api_key: String,
model: String,
voice: String,
language_type: Option<String>,
volume: Option<f32>,
) -> Self {
let mode = TtsMode::AliTts {
api_key,
model,
voice,
language_type,
volume,
};
Self::new(mode)
}
/// Worker thread for REST API TTS processing
fn run_rest_api_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
if let TtsMode::RestApi {
server_url,
voice,
backend,
quality,
format,
sample_rate,
volume,
} = mode
{
// Create a tokio runtime for HTTP requests
let rt = tokio::runtime::Runtime::new().unwrap();
let client = reqwest::Client::new();
// Initialize audio output stream (this will be reused for all audio playback)
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
while let Ok(message) = receiver.recv() {
let request = TtsRequest {
text: message,
voice: voice.clone(),
backend: backend.clone(),
quality: quality.clone(),
format: format.clone(),
sample_rate,
};
// Make HTTP request to TTS service
rt.block_on(async {
match client
.post(&format!("{}/tts", server_url))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
match response.json::<TtsResponse>().await {
Ok(tts_response) => {
info!("TTS generated successfully");
// Decode base64 audio data and play it
match general_purpose::STANDARD
.decode(&tts_response.audio_data)
{
Ok(audio_bytes) => {
// Create a cursor from the audio bytes
let cursor = Cursor::new(audio_bytes);
// Create a decoder for the audio format
match Decoder::new(cursor) {
Ok(source) => {
// Create a new sink for this audio
let sink =
Sink::try_new(&stream_handle).unwrap();
// Set volume if specified (default to 1.0 if not set)
let audio_volume = volume.unwrap_or(1.0);
sink.set_volume(audio_volume);
// Append the audio source to the sink
sink.append(source);
// Wait for the audio to finish playing
sink.sleep_until_end();
debug!("Audio playback completed");
}
Err(e) => error!(
"Failed to decode audio format: {}",
e
),
}
}
Err(e) => {
error!("Failed to decode base64 audio data: {}", e)
}
}
}
Err(e) => error!("Failed to parse TTS response: {}", e),
}
} else {
warn!("TTS request failed with status: {}", response.status());
}
}
Err(e) => error!("Failed to send TTS request: {}", e),
}
});
}
}
}
/// Worker thread for Alibaba DashScope TTS processing with SSE streaming
fn run_ali_tts_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
use futures::StreamExt;
if let TtsMode::AliTts {
api_key,
model,
voice,
language_type,
volume,
} = mode
{
// Create a tokio runtime for HTTP requests
let rt = tokio::runtime::Runtime::new().unwrap();
let client = reqwest::Client::new();
// Initialize audio output stream (this will be reused for all audio playback)
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
while let Ok(message) = receiver.recv() {
let request = AliTtsRequest {
model: model.clone(),
input: AliTtsInput {
text: message,
voice: voice.clone(),
language_type: language_type.clone(),
},
};
// Make HTTP request to DashScope TTS service with SSE
rt.block_on(async {
match client
.post("https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation")
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.header("X-DashScope-SSE", "enable")
.json(&request)
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
// Collect all audio chunks from SSE stream
let mut audio_chunks: Vec<Vec<u8>> = Vec::new();
let mut audio_url: Option<String> = None;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk_result) = stream.next().await {
match chunk_result {
Ok(chunk) => {
// Append chunk to buffer
if let Ok(text) = std::str::from_utf8(&chunk) {
buffer.push_str(text);
// Process complete SSE events in buffer
while let Some(event_end) = buffer.find("\n\n") {
let event = buffer[..event_end].to_string();
buffer = buffer[event_end + 2..].to_string();
// Parse SSE event - look for data: lines
for line in event.lines() {
if let Some(data) = line.strip_prefix("data:") {
let data = data.trim();
if data.is_empty() || data == "[DONE]" {
continue;
}
match serde_json::from_str::<AliTtsResponse>(data) {
Ok(ali_response) => {
if let Some(output) = ali_response.output {
if let Some(audio) = output.audio {
// Check for base64 audio data (non-empty)
if let Some(ref audio_data) = audio.data {
if !audio_data.is_empty() {
match general_purpose::STANDARD.decode(audio_data) {
Ok(decoded) => {
if !decoded.is_empty() {
audio_chunks.push(decoded);
}
}
Err(e) => {
debug!("Failed to decode audio chunk: {}", e);
}
}
}
}
// Check for audio URL (final response)
if let Some(url) = audio.url {
debug!("Audio URL received: {}", url);
audio_url = Some(url);
}
}
// Check if this is the final response
if let Some(ref reason) = output.finish_reason {
if reason == "stop" {
debug!("Received final response with finish_reason: stop");
}
}
}
}
Err(e) => {
debug!("Failed to parse SSE data: {} - data: {}", e, data);
}
}
}
}
}
}
}
Err(e) => {
error!("Error reading SSE stream: {}", e);
break;
}
}
}
// Try to play audio - prefer URL download over streamed chunks
// Streamed MP3 chunks cannot be simply concatenated due to headers/frames
let audio_data = if let Some(url) = audio_url {
// Download complete audio from URL (preferred method)
info!("AliTTS: downloading audio from URL");
match client.get(&url).send().await {
Ok(audio_response) => {
if audio_response.status().is_success() {
match audio_response.bytes().await {
Ok(bytes) => {
info!("AliTTS: downloaded {} bytes", bytes.len());
Some(bytes.to_vec())
}
Err(e) => {
error!("Failed to read audio bytes: {}", e);
None
}
}
} else {
error!("Failed to download audio: {}", audio_response.status());
None
}
}
Err(e) => {
error!("Failed to fetch audio URL: {}", e);
None
}
}
} else if !audio_chunks.is_empty() {
// Fallback: try to use collected base64 chunks
// Note: This may not work correctly for MP3 format due to concatenation issues
warn!("AliTTS: No URL provided, attempting to use streamed chunks (may have decoding issues)");
let combined: Vec<u8> = audio_chunks.into_iter().flatten().collect();
info!("AliTTS: using {} bytes from streamed chunks", combined.len());
Some(combined)
} else {
warn!("No audio data or URL received from AliTTS");
None
};
// Play the audio
if let Some(audio_bytes) = audio_data {
let cursor = Cursor::new(audio_bytes);
match Decoder::new(cursor) {
Ok(source) => {
let sink = Sink::try_new(&stream_handle).unwrap();
let audio_volume = volume.unwrap_or(1.0);
sink.set_volume(audio_volume);
sink.append(source);
sink.sleep_until_end();
debug!("Audio playback completed");
}
Err(e) => error!("Failed to decode audio format: {}", e),
}
}
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
warn!("AliTTS request failed with status: {} - {}", status, body);
}
}
Err(e) => error!("Failed to send AliTTS request: {}", e),
}
});
}
}
}
/// Worker thread for command-line TTS processing
fn run_command_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
if let TtsMode::Command {
tts_command,
tts_args,
} = mode
{
while let Ok(message) = receiver.recv() {
let mut command = Command::new(&tts_command);
for arg in &tts_args {
command.arg(arg);
}
// Execute TTS command and wait for it to complete
match command.arg(&message).status() {
Ok(status) => {
if status.success() {
debug!("TTS command completed successfully");
} else {
warn!("TTS command failed with status: {}", status);
}
}
Err(e) => error!("Failed to execute TTS command: {}", e),
}
}
}
}
/// Legacy method - kept for backward compatibility
#[deprecated(note = "Use new_rest_api_default instead")]
pub fn new_default(server_url: String) -> Self {
Self::new_rest_api_default(server_url)
}
}
impl EventHandler for TtsHandler {
fn handle(&self, msg: &BiliMessage, _context: &EventContext) {
if let BiliMessage::Danmu { user, text } = msg {
let message = format!("{}说:{}", user, text);
// Send message to the queue for sequential processing
let _ = self.sender.send(message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::models::BiliMessage;
use crate::client::scheduler::EventHandler;
#[test]
fn test_tts_handler_danmu() {
// Test with a mock server URL (won't actually make requests in this test)
let handler = TtsHandler::new_rest_api_default("http://localhost:8000".to_string());
let text = "您好,欢迎来到直播间。".to_string();
let msg = BiliMessage::Danmu {
user: "测试用户".to_string(),
text: text.clone(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_tts_handler_custom_config() {
let handler = TtsHandler::new_rest_api(
"http://localhost:8000".to_string(),
Some("zh-CN-XiaoxiaoNeural".to_string()),
Some("edge".to_string()),
Some("high".to_string()),
Some("wav".to_string()),
Some(44100),
);
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "hello world".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_tts_handler_sequential_processing() {
use std::time::Duration;
// Use default configuration for testing
let handler = TtsHandler::new_rest_api_default("http://localhost:8000".to_string());
// Send multiple messages quickly
let messages = vec![
("User1", "First message"),
("User2", "Second message"),
("User3", "Third message"),
];
for (user, text) in messages {
let msg = BiliMessage::Danmu {
user: user.to_string(),
text: text.to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
// Give the worker thread some time to process the queue
std::thread::sleep(Duration::from_millis(100));
// The test passes if no panic occurs - the sequential processing
// is ensured by the worker thread design
}
#[test]
fn test_tts_handler_command_mode() {
// Test command-based TTS (cross-platform using echo)
let handler = TtsHandler::new_command("echo".to_string(), vec![]);
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "test message".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
// Give the worker thread some time to process the message
std::thread::sleep(std::time::Duration::from_millis(50));
}
#[cfg(target_os = "macos")]
#[test]
fn test_tts_handler_macos_voice() {
let handler = TtsHandler::new_command(
"say".to_string(),
vec!["-v".to_string(), "Mei-Jia".to_string()],
);
let msg = BiliMessage::Danmu {
user: "用户".to_string(),
text: "你好".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[cfg(target_os = "linux")]
#[test]
fn test_tts_handler_linux_voice() {
let handler = TtsHandler::new_command(
"espeak-ng".to_string(),
vec!["-v".to_string(), "cmn".to_string()],
);
let msg = BiliMessage::Danmu {
user: "用户".to_string(),
text: "你好".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_tts_request_serialization() {
let request = TtsRequest {
text: "Hello world".to_string(),
voice: Some("zh-CN-XiaoxiaoNeural".to_string()),
backend: Some("edge".to_string()),
quality: Some("medium".to_string()),
format: Some("wav".to_string()),
sample_rate: Some(22050),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("Hello world"));
assert!(json.contains("zh-CN-XiaoxiaoNeural"));
assert!(json.contains("edge"));
}
#[test]
fn test_tts_handler_with_volume() {
// Test with custom volume setting
let handler =
TtsHandler::new_rest_api_default_with_volume("http://localhost:8000".to_string(), 0.5);
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "volume test".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
// Test with custom configuration including volume
let handler_custom = TtsHandler::new_rest_api_with_volume(
"http://localhost:8000".to_string(),
Some("zh-CN-XiaoxiaoNeural".to_string()),
Some("edge".to_string()),
Some("high".to_string()),
Some("wav".to_string()),
Some(44100),
Some(0.8),
);
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler_custom.handle(&msg, &context);
}
#[test]
fn test_ali_tts_handler_default() {
// Test with a mock API key (won't actually make requests in this test)
let handler = TtsHandler::new_ali_tts_default("test_api_key".to_string());
let msg = BiliMessage::Danmu {
user: "测试用户".to_string(),
text: "你好".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_ali_tts_handler_custom_config() {
let handler = TtsHandler::new_ali_tts(
"test_api_key".to_string(),
"qwen3-tts-flash".to_string(),
"Chelsie".to_string(),
Some("English".to_string()),
Some(0.8),
);
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "hello world".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_ali_tts_request_serialization() {
let request = AliTtsRequest {
model: "qwen3-tts-flash".to_string(),
input: AliTtsInput {
text: "你好世界".to_string(),
voice: "Cherry".to_string(),
language_type: Some("Chinese".to_string()),
},
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("qwen3-tts-flash"));
assert!(json.contains("你好世界"));
assert!(json.contains("Cherry"));
assert!(json.contains("Chinese"));
}
}
-624
View File
@@ -1,624 +0,0 @@
// src/tui/app.rs
//! TUI application state management
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
/// Maximum number of messages to keep in buffer
const MAX_MESSAGES: usize = 1000;
/// TUI Application state
pub struct TuiApp {
/// Shared message buffer (thread-safe)
pub message_buffer: Arc<Mutex<VecDeque<String>>>,
/// Current scroll offset (0 = bottom, 1 = one line up, etc.)
pub scroll_offset: usize,
/// Whether auto-scroll is enabled
pub auto_scroll: bool,
/// Current input text
pub input: String,
/// Cursor position in input
pub cursor_position: usize,
/// Room ID being monitored
pub room_id: String,
/// Whether to quit the application
pub should_quit: bool,
/// Shared online user count (thread-safe, updated from event handler)
pub online_count: Arc<AtomicU64>,
/// Whether to show raw event messages
pub show_raw: bool,
/// Shared log buffer for capturing log messages (thread-safe)
pub log_buffer: Arc<Mutex<VecDeque<String>>>,
/// Whether to show the logs panel
pub show_logs: bool,
/// Scroll offset for logs panel (0 = bottom)
pub log_scroll_offset: usize,
/// Whether auto-scroll is enabled for logs panel
pub log_auto_scroll: bool,
/// Whether the help overlay is visible
pub show_help: bool,
/// Whether Vim-style visual selection is active
pub visual_mode: bool,
/// Frozen message snapshot used while visual mode is active
frozen_messages: Vec<String>,
/// Frozen log snapshot used while visual mode is active
frozen_logs: Vec<String>,
/// Rendered wrapped lines for the active pane
rendered_lines: Vec<String>,
/// First visible rendered line for the active pane
rendered_start_line: usize,
/// Visible height for the active pane
rendered_visible_height: usize,
/// Selection anchor in rendered line coordinates
visual_anchor: usize,
/// Current cursor in rendered line coordinates
visual_cursor: usize,
/// Current line cursor in normal pane navigation
pane_cursor: usize,
/// Whether the pane cursor has been initialized
pane_cursor_initialized: bool,
/// History of submitted input messages (oldest first)
input_history: Vec<String>,
/// Position when browsing input history; None means editing the live draft
history_index: Option<usize>,
/// Draft saved when we start browsing input history
history_draft: String,
}
impl TuiApp {
/// Create a new TUI application with shared message buffer
pub fn new(message_buffer: Arc<Mutex<VecDeque<String>>>, room_id: String) -> Self {
Self::with_online_count(message_buffer, room_id, Arc::new(AtomicU64::new(0)))
}
/// Create a new TUI application with shared message buffer and online count
pub fn with_online_count(
message_buffer: Arc<Mutex<VecDeque<String>>>,
room_id: String,
online_count: Arc<AtomicU64>,
) -> Self {
Self {
message_buffer,
scroll_offset: 0,
auto_scroll: true,
input: String::new(),
cursor_position: 0,
room_id,
should_quit: false,
online_count,
show_raw: false,
log_buffer: Arc::new(Mutex::new(VecDeque::new())),
show_logs: false,
log_scroll_offset: 0,
log_auto_scroll: true,
show_help: false,
visual_mode: false,
frozen_messages: Vec::new(),
frozen_logs: Vec::new(),
rendered_lines: Vec::new(),
rendered_start_line: 0,
rendered_visible_height: 0,
visual_anchor: 0,
visual_cursor: 0,
pane_cursor: 0,
pane_cursor_initialized: false,
input_history: Vec::new(),
history_index: None,
history_draft: String::new(),
}
}
/// Get the current online count
pub fn get_online_count(&self) -> u64 {
self.online_count.load(Ordering::Relaxed)
}
/// Update the online count (called from event handler)
pub fn set_online_count(online_count: &Arc<AtomicU64>, count: u64) {
online_count.store(count, Ordering::Relaxed);
}
/// Add a message to the buffer (called from event handler)
pub fn add_message(buffer: &Arc<Mutex<VecDeque<String>>>, message: String) {
if let Ok(mut messages) = buffer.lock() {
messages.push_back(message);
while messages.len() > MAX_MESSAGES {
messages.pop_front();
}
}
}
/// Get messages for display (returns a copy of the buffer)
pub fn get_messages(&self) -> Vec<String> {
if self.visual_mode {
return self.frozen_messages.clone();
}
if let Ok(messages) = self.message_buffer.lock() {
messages.iter().cloned().collect()
} else {
Vec::new()
}
}
/// Get the number of messages in buffer
pub fn message_count(&self) -> usize {
if let Ok(messages) = self.message_buffer.lock() {
messages.len()
} else {
0
}
}
/// Scroll up (increase offset)
pub fn scroll_up(&mut self, amount: usize) {
let max_offset = self.message_count().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + amount).min(max_offset);
if self.scroll_offset > 0 {
self.auto_scroll = false;
}
}
/// Scroll down (decrease offset)
pub fn scroll_down(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
if self.scroll_offset == 0 {
self.auto_scroll = true;
}
}
/// Scroll to bottom
pub fn scroll_to_bottom(&mut self) {
self.scroll_offset = 0;
self.auto_scroll = true;
}
/// Handle character input
pub fn enter_char(&mut self, c: char) {
let byte_pos = self.byte_index();
self.input.insert(byte_pos, c);
self.cursor_position += 1;
}
/// Delete character before cursor
pub fn delete_char(&mut self) {
if self.cursor_position > 0 {
let byte_pos = self.byte_index_at(self.cursor_position - 1);
self.input.remove(byte_pos);
self.cursor_position -= 1;
}
}
/// Move cursor left
pub fn move_cursor_left(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
}
}
/// Move cursor right
pub fn move_cursor_right(&mut self) {
let char_count = self.input.chars().count();
if self.cursor_position < char_count {
self.cursor_position += 1;
}
}
fn byte_index(&self) -> usize {
self.input
.char_indices()
.nth(self.cursor_position)
.map(|(idx, _)| idx)
.unwrap_or(self.input.len())
}
fn byte_index_at(&self, char_pos: usize) -> usize {
self.input
.char_indices()
.nth(char_pos)
.map(|(idx, _)| idx)
.unwrap_or(self.input.len())
}
/// Get current input and clear it
pub fn take_input(&mut self) -> String {
let input = self.input.clone();
self.input.clear();
self.cursor_position = 0;
if !input.is_empty() && self.input_history.last() != Some(&input) {
self.input_history.push(input.clone());
}
self.history_index = None;
self.history_draft.clear();
input
}
/// Recall the previous (older) entry from input history into the input box
pub fn history_prev(&mut self) {
if self.input_history.is_empty() {
return;
}
let new_index = match self.history_index {
None => {
self.history_draft = self.input.clone();
self.input_history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.history_index = Some(new_index);
self.input = self.input_history[new_index].clone();
self.cursor_position = self.input.chars().count();
}
/// Recall the next (newer) entry from input history, or restore the draft
pub fn history_next(&mut self) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.input_history.len() {
let new_index = index + 1;
self.history_index = Some(new_index);
self.input = self.input_history[new_index].clone();
} else {
self.history_index = None;
self.input = std::mem::take(&mut self.history_draft);
}
self.cursor_position = self.input.chars().count();
}
/// Quit the application
pub fn quit(&mut self) {
self.should_quit = true;
}
/// Toggle raw message visibility
pub fn toggle_show_raw(&mut self) {
self.show_raw = !self.show_raw;
}
/// Toggle logs panel visibility
pub fn toggle_show_logs(&mut self) {
self.show_logs = !self.show_logs;
self.show_help = false;
if self.show_logs {
self.log_scroll_offset = 0;
self.log_auto_scroll = true;
}
}
/// Toggle help overlay visibility
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
/// Get the number of log messages in buffer
pub fn log_message_count(&self) -> usize {
if let Ok(logs) = self.log_buffer.lock() {
logs.len()
} else {
0
}
}
/// Scroll logs up (increase offset)
pub fn log_scroll_up(&mut self, amount: usize) {
let max_offset = self.log_message_count().saturating_sub(1);
self.log_scroll_offset = (self.log_scroll_offset + amount).min(max_offset);
if self.log_scroll_offset > 0 {
self.log_auto_scroll = false;
}
}
/// Scroll logs down (decrease offset)
pub fn log_scroll_down(&mut self, amount: usize) {
self.log_scroll_offset = self.log_scroll_offset.saturating_sub(amount);
if self.log_scroll_offset == 0 {
self.log_auto_scroll = true;
}
}
/// Scroll logs to bottom
pub fn log_scroll_to_bottom(&mut self) {
self.log_scroll_offset = 0;
self.log_auto_scroll = true;
}
/// Set the log buffer (used to share with the TuiLogger)
pub fn set_log_buffer(&mut self, log_buffer: Arc<Mutex<VecDeque<String>>>) {
self.log_buffer = log_buffer;
}
/// Get log messages for display
pub fn get_log_messages(&self) -> Vec<String> {
if self.visual_mode {
return self.frozen_logs.clone();
}
if let Ok(logs) = self.log_buffer.lock() {
logs.iter().cloned().collect()
} else {
Vec::new()
}
}
/// Store the current wrapped-line model for the active pane
pub fn set_rendered_lines(
&mut self,
lines: Vec<String>,
start_line: usize,
visible_height: usize,
) -> usize {
let old_total_lines = self.rendered_lines.len();
let old_start_line = self.rendered_start_line;
let old_visible_height = self.rendered_visible_height.max(1);
let old_last_visible = old_start_line
.saturating_add(old_visible_height.saturating_sub(1))
.min(old_total_lines.saturating_sub(1));
let was_following_bottom = self.pane_cursor_initialized
&& old_total_lines > 0
&& self.pane_cursor >= old_last_visible
&& self.active_auto_scroll();
self.rendered_lines = lines;
self.rendered_start_line = start_line;
self.rendered_visible_height = visible_height.max(1);
if self.visual_mode {
let max_index = self.rendered_lines.len().saturating_sub(1);
self.visual_anchor = self.visual_anchor.min(max_index);
self.visual_cursor = self.visual_cursor.min(max_index);
self.sync_visual_view();
} else if self.rendered_lines.is_empty() {
self.pane_cursor = 0;
self.pane_cursor_initialized = false;
} else {
let max_index = self.rendered_lines.len() - 1;
if !self.pane_cursor_initialized || was_following_bottom {
self.pane_cursor = self.initial_visible_cursor();
self.pane_cursor_initialized = true;
} else {
self.pane_cursor = self.pane_cursor.min(max_index);
self.sync_pane_view();
}
}
self.rendered_start_line
}
/// Enter Vim-style visual selection mode
pub fn enter_visual_mode(&mut self) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.frozen_messages = self
.message_buffer
.lock()
.map(|messages| messages.iter().cloned().collect())
.unwrap_or_default();
self.frozen_logs = self
.log_buffer
.lock()
.map(|logs| logs.iter().cloned().collect())
.unwrap_or_default();
self.show_help = false;
self.visual_mode = true;
self.visual_cursor = self.pane_cursor;
self.visual_anchor = self.visual_cursor;
self.sync_visual_view();
}
/// Exit visual selection mode and resume live updates
pub fn exit_visual_mode(&mut self) {
self.visual_mode = false;
self.frozen_messages.clear();
self.frozen_logs.clear();
}
/// Toggle visual selection mode
pub fn toggle_visual_mode(&mut self) {
if self.visual_mode {
self.exit_visual_mode();
} else {
self.enter_visual_mode();
}
}
/// Move the normal pane cursor up
pub fn pane_up(&mut self, amount: usize) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.pane_cursor = self.pane_cursor.saturating_sub(amount);
self.sync_pane_view();
}
/// Move the normal pane cursor down
pub fn pane_down(&mut self, amount: usize) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
let max_index = self.rendered_lines.len().saturating_sub(1);
self.pane_cursor = (self.pane_cursor + amount).min(max_index);
self.sync_pane_view();
}
/// Jump the normal pane cursor to the first line
pub fn pane_top(&mut self) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.pane_cursor = 0;
self.sync_pane_view();
}
/// Jump the normal pane cursor to the last line
pub fn pane_bottom(&mut self) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.pane_cursor = self.rendered_lines.len() - 1;
self.sync_pane_view();
}
/// Move the visual cursor up
pub fn visual_up(&mut self, amount: usize) {
if !self.visual_mode {
return;
}
self.visual_cursor = self.visual_cursor.saturating_sub(amount);
self.sync_visual_view();
}
/// Move the visual cursor down
pub fn visual_down(&mut self, amount: usize) {
if !self.visual_mode {
return;
}
let max_index = self.rendered_lines.len().saturating_sub(1);
self.visual_cursor = (self.visual_cursor + amount).min(max_index);
self.sync_visual_view();
}
/// Jump the visual cursor to the first line
pub fn visual_top(&mut self) {
if !self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.visual_cursor = 0;
self.sync_visual_view();
}
/// Jump the visual cursor to the last line
pub fn visual_bottom(&mut self) {
if !self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.visual_cursor = self.rendered_lines.len() - 1;
self.sync_visual_view();
}
/// Get the selected rendered-line range
pub fn visual_range(&self) -> Option<(usize, usize)> {
if !self.visual_mode || self.rendered_lines.is_empty() {
return None;
}
Some((
self.visual_anchor.min(self.visual_cursor),
self.visual_anchor.max(self.visual_cursor),
))
}
/// Get the current visual cursor position
pub fn visual_cursor(&self) -> Option<usize> {
if self.visual_mode && !self.rendered_lines.is_empty() {
Some(self.visual_cursor)
} else {
None
}
}
/// Get the current pane cursor position
pub fn pane_cursor(&self) -> Option<usize> {
if !self.visual_mode && self.pane_cursor_initialized && !self.rendered_lines.is_empty() {
Some(self.pane_cursor)
} else {
None
}
}
/// Return the selected text from the current visual range
pub fn selected_text(&self) -> Option<String> {
let (start, end) = self.visual_range()?;
Some(self.rendered_lines[start..=end].join("\n"))
}
fn initial_visible_cursor(&self) -> usize {
if self.rendered_lines.is_empty() {
return 0;
}
let last_visible = self
.rendered_start_line
.saturating_add(self.rendered_visible_height.saturating_sub(1));
last_visible.min(self.rendered_lines.len() - 1)
}
fn sync_pane_view(&mut self) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
let total_lines = self.rendered_lines.len();
let visible_height = self.rendered_visible_height.max(1).min(total_lines);
let max_start = total_lines.saturating_sub(visible_height);
let mut start_line = self.rendered_start_line.min(max_start);
if self.pane_cursor < start_line {
start_line = self.pane_cursor;
} else if self.pane_cursor >= start_line + visible_height {
start_line = self.pane_cursor + 1 - visible_height;
}
self.rendered_start_line = start_line;
let scroll_offset = total_lines.saturating_sub(visible_height + start_line);
if self.show_logs {
self.log_scroll_offset = scroll_offset;
self.log_auto_scroll = scroll_offset == 0;
} else {
self.scroll_offset = scroll_offset;
self.auto_scroll = scroll_offset == 0;
}
}
fn active_auto_scroll(&self) -> bool {
if self.show_logs {
self.log_auto_scroll
} else {
self.auto_scroll
}
}
fn sync_visual_view(&mut self) {
if !self.visual_mode || self.rendered_lines.is_empty() {
return;
}
let total_lines = self.rendered_lines.len();
let visible_height = self.rendered_visible_height.max(1).min(total_lines);
let max_start = total_lines.saturating_sub(visible_height);
let mut start_line = self.rendered_start_line.min(max_start);
if self.visual_cursor < start_line {
start_line = self.visual_cursor;
} else if self.visual_cursor >= start_line + visible_height {
start_line = self.visual_cursor + 1 - visible_height;
}
self.rendered_start_line = start_line;
let scroll_offset = total_lines.saturating_sub(visible_height + start_line);
if self.show_logs {
self.log_scroll_offset = scroll_offset;
self.log_auto_scroll = scroll_offset == 0;
} else {
self.scroll_offset = scroll_offset;
self.auto_scroll = scroll_offset == 0;
}
}
}
-254
View File
@@ -1,254 +0,0 @@
// src/tui/event.rs
//! Event handling and main TUI loop
use crate::tui::app::TuiApp;
use crate::tui::ui;
use arboard::Clipboard;
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::time::Duration;
/// Run the TUI application
pub fn run_tui<F>(mut app: TuiApp, mut on_message: F) -> io::Result<()>
where
F: FnMut(String),
{
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_app(&mut terminal, &mut app, &mut on_message);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_app<F>(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut TuiApp,
on_message: &mut F,
) -> io::Result<()>
where
F: FnMut(String),
{
let mut needs_redraw = true;
let mut clipboard = Clipboard::new().ok();
let mut last_message_count = app.message_count();
let mut last_log_count = app.log_message_count();
let mut last_online_count = app.get_online_count();
loop {
let message_count = app.message_count();
let log_count = app.log_message_count();
let online_count = app.get_online_count();
if !app.visual_mode
&& (message_count != last_message_count
|| log_count != last_log_count
|| online_count != last_online_count)
{
needs_redraw = true;
}
last_message_count = message_count;
last_log_count = log_count;
last_online_count = online_count;
if needs_redraw {
terminal.draw(|f| ui::render(f, app))?;
needs_redraw = false;
}
if event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.quit();
needs_redraw = true;
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.toggle_visual_mode();
needs_redraw = true;
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !app.visual_mode {
app.toggle_help();
needs_redraw = true;
}
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !app.visual_mode {
app.toggle_show_raw();
needs_redraw = true;
}
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !app.visual_mode {
app.toggle_show_logs();
needs_redraw = true;
}
}
KeyCode::Esc => {
if app.visual_mode {
app.exit_visual_mode();
} else if app.show_help {
app.show_help = false;
} else if app.show_logs {
app.toggle_show_logs();
} else {
app.quit();
}
needs_redraw = true;
}
_ if app.show_help => {}
_ if app.visual_mode => {
match key.code {
KeyCode::Char('k') | KeyCode::Up => app.visual_up(1),
KeyCode::Char('j') | KeyCode::Down => app.visual_down(1),
KeyCode::PageUp => app.visual_up(10),
KeyCode::PageDown => app.visual_down(10),
KeyCode::Char('g') | KeyCode::Home => app.visual_top(),
KeyCode::Char('G') | KeyCode::End => app.visual_bottom(),
KeyCode::Char('y') => {
copy_selection(app, clipboard.as_mut())?;
app.exit_visual_mode();
}
_ => {}
}
needs_redraw = true;
}
_ if app.show_logs => match key.code {
KeyCode::Up => {
app.pane_up(1);
needs_redraw = true;
}
KeyCode::Down => {
app.pane_down(1);
needs_redraw = true;
}
KeyCode::PageUp => {
app.pane_up(10);
needs_redraw = true;
}
KeyCode::PageDown => {
app.pane_down(10);
needs_redraw = true;
}
KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pane_top();
needs_redraw = true;
}
KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pane_bottom();
needs_redraw = true;
}
KeyCode::Home => {
app.pane_top();
needs_redraw = true;
}
KeyCode::End => {
app.pane_bottom();
needs_redraw = true;
}
_ => {}
},
KeyCode::Char(c) => {
app.enter_char(c);
needs_redraw = true;
}
KeyCode::Backspace => {
app.delete_char();
needs_redraw = true;
}
KeyCode::Enter => {
let input = app.take_input();
if !input.is_empty() {
if input == "/quit" || input == "/exit" {
app.quit();
} else {
on_message(input);
}
}
needs_redraw = true;
}
KeyCode::Up => {
app.history_prev();
needs_redraw = true;
}
KeyCode::Down => {
app.history_next();
needs_redraw = true;
}
KeyCode::Left => {
app.move_cursor_left();
needs_redraw = true;
}
KeyCode::Right => {
app.move_cursor_right();
needs_redraw = true;
}
KeyCode::PageUp => {
app.pane_up(10);
needs_redraw = true;
}
KeyCode::PageDown => {
app.pane_down(10);
needs_redraw = true;
}
KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pane_top();
needs_redraw = true;
}
KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pane_bottom();
needs_redraw = true;
}
KeyCode::Home => {
app.cursor_position = 0;
needs_redraw = true;
}
KeyCode::End => {
app.cursor_position = app.input.chars().count();
needs_redraw = true;
}
_ => {}
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn copy_selection(app: &TuiApp, clipboard: Option<&mut Clipboard>) -> io::Result<()> {
let Some(text) = app.selected_text() else {
return Ok(());
};
let Some(clipboard) = clipboard else {
return Err(io::Error::other("clipboard is unavailable"));
};
clipboard.set_text(text).map_err(io::Error::other)
}
-73
View File
@@ -1,73 +0,0 @@
// src/tui/logger.rs
//! Custom logger that captures log messages into a shared buffer for TUI display
use log::{Log, Metadata, Record};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Instant;
/// Maximum number of log messages to keep in buffer
const MAX_LOG_MESSAGES: usize = 1000;
/// A logger that writes log messages to a shared buffer for TUI display.
/// It also optionally forwards to env_logger for file/stderr output.
pub struct TuiLogger {
buffer: Arc<Mutex<VecDeque<String>>>,
level: log::LevelFilter,
start_time: Instant,
}
impl TuiLogger {
/// Create a new TuiLogger with the given shared buffer and level filter.
pub fn new(buffer: Arc<Mutex<VecDeque<String>>>, level: log::LevelFilter) -> Self {
Self {
buffer,
level,
start_time: Instant::now(),
}
}
/// Initialize this logger as the global logger.
/// Returns the shared buffer so it can be passed to TuiApp.
pub fn init(level: log::LevelFilter) -> Arc<Mutex<VecDeque<String>>> {
let buffer = Arc::new(Mutex::new(VecDeque::new()));
let logger = TuiLogger::new(Arc::clone(&buffer), level);
log::set_boxed_logger(Box::new(logger)).expect("Failed to set TuiLogger");
log::set_max_level(level);
buffer
}
}
impl Log for TuiLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= self.level
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
let elapsed = self.start_time.elapsed();
let secs = elapsed.as_secs();
let mins = secs / 60;
let hours = mins / 60;
let timestamp = format!("{:02}:{:02}:{:02}", hours, mins % 60, secs % 60);
let msg = format!(
"[{}] [{}] [{}] {}",
timestamp,
record.level(),
record.target(),
record.args()
);
if let Ok(mut buf) = self.buffer.lock() {
buf.push_back(msg);
while buf.len() > MAX_LOG_MESSAGES {
buf.pop_front();
}
}
}
fn flush(&self) {}
}
-11
View File
@@ -1,11 +0,0 @@
// src/tui/mod.rs
//! TUI module for displaying messages and handling user input
pub mod app;
pub mod event;
pub mod logger;
pub mod ui;
pub use app::TuiApp;
pub use event::run_tui;
pub use logger::TuiLogger;
-355
View File
@@ -1,355 +0,0 @@
// src/tui/ui.rs
//! UI rendering logic for the TUI
use crate::tui::app::TuiApp;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame,
};
use unicode_width::UnicodeWidthStr;
pub fn render(f: &mut Frame, app: &mut TuiApp) {
if app.show_logs {
render_logs_panel(f, app, f.area());
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(90), Constraint::Percentage(10)])
.split(f.area());
render_message_list(f, app, chunks[0]);
render_input_box(f, app, chunks[1]);
}
if app.show_help {
render_help_overlay(f, app);
}
}
fn render_message_list(f: &mut Frame, app: &mut TuiApp, area: Rect) {
let messages = app.get_messages();
let inner_width = area.width.saturating_sub(2) as usize;
let visible_height = area.height.saturating_sub(2) as usize;
let mut all_lines = Vec::new();
for msg in &messages {
if !app.show_raw && msg.starts_with("[Raw]") {
continue;
}
let style = get_message_style(msg);
for line_text in wrap_text(msg, inner_width) {
all_lines.push((line_text, style));
}
}
let total_lines = all_lines.len();
let start_line = if app.auto_scroll {
total_lines.saturating_sub(visible_height)
} else {
total_lines.saturating_sub(visible_height + app.scroll_offset)
};
let start_line = app.set_rendered_lines(
all_lines.iter().map(|(text, _)| text.clone()).collect(),
start_line,
visible_height,
);
let visible_lines = all_lines
.into_iter()
.enumerate()
.skip(start_line)
.take(visible_height)
.map(|(idx, (line_text, style))| {
Line::from(Span::styled(
line_text,
style_for_line(app, idx, style, Color::Blue),
))
})
.collect::<Vec<_>>();
let scroll_indicator = if app.visual_mode {
"VISUAL | j/k move | g/G jump | y copy | Esc cancel"
} else if app.pane_cursor().is_some() {
"Up/Down history | PgUp/Dn scroll | Ctrl+Y select"
} else if app.auto_scroll {
"Auto-scroll"
} else {
"Paused - PgUp/Dn to scroll"
};
let online_count = app.get_online_count();
let online_display = if online_count > 0 {
format!(" | Online: {}", online_count)
} else {
String::new()
};
let raw_indicator = if app.show_raw { "Raw:ON" } else { "Raw:OFF" };
let title = format!(
" Room {}{} | {} | {} ",
app.room_id, online_display, scroll_indicator, raw_indicator
);
let paragraph = Paragraph::new(visible_lines)
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
}
fn get_message_style(msg: &str) -> Style {
if msg.starts_with("[Danmu]") {
Style::default().fg(Color::Cyan)
} else if msg.starts_with("[Gift]") {
Style::default().fg(Color::Yellow)
} else if msg.starts_with("[Raw]") {
Style::default().fg(Color::Magenta)
} else if msg.starts_with("[Unsupported") {
Style::default().fg(Color::DarkGray)
} else if msg.starts_with("[System]") {
Style::default().fg(Color::Green)
} else {
Style::default()
}
}
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for ch in text.chars() {
let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + char_width > max_width && !current_line.is_empty() {
lines.push(current_line);
current_line = String::new();
current_width = 0;
}
current_line.push(ch);
current_width += char_width;
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn render_logs_panel(f: &mut Frame, app: &mut TuiApp, area: Rect) {
let logs = app.get_log_messages();
let inner_width = area.width.saturating_sub(2) as usize;
let visible_height = area.height.saturating_sub(2) as usize;
let mut all_lines = Vec::new();
for log_msg in &logs {
let style = get_log_style(log_msg);
for line_text in wrap_text(log_msg, inner_width) {
all_lines.push((line_text, style));
}
}
let total_lines = all_lines.len();
let start_line = if app.log_auto_scroll {
total_lines.saturating_sub(visible_height)
} else {
total_lines.saturating_sub(visible_height + app.log_scroll_offset)
};
let start_line = app.set_rendered_lines(
all_lines.iter().map(|(text, _)| text.clone()).collect(),
start_line,
visible_height,
);
let visible_lines = all_lines
.into_iter()
.enumerate()
.skip(start_line)
.take(visible_height)
.map(|(idx, (line_text, style))| {
Line::from(Span::styled(
line_text,
style_for_line(app, idx, style, Color::LightBlue),
))
})
.collect::<Vec<_>>();
let scroll_indicator = if app.visual_mode {
"VISUAL | j/k move | g/G jump | y copy | Esc cancel"
} else if app.pane_cursor().is_some() {
"CURSOR | Up/Down move | Ctrl+Y visual from cursor"
} else if app.log_auto_scroll {
"Auto-scroll"
} else {
"Paused"
};
let title = format!(
" Logs ({} entries) | {} | Ctrl+Y: visual | Ctrl+H: help | Ctrl+L: close ",
logs.len(),
scroll_indicator
);
let paragraph = Paragraph::new(visible_lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::LightBlue)),
)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
}
fn get_log_style(msg: &str) -> Style {
if msg.contains("[ERROR]") {
Style::default().fg(Color::Red)
} else if msg.contains("[WARN]") {
Style::default().fg(Color::Yellow)
} else if msg.contains("[INFO]") {
Style::default().fg(Color::Green)
} else if msg.contains("[DEBUG]") || msg.contains("[TRACE]") {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
}
}
fn render_input_box(f: &mut Frame, app: &TuiApp, area: Rect) {
let input_text = format!("> {}", app.input);
let paragraph = Paragraph::new(input_text.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Input (Up/Down: history | Ctrl+Y: visual | Ctrl+H: help | Ctrl+C: exit) ")
.border_style(Style::default().fg(Color::Green)),
)
.style(Style::default());
f.render_widget(paragraph, area);
let text_before_cursor: String = app.input.chars().take(app.cursor_position).collect();
let display_width = text_before_cursor.width();
let cursor_x = area.x + 1 + 2 + display_width as u16;
let cursor_y = area.y + 1;
if !app.visual_mode && cursor_x < area.x + area.width.saturating_sub(1) {
f.set_cursor_position((cursor_x, cursor_y));
}
}
fn render_help_overlay(f: &mut Frame, app: &TuiApp) {
let area = centered_rect(72, 72, f.area());
let lines = if app.show_logs {
vec![
Line::from("Key Map"),
Line::from(""),
Line::from("Ctrl+H Toggle this help"),
Line::from("Up/Down Pick start line"),
Line::from("Ctrl+Y Enter visual mode from cursor"),
Line::from("j/k Move visual selection"),
Line::from("g / G Jump to top or bottom"),
Line::from("y Copy selected lines"),
Line::from("Esc Close help, cancel visual, or close logs"),
Line::from("Up/Down Scroll logs normally"),
Line::from("PgUp/Dn Scroll faster"),
Line::from("Home/End Jump to top or bottom"),
Line::from("Ctrl+C Exit app"),
]
} else {
vec![
Line::from("Key Map"),
Line::from(""),
Line::from("Enter Send input"),
Line::from("Up/Down Browse sent-input history"),
Line::from("Ctrl+H Toggle this help"),
Line::from("Ctrl+Y Enter visual mode (scroll/select)"),
Line::from("j/k Move visual selection"),
Line::from("g / G Jump to top or bottom"),
Line::from("y Copy selected lines"),
Line::from("Ctrl+R Toggle raw messages"),
Line::from("Ctrl+L Toggle logs panel"),
Line::from("PgUp/Dn Scroll messages"),
Line::from("Left/Right Move input cursor"),
Line::from("Home/End Move input cursor"),
Line::from("Ctrl+Home Jump to top"),
Line::from("Ctrl+End Jump to bottom"),
Line::from("Esc Close help, cancel visual, or quit"),
Line::from("Ctrl+C Exit app"),
]
};
let title = if app.show_logs {
" Help - Logs "
} else {
" Help - Messages "
};
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false });
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
fn style_for_line(app: &TuiApp, idx: usize, base: Style, cursor_color: Color) -> Style {
if let Some((start, end)) = app.visual_range() {
if Some(idx) == app.visual_cursor() {
return base.bg(cursor_color).fg(Color::Black);
}
if (start..=end).contains(&idx) {
return base.bg(Color::DarkGray).fg(Color::White);
}
}
if Some(idx) == app.pane_cursor() {
return base.bg(Color::Rgb(40, 40, 40)).fg(Color::White);
}
base
}