Compare commits

...

27 Commits

Author SHA1 Message Date
Xinrea
3813528f50 feat: implement virtual scrolling for danmu list 2025-06-24 23:36:15 +08:00
Xinrea
e3bb014644 fix: crashed by loading large amount of cover data 2025-06-24 00:23:56 +08:00
Xinrea
76a7afde76 bump version to 2.7.0 2025-06-22 21:21:09 +08:00
Xinrea
1184f9f3f5 fix: auto close video preview after deleting 2025-06-22 21:20:04 +08:00
Xinrea
b754f8938f chore: clean up code 2025-06-22 21:15:03 +08:00
Xinrea
6b30ff04b7 feat: stt services support (#128)
* feat: add task page

* feat: online whisper service support (close #126)

* fix: blocking in recorder main loop
2025-06-22 21:06:35 +08:00
Xinrea
1c40acca63 feat: clip manage (#127)
* feat: ignore pre-release on version check

* feat: new clip-manage page

* chore: rename custom scrollbar class
2025-06-22 00:32:39 +08:00
Xinrea
a5a7a8afaf fix: douyin danmu 2025-06-20 00:50:37 +08:00
Xinrea
583ac13a37 chore: update dependencies 2025-06-19 23:35:11 +08:00
Xinrea
3e58972072 fix: recorder re-added when removing 2025-06-19 00:31:11 +08:00
Xinrea
f15aa27727 fix: release build with cuda cache error 2025-06-19 00:29:22 +08:00
Xinrea
2581014dbd fix: dockerfile 2025-06-19 00:18:16 +08:00
Xinrea
baaaa1b57e feat: randomly use multiple accounts (close #123) 2025-06-19 00:11:01 +08:00
Xinrea
160fbb3590 feat: collect frontend log to backend (close #122) 2025-06-18 22:49:07 +08:00
Xinrea
6f3253678c feat: readonly mode (#121)
* feat: readonly mode (close #112)

* feat: check free-space when clipping
2025-06-18 01:09:58 +08:00
Xinrea
563ad66243 feat: danmu local offset settings (close #115) (#120) 2025-06-16 23:38:24 +08:00
Xinrea
a8d002cc53 fix: deadlock removing douyin-recorder 2025-06-13 00:29:29 +08:00
Xinrea
0615410fa4 fix: deadlock removing bili-recorder 2025-06-13 00:15:59 +08:00
Xinrea
fc98e065f8 fix: status-check interval not work for bilibili recorder 2025-06-12 23:35:24 +08:00
Xinrea
66f671ffa0 feat: douyin danmu (#119)
* feat: migrate to uniformed interface for danmu stream

* feat: douyin danmu support (close #113)

* chore: fix typo

* fix: loop-decompress body
2025-06-12 01:00:33 +08:00
Xinrea
69a35af456 fix: recorder adding back when removing (close #114)
Add a to_remove-set to filter recorders that are still in removing-stage,
so that monitor-thread wouldn't add them back.
2025-06-08 10:37:04 +08:00
Xinrea
e462bd0b4c feat: simplified room control mechanism (close #111) 2025-06-08 10:22:20 +08:00
Xinrea
ae6483427f bump version to 2.6.0 2025-06-03 21:53:14 +08:00
Xinrea
ad97677104 feat: configuration for status check interval 2025-05-30 00:32:30 +08:00
Xinrea
996d15ef25 chore: adjust ffmpeg log level & clean up code 2025-05-29 01:18:27 +08:00
Xinrea
06de32ffe7 bump version to 2.5.9 2025-05-27 15:13:45 +08:00
Xinrea
dd43074e46 fix: danmu api missing param 2025-05-27 15:13:22 +08:00
85 changed files with 21791 additions and 2490 deletions

View File

@@ -57,7 +57,7 @@ jobs:
- name: Install CUDA toolkit (Windows CUDA only)
if: matrix.platform == 'windows-latest' && matrix.features == 'cuda'
uses: Jimver/cuda-toolkit@master
uses: Jimver/cuda-toolkit@v0.2.24
- name: Rust cache
uses: swatinem/rust-cache@v2

View File

@@ -1,6 +1,7 @@
[[language]]
name = "rust"
auto-format = true
rulers = []
[[language]]
name = "svelte"

View File

@@ -42,6 +42,7 @@ RUN apt-get update && apt-get install -y \
# Copy Rust project files
COPY src-tauri/Cargo.toml src-tauri/Cargo.lock ./src-tauri/
COPY src-tauri/src ./src-tauri/src
COPY src-tauri/crates ./src-tauri/crates
# Build Rust backend
WORKDIR /app/src-tauri

13
index_clip.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>切片窗口</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main_clip.ts"></script>
</body>
</html>

63
index_live.html Normal file
View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="zh-cn" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="shaka-player/controls.min.css" />
<link rel="stylesheet" href="shaka-player/youtube-theme.css" />
<script src="shaka-player/shaka-player.ui.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/main_live.ts"></script>
<style>
input[type="range"]::-webkit-slider-thumb {
width: 12px;
/* 设置滑块按钮宽度 */
height: 12px;
/* 设置滑块按钮高度 */
border-radius: 50%;
/* 设置为圆形 */
}
html {
scrollbar-face-color: #646464;
scrollbar-base-color: #646464;
scrollbar-3dlight-color: #646464;
scrollbar-highlight-color: #646464;
scrollbar-track-color: #000;
scrollbar-arrow-color: #000;
scrollbar-shadow-color: #646464;
}
::-webkit-scrollbar {
width: 8px;
height: 3px;
}
::-webkit-scrollbar-button {
background-color: #666;
}
::-webkit-scrollbar-track {
background-color: #646464;
}
::-webkit-scrollbar-track-piece {
background-color: #000;
}
::-webkit-scrollbar-thumb {
height: 50px;
background-color: #666;
border-radius: 3px;
}
::-webkit-scrollbar-corner {
background-color: #646464;
}
</style>
</body>
</html>

View File

@@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cn" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="shaka-player/controls.min.css" />
<link rel="stylesheet" href="shaka-player/youtube-theme.css" />
<script src="shaka-player/shaka-player.ui.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/live_main.ts"></script>
<style>
input[type="range"]::-webkit-slider-thumb {
width: 12px;
/* 设置滑块按钮宽度 */
height: 12px;
/* 设置滑块按钮高度 */
border-radius: 50%;
/* 设置为圆形 */
}
html {
scrollbar-face-color: #646464;
scrollbar-base-color: #646464;
scrollbar-3dlight-color: #646464;
scrollbar-highlight-color: #646464;
scrollbar-track-color: #000;
scrollbar-arrow-color: #000;
scrollbar-shadow-color: #646464;
}
::-webkit-scrollbar {
width: 8px;
height: 3px;
}
::-webkit-scrollbar-button {
background-color: #666;
}
::-webkit-scrollbar-track {
background-color: #646464;
}
::-webkit-scrollbar-track-piece {
background-color: #000;
}
::-webkit-scrollbar-thumb {
height: 50px;
background-color: #666;
border-radius: 3px;
}
::-webkit-scrollbar-corner {
background-color: #646464;
}
</style>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "bili-shadowreplay",
"private": true,
"version": "2.5.8",
"version": "2.7.2",
"type": "module",
"scripts": {
"dev": "vite",

1819
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
[workspace]
members = ["crates/danmu_stream"]
resolver = "2"
[package]
name = "bili-shadowreplay"
version = "1.0.0"
@@ -10,8 +14,9 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
danmu_stream = { path = "crates/danmu_stream" }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["blocking", "json"] }
reqwest = { version = "0.11", features = ["blocking", "json", "multipart"] }
serde_derive = "1.0.158"
serde = "1.0.158"
sysinfo = "0.32.0"
@@ -21,7 +26,6 @@ async-ffmpeg-sidecar = "0.0.1"
chrono = { version = "0.4.24", features = ["serde"] }
toml = "0.7.3"
custom_error = "1.9.2"
felgens = { git = "https://github.com/Xinrea/felgens.git", tag = "v0.4.5" }
regex = "1.7.3"
tokio = { version = "1.27.0", features = ["process"] }
platform-dirs = "0.3.0"
@@ -41,7 +45,7 @@ whisper-rs = "0.14.2"
hound = "3.5.1"
uuid = { version = "1.4", features = ["v4"] }
axum = { version = "0.7", features = ["macros"] }
tower-http = { version = "0.5", features = ["cors", "fs", "limit"] }
tower-http = { version = "0.5", features = ["cors", "fs"] }
futures-core = "0.3"
futures = "0.3"
tokio-util = { version = "0.7", features = ["io"] }

View File

@@ -2,10 +2,7 @@
"identifier": "migrated",
"description": "permissions that were migrated from v1",
"local": true,
"windows": [
"main",
"Live*"
],
"windows": ["main", "Live*", "Clip*"],
"permissions": [
"core:default",
"fs:allow-read-file",
@@ -19,9 +16,7 @@
"fs:allow-exists",
{
"identifier": "fs:scope",
"allow": [
"**"
]
"allow": ["**"]
},
"core:window:default",
"core:window:allow-start-dragging",
@@ -72,4 +67,4 @@
"os:default",
"dialog:default"
]
}
}

View File

@@ -5,8 +5,10 @@ live_end_notify = true
clip_notify = true
post_notify = true
auto_subtitle = false
subtitle_generator_type = "whisper_online"
whisper_model = "./whisper_model.bin"
whisper_prompt = "这是一段中文 你们好"
openai_api_key = ""
clip_name_format = "[{room_id}][{live_id}][{title}][{created_at}].mp4"
[auto_generate]

View File

@@ -0,0 +1,43 @@
[package]
name = "danmu_stream"
version = "0.1.0"
edition = "2021"
[lib]
name = "danmu_stream"
path = "src/lib.rs"
[[example]]
name = "douyin"
path = "examples/douyin.rs"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
futures-util = "0.3"
prost = "0.12"
chrono = "0.4"
log = "0.4"
env_logger = "0.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json"] }
url = "2.4"
md5 = "0.7"
regex = "1.9"
deno_core = "0.242.0"
pct-str = "2.0.0"
custom_error = "1.9.2"
flate2 = "1.0"
scroll = "0.13.0"
scroll_derive = "0.13.0"
brotli = "8.0.1"
http = "1.0"
rand = "0.9.1"
urlencoding = "2.1.3"
gzip = "0.1.2"
hex = "0.4.3"
async-trait = "0.1.88"
[build-dependencies]
tonic-build = "0.10"

View File

View File

@@ -0,0 +1,40 @@
use std::{sync::Arc, time::Duration};
use danmu_stream::{danmu_stream::DanmuStream, provider::ProviderType, DanmuMessageType};
use tokio::time::sleep;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
env_logger::init();
// Replace these with actual values
let room_id = 7514298567821937427; // Replace with actual Douyin room_id. When live starts, the room_id will be generated, so it's more like a live_id.
let cookie = "your_cookie";
let stream = Arc::new(DanmuStream::new(ProviderType::Douyin, cookie, room_id).await?);
log::info!("Start to receive danmu messages");
let _ = stream.start().await;
let stream_clone = stream.clone();
tokio::spawn(async move {
loop {
if let Ok(Some(msg)) = stream_clone.recv().await {
match msg {
DanmuMessageType::DanmuMessage(danmu) => {
log::info!("Received danmu message: {:?}", danmu.message);
}
}
} else {
log::info!("Channel closed");
break;
}
}
});
sleep(Duration::from_secs(10)).await;
stream.stop().await?;
Ok(())
}

View File

@@ -0,0 +1,51 @@
use std::sync::Arc;
use crate::{
provider::{new, DanmuProvider, ProviderType},
DanmuMessageType, DanmuStreamError,
};
use tokio::sync::{mpsc, RwLock};
#[derive(Clone)]
pub struct DanmuStream {
pub provider_type: ProviderType,
pub identifier: String,
pub room_id: u64,
pub provider: Arc<RwLock<Box<dyn DanmuProvider>>>,
tx: mpsc::UnboundedSender<DanmuMessageType>,
rx: Arc<RwLock<mpsc::UnboundedReceiver<DanmuMessageType>>>,
}
impl DanmuStream {
pub async fn new(
provider_type: ProviderType,
identifier: &str,
room_id: u64,
) -> Result<Self, DanmuStreamError> {
let (tx, rx) = mpsc::unbounded_channel();
let provider = new(provider_type, identifier, room_id).await?;
Ok(Self {
provider_type,
identifier: identifier.to_string(),
room_id,
provider: Arc::new(RwLock::new(provider)),
tx,
rx: Arc::new(RwLock::new(rx)),
})
}
pub async fn start(&self) -> Result<(), DanmuStreamError> {
self.provider.write().await.start(self.tx.clone()).await
}
pub async fn stop(&self) -> Result<(), DanmuStreamError> {
self.provider.write().await.stop().await?;
// close channel
self.rx.write().await.close();
Ok(())
}
pub async fn recv(&self) -> Result<Option<DanmuMessageType>, DanmuStreamError> {
Ok(self.rx.write().await.recv().await)
}
}

View File

@@ -0,0 +1,51 @@
use std::time::Duration;
use crate::DanmuStreamError;
use reqwest::header::HeaderMap;
impl From<reqwest::Error> for DanmuStreamError {
fn from(value: reqwest::Error) -> Self {
Self::HttpError { err: value }
}
}
impl From<url::ParseError> for DanmuStreamError {
fn from(value: url::ParseError) -> Self {
Self::ParseError { err: value }
}
}
pub struct ApiClient {
client: reqwest::Client,
header: HeaderMap,
}
impl ApiClient {
pub fn new(cookies: &str) -> Self {
let mut header = HeaderMap::new();
header.insert("cookie", cookies.parse().unwrap());
Self {
client: reqwest::Client::new(),
header,
}
}
pub async fn get(
&self,
url: &str,
query: Option<&[(&str, &str)]>,
) -> Result<reqwest::Response, DanmuStreamError> {
let resp = self
.client
.get(url)
.query(query.unwrap_or_default())
.headers(self.header.clone())
.timeout(Duration::from_secs(10))
.send()
.await?
.error_for_status()?;
Ok(resp)
}
}

View File

@@ -0,0 +1,30 @@
pub mod danmu_stream;
mod http_client;
pub mod provider;
use custom_error::custom_error;
custom_error! {pub DanmuStreamError
HttpError {err: reqwest::Error} = "HttpError {err}",
ParseError {err: url::ParseError} = "ParseError {err}",
WebsocketError {err: String } = "WebsocketError {err}",
PackError {err: String} = "PackError {err}",
UnsupportProto {proto: u16} = "UnsupportProto {proto}",
MessageParseError {err: String} = "MessageParseError {err}",
InvalidIdentifier {err: String} = "InvalidIdentifier {err}"
}
pub enum DanmuMessageType {
DanmuMessage(DanmuMessage),
}
#[derive(Debug, Clone)]
pub struct DanmuMessage {
pub room_id: u64,
pub user_id: u64,
pub user_name: String,
pub message: String,
pub color: u32,
/// timestamp in milliseconds
pub timestamp: i64,
}

View File

@@ -0,0 +1,72 @@
mod bilibili;
mod douyin;
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::{
provider::bilibili::BiliDanmu, provider::douyin::DouyinDanmu, DanmuMessageType,
DanmuStreamError,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderType {
BiliBili,
Douyin,
}
#[async_trait]
pub trait DanmuProvider: Send + Sync {
async fn new(identifier: &str, room_id: u64) -> Result<Self, DanmuStreamError>
where
Self: Sized;
async fn start(
&self,
tx: mpsc::UnboundedSender<DanmuMessageType>,
) -> Result<(), DanmuStreamError>;
async fn stop(&self) -> Result<(), DanmuStreamError>;
}
/// Creates a new danmu stream provider for the specified platform.
///
/// This function initializes and starts a danmu stream provider based on the specified platform type.
/// The provider will fetch danmu messages and send them through the provided channel.
///
/// # Arguments
///
/// * `tx` - An unbounded sender channel that will receive danmu messages
/// * `provider_type` - The type of platform to fetch danmu from (BiliBili or Douyin)
/// * `identifier` - User validation information (e.g., cookies) required by the platform
/// * `room_id` - The unique identifier of the room/channel to fetch danmu from. Notice that douyin room_id is more like a live_id, it changes every time the live starts.
///
/// # Returns
///
/// Returns `Result<(), DanmmuStreamError>` where:
/// * `Ok(())` indicates successful initialization and start of the provider, only return after disconnect
/// * `Err(DanmmuStreamError)` indicates an error occurred during initialization or startup
///
/// # Examples
///
/// ```rust
/// use tokio::sync::mpsc;
/// let (tx, mut rx) = mpsc::unbounded_channel();
/// new(tx, ProviderType::BiliBili, "your_cookie", 123456).await?;
/// ```
pub async fn new(
provider_type: ProviderType,
identifier: &str,
room_id: u64,
) -> Result<Box<dyn DanmuProvider>, DanmuStreamError> {
match provider_type {
ProviderType::BiliBili => {
let bili = BiliDanmu::new(identifier, room_id).await?;
Ok(Box::new(bili))
}
ProviderType::Douyin => {
let douyin = DouyinDanmu::new(identifier, room_id).await?;
Ok(Box::new(douyin))
}
}
}

View File

@@ -0,0 +1,436 @@
mod dannmu_msg;
mod interact_word;
mod pack;
mod send_gift;
mod stream;
mod super_chat;
use std::{sync::Arc, time::SystemTime};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt, TryStreamExt};
use log::{error, info};
use pct_str::{PctString, URIReserved};
use regex::Regex;
use serde::{Deserialize, Serialize};
use tokio::{
sync::{mpsc, RwLock},
time::{sleep, Duration},
};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use crate::{
http_client::ApiClient,
provider::{DanmuMessageType, DanmuProvider},
DanmuStreamError,
};
type WsReadType = futures_util::stream::SplitStream<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
>;
type WsWriteType = futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
Message,
>;
pub struct BiliDanmu {
client: ApiClient,
room_id: u64,
user_id: u64,
stop: Arc<RwLock<bool>>,
write: Arc<RwLock<Option<WsWriteType>>>,
}
#[async_trait]
impl DanmuProvider for BiliDanmu {
async fn new(cookie: &str, room_id: u64) -> Result<Self, DanmuStreamError> {
// find DedeUserID=<user_id> in cookie str
let user_id = BiliDanmu::parse_user_id(cookie)?;
let client = ApiClient::new(cookie);
Ok(Self {
client,
user_id,
room_id,
stop: Arc::new(RwLock::new(false)),
write: Arc::new(RwLock::new(None)),
})
}
async fn start(
&self,
tx: mpsc::UnboundedSender<DanmuMessageType>,
) -> Result<(), DanmuStreamError> {
let mut retry_count = 0;
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY: Duration = Duration::from_secs(5);
info!(
"Bilibili WebSocket connection started, room_id: {}",
self.room_id
);
loop {
if *self.stop.read().await {
break;
}
match self.connect_and_handle(tx.clone()).await {
Ok(_) => {
info!("Bilibili WebSocket connection closed normally");
break;
}
Err(e) => {
error!("Bilibili WebSocket connection error: {}", e);
retry_count += 1;
if retry_count >= MAX_RETRIES {
return Err(DanmuStreamError::WebsocketError {
err: format!("Failed to connect after {} retries", MAX_RETRIES),
});
}
info!(
"Retrying connection in {} seconds... (Attempt {}/{})",
RETRY_DELAY.as_secs(),
retry_count,
MAX_RETRIES
);
tokio::time::sleep(RETRY_DELAY).await;
}
}
}
Ok(())
}
async fn stop(&self) -> Result<(), DanmuStreamError> {
*self.stop.write().await = true;
if let Some(mut write) = self.write.write().await.take() {
if let Err(e) = write.close().await {
error!("Failed to close WebSocket connection: {}", e);
}
}
Ok(())
}
}
impl BiliDanmu {
async fn connect_and_handle(
&self,
tx: mpsc::UnboundedSender<DanmuMessageType>,
) -> Result<(), DanmuStreamError> {
let wbi_key = self.get_wbi_key().await?;
let danmu_info = self.get_danmu_info(&wbi_key, self.room_id).await?;
let ws_hosts = danmu_info.data.host_list.clone();
let mut conn = None;
// try to connect to ws_hsots, once success, send the token to the tx
for i in ws_hosts {
let host = format!("wss://{}/sub", i.host);
match connect_async(&host).await {
Ok((c, _)) => {
conn = Some(c);
break;
}
Err(e) => {
eprintln!(
"Connect ws host: {} has error, trying next host ...\n{:?}\n{:?}",
host, i, e
);
}
}
}
let conn = conn.ok_or(DanmuStreamError::WebsocketError {
err: "Failed to connect to ws host".into(),
})?;
let (write, read) = conn.split();
*self.write.write().await = Some(write);
let json = serde_json::to_string(&WsSend {
roomid: self.room_id,
key: danmu_info.data.token,
uid: self.user_id,
protover: 3,
platform: "web".to_string(),
t: 2,
})
.map_err(|e| DanmuStreamError::WebsocketError { err: e.to_string() })?;
let json = pack::encode(&json, 7);
if let Some(write) = self.write.write().await.as_mut() {
write
.send(Message::binary(json))
.await
.map_err(|e| DanmuStreamError::WebsocketError { err: e.to_string() })?;
}
tokio::select! {
v = BiliDanmu::send_heartbeat_packets(Arc::clone(&self.write)) => v,
v = BiliDanmu::recv(read, tx, Arc::clone(&self.stop)) => v
}?;
Ok(())
}
async fn send_heartbeat_packets(
write: Arc<RwLock<Option<WsWriteType>>>,
) -> Result<(), DanmuStreamError> {
loop {
if let Some(write) = write.write().await.as_mut() {
write
.send(Message::binary(pack::encode("", 2)))
.await
.map_err(|e| DanmuStreamError::WebsocketError { err: e.to_string() })?;
}
sleep(Duration::from_secs(30)).await;
}
}
async fn recv(
mut read: WsReadType,
tx: mpsc::UnboundedSender<DanmuMessageType>,
stop: Arc<RwLock<bool>>,
) -> Result<(), DanmuStreamError> {
while let Ok(Some(msg)) = read.try_next().await {
if *stop.read().await {
log::info!("Stopping bilibili danmu stream");
break;
}
let data = msg.into_data();
if !data.is_empty() {
let s = pack::build_pack(&data);
if let Ok(msgs) = s {
for i in msgs {
let ws = stream::WsStreamCtx::new(&i);
if let Ok(ws) = ws {
match ws.match_msg() {
Ok(v) => {
tx.send(v).map_err(|e| DanmuStreamError::WebsocketError {
err: e.to_string(),
})?;
}
Err(e) => {
log::trace!(
"This message parsing is not yet supported:\nMessage: {i}\nErr: {e:#?}"
);
}
}
} else {
log::error!("{}", ws.unwrap_err());
}
}
}
}
}
Ok(())
}
async fn get_danmu_info(
&self,
wbi_key: &str,
room_id: u64,
) -> Result<DanmuInfo, DanmuStreamError> {
let room_id = self.get_real_room(wbi_key, room_id).await?;
let params = self
.get_sign(
wbi_key,
serde_json::json!({
"id": room_id,
"type": 0,
}),
)
.await?;
let resp = self
.client
.get(
&format!(
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?{}",
params
),
None,
)
.await?
.json::<DanmuInfo>()
.await?;
Ok(resp)
}
async fn get_real_room(&self, wbi_key: &str, room_id: u64) -> Result<u64, DanmuStreamError> {
let params = self
.get_sign(
wbi_key,
serde_json::json!({
"id": room_id,
"from": "room",
}),
)
.await?;
let resp = self
.client
.get(
&format!(
"https://api.live.bilibili.com/room/v1/Room/room_init?{}",
params
),
None,
)
.await?
.json::<RoomInit>()
.await?
.data
.room_id;
Ok(resp)
}
fn parse_user_id(cookie: &str) -> Result<u64, DanmuStreamError> {
let mut user_id = None;
// find DedeUserID=<user_id> in cookie str
let re = Regex::new(r"DedeUserID=(\d+)").unwrap();
if let Some(captures) = re.captures(cookie) {
if let Some(user) = captures.get(1) {
user_id = Some(user.as_str().parse::<u64>().unwrap());
}
}
if let Some(user_id) = user_id {
Ok(user_id)
} else {
Err(DanmuStreamError::InvalidIdentifier {
err: format!("Failed to find user_id in cookie: {cookie}"),
})
}
}
async fn get_wbi_key(&self) -> Result<String, DanmuStreamError> {
let nav_info: serde_json::Value = self
.client
.get("https://api.bilibili.com/x/web-interface/nav", None)
.await?
.json()
.await?;
let re = Regex::new(r"wbi/(.*).png").unwrap();
let img = re
.captures(nav_info["data"]["wbi_img"]["img_url"].as_str().unwrap())
.unwrap()
.get(1)
.unwrap()
.as_str();
let sub = re
.captures(nav_info["data"]["wbi_img"]["sub_url"].as_str().unwrap())
.unwrap()
.get(1)
.unwrap()
.as_str();
let raw_string = format!("{}{}", img, sub);
Ok(raw_string)
}
pub async fn get_sign(
&self,
wbi_key: &str,
mut parameters: serde_json::Value,
) -> Result<String, DanmuStreamError> {
let table = vec![
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,
];
let raw_string = wbi_key;
let mut encoded = Vec::new();
table.into_iter().for_each(|x| {
if x < raw_string.len() {
encoded.push(raw_string.as_bytes()[x]);
}
});
// only keep 32 bytes of encoded
encoded = encoded[0..32].to_vec();
let encoded = String::from_utf8(encoded).unwrap();
// Timestamp in seconds
let wts = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
parameters
.as_object_mut()
.unwrap()
.insert("wts".to_owned(), serde_json::Value::String(wts.to_string()));
// Get all keys from parameters into vec
let mut keys = parameters
.as_object()
.unwrap()
.keys()
.map(|x| x.to_owned())
.collect::<Vec<String>>();
// sort keys
keys.sort();
let mut params = String::new();
keys.iter().for_each(|x| {
params.push_str(x);
params.push('=');
// Convert value to string based on its type
let value = match parameters.get(x).unwrap() {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => "".to_string(),
};
// Value filters !'()* characters
let value = value.replace(['!', '\'', '(', ')', '*'], "");
let value = PctString::encode(value.chars(), URIReserved);
params.push_str(value.as_str());
// add & if not last
if x != keys.last().unwrap() {
params.push('&');
}
});
// md5 params+encoded
let w_rid = md5::compute(params.to_string() + encoded.as_str());
let params = params + format!("&w_rid={:x}", w_rid).as_str();
Ok(params)
}
}
#[derive(Serialize)]
struct WsSend {
uid: u64,
roomid: u64,
key: String,
protover: u32,
platform: String,
#[serde(rename = "type")]
t: u32,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DanmuInfo {
pub data: DanmuInfoData,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DanmuInfoData {
pub token: String,
pub host_list: Vec<WsHost>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct WsHost {
pub host: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct RoomInit {
data: RoomInitData,
}
#[derive(Debug, Deserialize, Clone)]
pub struct RoomInitData {
room_id: u64,
}

View File

@@ -0,0 +1,88 @@
use serde::Deserialize;
use crate::{provider::bilibili::stream::WsStreamCtx, DanmuStreamError};
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct BiliDanmuMessage {
pub uid: u64,
pub username: String,
pub msg: String,
pub fan: Option<String>,
pub fan_level: Option<u64>,
pub timestamp: i64,
}
impl BiliDanmuMessage {
pub fn new_from_ctx(ctx: &WsStreamCtx) -> Result<Self, DanmuStreamError> {
let info = ctx
.info
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "info is None".to_string(),
})?;
let array_2 = info
.get(2)
.and_then(|x| x.as_array())
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "array_2 is None".to_string(),
})?
.to_owned();
let uid = array_2.first().and_then(|x| x.as_u64()).ok_or_else(|| {
DanmuStreamError::MessageParseError {
err: "uid is None".to_string(),
}
})?;
let username = array_2
.get(1)
.and_then(|x| x.as_str())
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "username is None".to_string(),
})?
.to_string();
let msg = info
.get(1)
.and_then(|x| x.as_str())
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "msg is None".to_string(),
})?
.to_string();
let array_3 = info
.get(3)
.and_then(|x| x.as_array())
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "array_3 is None".to_string(),
})?
.to_owned();
let fan = array_3
.get(1)
.and_then(|x| x.as_str())
.map(|x| x.to_owned());
let fan_level = array_3.first().and_then(|x| x.as_u64());
let timestamp = info
.first()
.and_then(|x| x.as_array())
.and_then(|x| x.get(4))
.and_then(|x| x.as_i64())
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "timestamp is None".to_string(),
})?;
Ok(Self {
uid,
username,
msg,
fan,
fan_level,
timestamp,
})
}
}

View File

@@ -0,0 +1,67 @@
use crate::{provider::bilibili::stream::WsStreamCtx, DanmuStreamError};
#[derive(Debug)]
#[allow(dead_code)]
pub struct InteractWord {
pub uid: u64,
pub uname: String,
pub fan: Option<String>,
pub fan_level: Option<u32>,
}
#[allow(dead_code)]
impl InteractWord {
pub fn new_from_ctx(ctx: &WsStreamCtx) -> Result<Self, DanmuStreamError> {
let data = ctx
.data
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "data is None".to_string(),
})?;
let uname = data
.uname
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "uname is None".to_string(),
})?
.to_string();
let uid = data
.uid
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "uid is None".to_string(),
})?
.as_u64()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "uid is None".to_string(),
})?;
let fan = data
.fans_medal
.as_ref()
.and_then(|x| x.medal_name.to_owned());
let fan = if fan == Some("".to_string()) {
None
} else {
fan
};
let fan_level = data.fans_medal.as_ref().and_then(|x| x.medal_level);
let fan_level = if fan_level == Some(0) {
None
} else {
fan_level
};
Ok(Self {
uid,
uname,
fan,
fan_level,
})
}
}

View File

@@ -0,0 +1,161 @@
// This file is copied from https://github.com/eatradish/felgens/blob/master/src/pack.rs
use std::io::Read;
use flate2::read::ZlibDecoder;
use scroll::Pread;
use scroll_derive::Pread;
use crate::DanmuStreamError;
#[derive(Debug, Pread, Clone)]
struct BilibiliPackHeader {
pack_len: u32,
_header_len: u16,
ver: u16,
_op: u32,
_seq: u32,
}
#[derive(Debug, Pread)]
struct PackHotCount {
count: u32,
}
type BilibiliPackCtx<'a> = (BilibiliPackHeader, &'a [u8]);
fn pack(buffer: &[u8]) -> Result<BilibiliPackCtx, DanmuStreamError> {
let data = buffer
.pread_with(0, scroll::BE)
.map_err(|e: scroll::Error| DanmuStreamError::PackError { err: e.to_string() })?;
let buf = &buffer[16..];
Ok((data, buf))
}
fn write_int(buffer: &[u8], start: usize, val: u32) -> Vec<u8> {
let val_bytes = val.to_be_bytes();
let mut buf = buffer.to_vec();
for (i, c) in val_bytes.iter().enumerate() {
buf[start + i] = *c;
}
buf
}
pub fn encode(s: &str, op: u8) -> Vec<u8> {
let data = s.as_bytes();
let packet_len = 16 + data.len();
let header = vec![0, 0, 0, 0, 0, 16, 0, 1, 0, 0, 0, op, 0, 0, 0, 1];
let header = write_int(&header, 0, packet_len as u32);
[&header, data].concat()
}
pub fn build_pack(buf: &[u8]) -> Result<Vec<String>, DanmuStreamError> {
let ctx = pack(buf)?;
let msgs = decode(ctx)?;
Ok(msgs)
}
fn get_hot_count(body: &[u8]) -> Result<u32, DanmuStreamError> {
let count = body
.pread_with::<PackHotCount>(0, scroll::BE)
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?
.count;
Ok(count)
}
fn zlib_decode(body: &[u8]) -> Result<(BilibiliPackHeader, Vec<u8>), DanmuStreamError> {
let mut buf = vec![];
let mut z = ZlibDecoder::new(body);
z.read_to_end(&mut buf)
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?;
let ctx = pack(&buf)?;
let header = ctx.0;
let buf = ctx.1.to_vec();
Ok((header, buf))
}
fn decode(ctx: BilibiliPackCtx) -> Result<Vec<String>, DanmuStreamError> {
let (mut header, body) = ctx;
let mut buf = body.to_vec();
loop {
(header, buf) = match header.ver {
2 => zlib_decode(&buf)?,
3 => brotli_decode(&buf)?,
0 | 1 => break,
_ => break,
}
}
let msgs = match header.ver {
0 => split_msgs(buf, header)?,
1 => vec![format!("{{\"count\": {}}}", get_hot_count(&buf)?)],
x => return Err(DanmuStreamError::UnsupportProto { proto: x }),
};
Ok(msgs)
}
fn split_msgs(buf: Vec<u8>, header: BilibiliPackHeader) -> Result<Vec<String>, DanmuStreamError> {
let mut buf = buf;
let mut header = header;
let mut msgs = vec![];
let mut offset = 0;
let buf_len = buf.len();
msgs.push(
std::str::from_utf8(&buf[..(header.pack_len - 16) as usize])
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?
.to_string(),
);
buf = buf[(header.pack_len - 16) as usize..].to_vec();
offset += header.pack_len - 16;
while offset != buf_len as u32 {
let ctx = pack(&buf).map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?;
header = ctx.0;
buf = ctx.1.to_vec();
msgs.push(
std::str::from_utf8(&buf[..(header.pack_len - 16) as usize])
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?
.to_string(),
);
buf = buf[(header.pack_len - 16) as usize..].to_vec();
offset += header.pack_len;
}
Ok(msgs)
}
fn brotli_decode(body: &[u8]) -> Result<(BilibiliPackHeader, Vec<u8>), DanmuStreamError> {
let mut reader = brotli::Decompressor::new(body, 4096);
let mut buf = Vec::new();
reader
.read_to_end(&mut buf)
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?;
let ctx = pack(&buf).map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?;
let header = ctx.0;
let buf = ctx.1.to_vec();
Ok((header, buf))
}

View File

@@ -0,0 +1,115 @@
use serde::Deserialize;
use crate::{provider::bilibili::stream::WsStreamCtx, DanmuStreamError};
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct SendGift {
pub action: String,
pub gift_name: String,
pub num: u64,
pub uname: String,
pub uid: u64,
pub medal_name: Option<String>,
pub medal_level: Option<u32>,
pub price: u32,
}
#[allow(dead_code)]
impl SendGift {
pub fn new_from_ctx(ctx: &WsStreamCtx) -> Result<Self, DanmuStreamError> {
let data = ctx
.data
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "data is None".to_string(),
})?;
let action = data
.action
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "action is None".to_string(),
})?
.to_owned();
let combo_send = data.combo_send.clone();
let gift_name = if let Some(gift) = data.gift_name.as_ref() {
gift.to_owned()
} else if let Some(gift) = combo_send.clone().and_then(|x| x.gift_name) {
gift
} else {
return Err(DanmuStreamError::MessageParseError {
err: "gift_name is None".to_string(),
});
};
let num = if let Some(num) = combo_send.clone().and_then(|x| x.combo_num) {
num
} else if let Some(num) = data.num {
num
} else if let Some(num) = combo_send.and_then(|x| x.gift_num) {
num
} else {
return Err(DanmuStreamError::MessageParseError {
err: "num is None".to_string(),
});
};
let uname = data
.uname
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "uname is None".to_string(),
})?
.to_owned();
let uid = data
.uid
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "uid is None".to_string(),
})?
.as_u64()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "uid is None".to_string(),
})?;
let medal_name = data
.medal_info
.as_ref()
.and_then(|x| x.medal_name.to_owned());
let medal_level = data.medal_info.as_ref().and_then(|x| x.medal_level);
let medal_name = if medal_name == Some("".to_string()) {
None
} else {
medal_name
};
let medal_level = if medal_level == Some(0) {
None
} else {
medal_level
};
let price = data
.price
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "price is None".to_string(),
})?;
Ok(Self {
action,
gift_name,
num,
uname,
uid,
medal_name,
medal_level,
price,
})
}
}

View File

@@ -0,0 +1,97 @@
use serde::Deserialize;
use serde_json::Value;
use crate::{
provider::{bilibili::dannmu_msg::BiliDanmuMessage, DanmuMessageType},
DanmuStreamError, DanmuMessage,
};
#[derive(Debug, Deserialize, Clone)]
pub struct WsStreamCtx {
pub cmd: Option<String>,
pub info: Option<Vec<Value>>,
pub data: Option<WsStreamCtxData>,
#[serde(flatten)]
_v: Value,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct WsStreamCtxData {
pub message: Option<String>,
pub price: Option<u32>,
pub start_time: Option<u64>,
pub time: Option<u32>,
pub uid: Option<Value>,
pub user_info: Option<WsStreamCtxDataUser>,
pub medal_info: Option<WsStreamCtxDataMedalInfo>,
pub uname: Option<String>,
pub fans_medal: Option<WsStreamCtxDataMedalInfo>,
pub action: Option<String>,
#[serde(rename = "giftName")]
pub gift_name: Option<String>,
pub num: Option<u64>,
pub combo_num: Option<u64>,
pub gift_num: Option<u64>,
pub combo_send: Box<Option<WsStreamCtxData>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct WsStreamCtxDataMedalInfo {
pub medal_name: Option<String>,
pub medal_level: Option<u32>,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct WsStreamCtxDataUser {
pub face: String,
pub uname: String,
}
impl WsStreamCtx {
pub fn new(s: &str) -> Result<Self, DanmuStreamError> {
serde_json::from_str(s).map_err(|_| DanmuStreamError::MessageParseError {
err: "Failed to parse message".to_string(),
})
}
pub fn match_msg(&self) -> Result<DanmuMessageType, DanmuStreamError> {
let cmd = self.handle_cmd();
let danmu_msg = match cmd {
Some(c) if c.contains("DANMU_MSG") => Some(BiliDanmuMessage::new_from_ctx(self)?),
_ => None,
};
if let Some(danmu_msg) = danmu_msg {
Ok(DanmuMessageType::DanmuMessage(DanmuMessage {
room_id: 0,
user_id: danmu_msg.uid,
user_name: danmu_msg.username,
message: danmu_msg.msg,
color: 0,
timestamp: danmu_msg.timestamp,
}))
} else {
Err(DanmuStreamError::MessageParseError {
err: "Unknown message".to_string(),
})
}
}
fn handle_cmd(&self) -> Option<&str> {
// handle DANMU_MSG:4:0:2:2:2:0
let cmd = if let Some(c) = self.cmd.as_deref() {
if c.starts_with("DANMU_MSG") {
Some("DANMU_MSG")
} else {
Some(c)
}
} else {
None
};
cmd
}
}

View File

@@ -0,0 +1,93 @@
use serde::Deserialize;
use crate::{provider::bilibili::stream::WsStreamCtx, DanmuStreamError};
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct SuperChatMessage {
pub uname: String,
pub uid: u64,
pub face: String,
pub price: u32,
pub start_time: u64,
pub time: u32,
pub msg: String,
pub medal_name: Option<String>,
pub medal_level: Option<u32>,
}
#[allow(dead_code)]
impl SuperChatMessage {
pub fn new_from_ctx(ctx: &WsStreamCtx) -> Result<Self, DanmuStreamError> {
let data = ctx
.data
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "data is None".to_string(),
})?;
let user_info =
data.user_info
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "user_info is None".to_string(),
})?;
let uname = user_info.uname.to_owned();
let uid = data.uid.as_ref().and_then(|x| x.as_u64()).ok_or_else(|| {
DanmuStreamError::MessageParseError {
err: "uid is None".to_string(),
}
})?;
let face = user_info.face.to_owned();
let price = data
.price
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "price is None".to_string(),
})?;
let start_time = data
.start_time
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "start_time is None".to_string(),
})?;
let time = data
.time
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "time is None".to_string(),
})?;
let msg = data
.message
.as_ref()
.ok_or_else(|| DanmuStreamError::MessageParseError {
err: "message is None".to_string(),
})?
.to_owned();
let medal = data
.medal_info
.as_ref()
.map(|x| (x.medal_name.to_owned(), x.medal_level.to_owned()));
let medal_name = medal.as_ref().and_then(|(name, _)| name.to_owned());
let medal_level = medal.and_then(|(_, level)| level);
Ok(Self {
uname,
uid,
face,
price,
start_time,
time,
msg,
medal_name,
medal_level,
})
}
}

View File

@@ -0,0 +1,462 @@
use crate::{provider::DanmuProvider, DanmuMessage, DanmuMessageType, DanmuStreamError};
use async_trait::async_trait;
use deno_core::v8;
use deno_core::JsRuntime;
use deno_core::RuntimeOptions;
use flate2::read::GzDecoder;
use futures_util::{SinkExt, StreamExt, TryStreamExt};
use log::debug;
use log::{error, info};
use prost::bytes::Bytes;
use prost::Message;
use std::io::Read;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::sync::RwLock;
use tokio_tungstenite::{
connect_async, tungstenite::Message as WsMessage, MaybeTlsStream, WebSocketStream,
};
mod messages;
use messages::*;
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
type WsReadType = futures_util::stream::SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>;
type WsWriteType =
futures_util::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, WsMessage>;
pub struct DouyinDanmu {
room_id: u64,
cookie: String,
stop: Arc<RwLock<bool>>,
write: Arc<RwLock<Option<WsWriteType>>>,
}
impl DouyinDanmu {
async fn connect_and_handle(
&self,
tx: mpsc::UnboundedSender<DanmuMessageType>,
) -> Result<(), DanmuStreamError> {
let url = self.get_wss_url().await?;
let request = tokio_tungstenite::tungstenite::http::Request::builder()
.uri(url)
.header(
tokio_tungstenite::tungstenite::http::header::COOKIE,
self.cookie.as_str(),
)
.header(
tokio_tungstenite::tungstenite::http::header::REFERER,
"https://live.douyin.com/",
)
.header(
tokio_tungstenite::tungstenite::http::header::USER_AGENT,
USER_AGENT,
)
.header(
tokio_tungstenite::tungstenite::http::header::HOST,
"webcast5-ws-web-hl.douyin.com",
)
.header(
tokio_tungstenite::tungstenite::http::header::UPGRADE,
"websocket",
)
.header(
tokio_tungstenite::tungstenite::http::header::CONNECTION,
"Upgrade",
)
.header(
tokio_tungstenite::tungstenite::http::header::SEC_WEBSOCKET_VERSION,
"13",
)
.header(
tokio_tungstenite::tungstenite::http::header::SEC_WEBSOCKET_EXTENSIONS,
"permessage-deflate; client_max_window_bits",
)
.header(
tokio_tungstenite::tungstenite::http::header::SEC_WEBSOCKET_KEY,
"V1Yza5x1zcfkembl6u/0Pg==",
)
.body(())
.unwrap();
let (ws_stream, response) =
connect_async(request)
.await
.map_err(|e| DanmuStreamError::WebsocketError {
err: format!("Failed to connect to douyin websocket: {}", e),
})?;
// Log the response status for debugging
info!("WebSocket connection response: {:?}", response.status());
let (write, read) = ws_stream.split();
*self.write.write().await = Some(write);
self.handle_connection(read, tx).await
}
async fn get_wss_url(&self) -> Result<String, DanmuStreamError> {
// Create a new V8 runtime
let mut runtime = JsRuntime::new(RuntimeOptions::default());
// Add global CryptoJS object
let crypto_js = include_str!("douyin/crypto-js.min.js");
runtime
.execute_script(
"<crypto-js.min.js>",
deno_core::FastString::Static(crypto_js),
)
.map_err(|e| DanmuStreamError::WebsocketError {
err: format!("Failed to execute crypto-js: {}", e),
})?;
// Load and execute the sign.js file
let js_code = include_str!("douyin/webmssdk.js");
runtime
.execute_script("<sign.js>", deno_core::FastString::Static(js_code))
.map_err(|e| DanmuStreamError::WebsocketError {
err: format!("Failed to execute JavaScript: {}", e),
})?;
// Call the get_wss_url function
let sign_call = format!("get_wss_url(\"{}\")", self.room_id);
let result = runtime
.execute_script(
"<sign_call>",
deno_core::FastString::Owned(sign_call.into_boxed_str()),
)
.map_err(|e| DanmuStreamError::WebsocketError {
err: format!("Failed to execute JavaScript: {}", e),
})?;
// Get the result from the V8 runtime
let scope = &mut runtime.handle_scope();
let local = v8::Local::new(scope, result);
let url = local.to_string(scope).unwrap().to_rust_string_lossy(scope);
debug!("Douyin wss url: {}", url);
Ok(url)
}
async fn handle_connection(
&self,
mut read: WsReadType,
tx: mpsc::UnboundedSender<DanmuMessageType>,
) -> Result<(), DanmuStreamError> {
// Start heartbeat task with error handling
let (tx_write, mut _rx_write) = mpsc::channel(32);
let tx_write_clone = tx_write.clone();
let stop = Arc::clone(&self.stop);
let heartbeat_handle = tokio::spawn(async move {
let mut last_heartbeat = SystemTime::now();
let mut consecutive_failures = 0;
const MAX_FAILURES: u32 = 3;
loop {
if *stop.read().await {
log::info!("Stopping douyin danmu stream");
break;
}
tokio::time::sleep(HEARTBEAT_INTERVAL).await;
match Self::send_heartbeat(&tx_write_clone).await {
Ok(_) => {
last_heartbeat = SystemTime::now();
consecutive_failures = 0;
}
Err(e) => {
error!("Failed to send heartbeat: {}", e);
consecutive_failures += 1;
if consecutive_failures >= MAX_FAILURES {
error!("Too many consecutive heartbeat failures, closing connection");
break;
}
// Check if we've exceeded the maximum time without a successful heartbeat
if let Ok(duration) = last_heartbeat.elapsed() {
if duration > HEARTBEAT_INTERVAL * 2 {
error!("No successful heartbeat for too long, closing connection");
break;
}
}
}
}
}
});
// Main message handling loop
let room_id = self.room_id;
let stop = Arc::clone(&self.stop);
let write = Arc::clone(&self.write);
let message_handle = tokio::spawn(async move {
while let Some(msg) =
read.try_next()
.await
.map_err(|e| DanmuStreamError::WebsocketError {
err: format!("Failed to read message: {}", e),
})?
{
if *stop.read().await {
log::info!("Stopping douyin danmu stream");
break;
}
match msg {
WsMessage::Binary(data) => {
if let Ok(Some(ack)) = handle_binary_message(&data, &tx, room_id).await {
if let Some(write) = write.write().await.as_mut() {
if let Err(e) =
write.send(WsMessage::Binary(ack.encode_to_vec())).await
{
error!("Failed to send ack: {}", e);
}
}
}
}
WsMessage::Close(_) => {
info!("WebSocket connection closed");
break;
}
WsMessage::Ping(data) => {
// Respond to ping with pong
if let Err(e) = tx_write.send(WsMessage::Pong(data)).await {
error!("Failed to send pong: {}", e);
break;
}
}
_ => {}
}
}
Ok::<(), DanmuStreamError>(())
});
// Wait for either the heartbeat or message handling to complete
tokio::select! {
result = heartbeat_handle => {
if let Err(e) = result {
error!("Heartbeat task failed: {}", e);
}
}
result = message_handle => {
if let Err(e) = result {
error!("Message handling task failed: {}", e);
}
}
}
Ok(())
}
async fn send_heartbeat(tx: &mpsc::Sender<WsMessage>) -> Result<(), DanmuStreamError> {
// heartbeat message: 3A 02 68 62
tx.send(WsMessage::Binary(vec![0x3A, 0x02, 0x68, 0x62]))
.await
.map_err(|e| DanmuStreamError::WebsocketError {
err: format!("Failed to send heartbeat message: {}", e),
})?;
Ok(())
}
}
async fn handle_binary_message(
data: &[u8],
tx: &mpsc::UnboundedSender<DanmuMessageType>,
room_id: u64,
) -> Result<Option<PushFrame>, DanmuStreamError> {
// First decode the PushFrame
let push_frame = PushFrame::decode(Bytes::from(data.to_vec())).map_err(|e| {
DanmuStreamError::WebsocketError {
err: format!("Failed to decode PushFrame: {}", e),
}
})?;
// Decompress the payload
let mut decoder = GzDecoder::new(push_frame.payload.as_slice());
let mut decompressed = Vec::new();
decoder
.read_to_end(&mut decompressed)
.map_err(|e| DanmuStreamError::WebsocketError {
err: format!("Failed to decompress payload: {}", e),
})?;
// Decode the Response from decompressed payload
let response = Response::decode(Bytes::from(decompressed)).map_err(|e| {
DanmuStreamError::WebsocketError {
err: format!("Failed to decode Response: {}", e),
}
})?;
// if payload_package.needAck:
// obj = PushFrame()
// obj.payloadType = 'ack'
// obj.logId = log_id
// obj.payloadType = payload_package.internalExt
// ack = obj.SerializeToString()
let mut ack = None;
if response.need_ack {
let ack_msg = PushFrame {
payload_type: "ack".to_string(),
log_id: push_frame.log_id,
payload_encoding: "".to_string(),
payload: vec![],
seq_id: 0,
service: 0,
method: 0,
headers_list: vec![],
};
debug!("Need to respond ack: {:?}", ack_msg);
ack = Some(ack_msg);
}
for message in response.messages_list {
match message.method.as_str() {
"WebcastChatMessage" => {
let chat_msg =
DouyinChatMessage::decode(message.payload.as_slice()).map_err(|e| {
DanmuStreamError::WebsocketError {
err: format!("Failed to decode chat message: {}", e),
}
})?;
if let Some(user) = chat_msg.user {
let danmu_msg = DanmuMessage {
room_id,
user_id: user.id,
user_name: user.nick_name,
message: chat_msg.content,
color: 0xffffff,
timestamp: chat_msg.event_time as i64 * 1000,
};
debug!("Received danmu message: {:?}", danmu_msg);
tx.send(DanmuMessageType::DanmuMessage(danmu_msg))
.map_err(|e| DanmuStreamError::WebsocketError {
err: format!("Failed to send message to channel: {}", e),
})?;
}
}
"WebcastGiftMessage" => {
let gift_msg = GiftMessage::decode(message.payload.as_slice()).map_err(|e| {
DanmuStreamError::WebsocketError {
err: format!("Failed to decode gift message: {}", e),
}
})?;
if let Some(user) = gift_msg.user {
if let Some(gift) = gift_msg.gift {
log::debug!("Received gift: {} from user: {}", gift.name, user.nick_name);
}
}
}
"WebcastLikeMessage" => {
let like_msg = LikeMessage::decode(message.payload.as_slice()).map_err(|e| {
DanmuStreamError::WebsocketError {
err: format!("Failed to decode like message: {}", e),
}
})?;
if let Some(user) = like_msg.user {
log::debug!(
"Received {} likes from user: {}",
like_msg.count,
user.nick_name
);
}
}
"WebcastMemberMessage" => {
let member_msg =
MemberMessage::decode(message.payload.as_slice()).map_err(|e| {
DanmuStreamError::WebsocketError {
err: format!("Failed to decode member message: {}", e),
}
})?;
if let Some(user) = member_msg.user {
log::debug!(
"Member joined: {} (Action: {})",
user.nick_name,
member_msg.action_description
);
}
}
_ => {
debug!("Unknown message: {:?}", message);
}
}
}
Ok(ack)
}
#[async_trait]
impl DanmuProvider for DouyinDanmu {
async fn new(identifier: &str, room_id: u64) -> Result<Self, DanmuStreamError> {
Ok(Self {
room_id,
cookie: identifier.to_string(),
stop: Arc::new(RwLock::new(false)),
write: Arc::new(RwLock::new(None)),
})
}
async fn start(
&self,
tx: mpsc::UnboundedSender<DanmuMessageType>,
) -> Result<(), DanmuStreamError> {
let mut retry_count = 0;
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY: Duration = Duration::from_secs(5);
info!(
"Douyin WebSocket connection started, room_id: {}",
self.room_id
);
loop {
if *self.stop.read().await {
break;
}
match self.connect_and_handle(tx.clone()).await {
Ok(_) => {
info!("Douyin WebSocket connection closed normally");
break;
}
Err(e) => {
error!("Douyin WebSocket connection error: {}", e);
retry_count += 1;
if retry_count >= MAX_RETRIES {
return Err(DanmuStreamError::WebsocketError {
err: format!("Failed to connect after {} retries", MAX_RETRIES),
});
}
info!(
"Retrying connection in {} seconds... (Attempt {}/{})",
RETRY_DELAY.as_secs(),
retry_count,
MAX_RETRIES
);
tokio::time::sleep(RETRY_DELAY).await;
}
}
}
Ok(())
}
async fn stop(&self) -> Result<(), DanmuStreamError> {
*self.stop.write().await = true;
if let Some(mut write) = self.write.write().await.take() {
if let Err(e) = write.close().await {
error!("Failed to close WebSocket connection: {}", e);
}
}
Ok(())
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,861 @@
use prost::Message;
use std::collections::HashMap;
// message Response {
// repeated Message messagesList = 1;
// string cursor = 2;
// uint64 fetchInterval = 3;
// uint64 now = 4;
// string internalExt = 5;
// uint32 fetchType = 6;
// map<string, string> routeParams = 7;
// uint64 heartbeatDuration = 8;
// bool needAck = 9;
// string pushServer = 10;
// string liveCursor = 11;
// bool historyNoMore = 12;
// }
#[derive(Message)]
pub struct Response {
#[prost(message, repeated, tag = "1")]
pub messages_list: Vec<CommonMessage>,
#[prost(string, tag = "2")]
pub cursor: String,
#[prost(uint64, tag = "3")]
pub fetch_interval: u64,
#[prost(uint64, tag = "4")]
pub now: u64,
#[prost(string, tag = "5")]
pub internal_ext: String,
#[prost(uint32, tag = "6")]
pub fetch_type: u32,
#[prost(map = "string, string", tag = "7")]
pub route_params: HashMap<String, String>,
#[prost(uint64, tag = "8")]
pub heartbeat_duration: u64,
#[prost(bool, tag = "9")]
pub need_ack: bool,
#[prost(string, tag = "10")]
pub push_server: String,
#[prost(string, tag = "11")]
pub live_cursor: String,
#[prost(bool, tag = "12")]
pub history_no_more: bool,
}
#[derive(Message)]
pub struct CommonMessage {
#[prost(string, tag = "1")]
pub method: String,
#[prost(bytes, tag = "2")]
pub payload: Vec<u8>,
#[prost(int64, tag = "3")]
pub msg_id: i64,
#[prost(int32, tag = "4")]
pub msg_type: i32,
#[prost(int64, tag = "5")]
pub offset: i64,
#[prost(bool, tag = "6")]
pub need_wrds_store: bool,
#[prost(int64, tag = "7")]
pub wrds_version: i64,
#[prost(string, tag = "8")]
pub wrds_sub_key: String,
}
#[derive(Message)]
pub struct Common {
#[prost(string, tag = "1")]
pub method: String,
#[prost(uint64, tag = "2")]
pub msg_id: u64,
#[prost(uint64, tag = "3")]
pub room_id: u64,
#[prost(uint64, tag = "4")]
pub create_time: u64,
#[prost(uint32, tag = "5")]
pub monitor: u32,
#[prost(bool, tag = "6")]
pub is_show_msg: bool,
#[prost(string, tag = "7")]
pub describe: String,
#[prost(uint64, tag = "9")]
pub fold_type: u64,
#[prost(uint64, tag = "10")]
pub anchor_fold_type: u64,
#[prost(uint64, tag = "11")]
pub priority_score: u64,
#[prost(string, tag = "12")]
pub log_id: String,
#[prost(string, tag = "13")]
pub msg_process_filter_k: String,
#[prost(string, tag = "14")]
pub msg_process_filter_v: String,
#[prost(message, optional, tag = "15")]
pub user: Option<User>,
}
#[derive(Message)]
pub struct User {
#[prost(uint64, tag = "1")]
pub id: u64,
#[prost(uint64, tag = "2")]
pub short_id: u64,
#[prost(string, tag = "3")]
pub nick_name: String,
#[prost(uint32, tag = "4")]
pub gender: u32,
#[prost(string, tag = "5")]
pub signature: String,
#[prost(uint32, tag = "6")]
pub level: u32,
#[prost(uint64, tag = "7")]
pub birthday: u64,
#[prost(string, tag = "8")]
pub telephone: String,
#[prost(message, optional, tag = "9")]
pub avatar_thumb: Option<Image>,
#[prost(message, optional, tag = "10")]
pub avatar_medium: Option<Image>,
#[prost(message, optional, tag = "11")]
pub avatar_large: Option<Image>,
#[prost(bool, tag = "12")]
pub verified: bool,
#[prost(uint32, tag = "13")]
pub experience: u32,
#[prost(string, tag = "14")]
pub city: String,
#[prost(int32, tag = "15")]
pub status: i32,
#[prost(uint64, tag = "16")]
pub create_time: u64,
#[prost(uint64, tag = "17")]
pub modify_time: u64,
#[prost(uint32, tag = "18")]
pub secret: u32,
#[prost(string, tag = "19")]
pub share_qrcode_uri: String,
#[prost(uint32, tag = "20")]
pub income_share_percent: u32,
#[prost(message, repeated, tag = "21")]
pub badge_image_list: Vec<Image>,
#[prost(message, optional, tag = "22")]
pub follow_info: Option<FollowInfo>,
#[prost(message, optional, tag = "23")]
pub pay_grade: Option<PayGrade>,
#[prost(message, optional, tag = "24")]
pub fans_club: Option<FansClub>,
#[prost(string, tag = "26")]
pub special_id: String,
#[prost(message, optional, tag = "27")]
pub avatar_border: Option<Image>,
#[prost(message, optional, tag = "28")]
pub medal: Option<Image>,
#[prost(message, repeated, tag = "29")]
pub real_time_icons_list: Vec<Image>,
#[prost(string, tag = "38")]
pub display_id: String,
#[prost(string, tag = "46")]
pub sec_uid: String,
#[prost(uint64, tag = "1022")]
pub fan_ticket_count: u64,
#[prost(string, tag = "1028")]
pub id_str: String,
#[prost(uint32, tag = "1045")]
pub age_range: u32,
}
#[derive(Message, PartialEq)]
pub struct Image {
#[prost(string, repeated, tag = "1")]
pub url_list_list: Vec<String>,
#[prost(string, tag = "2")]
pub uri: String,
#[prost(uint64, tag = "3")]
pub height: u64,
#[prost(uint64, tag = "4")]
pub width: u64,
#[prost(string, tag = "5")]
pub avg_color: String,
#[prost(uint32, tag = "6")]
pub image_type: u32,
#[prost(string, tag = "7")]
pub open_web_url: String,
#[prost(message, optional, tag = "8")]
pub content: Option<ImageContent>,
#[prost(bool, tag = "9")]
pub is_animated: bool,
#[prost(message, optional, tag = "10")]
pub flex_setting_list: Option<NinePatchSetting>,
#[prost(message, optional, tag = "11")]
pub text_setting_list: Option<NinePatchSetting>,
}
#[derive(Message, PartialEq)]
pub struct ImageContent {
#[prost(string, tag = "1")]
pub name: String,
#[prost(string, tag = "2")]
pub font_color: String,
#[prost(uint64, tag = "3")]
pub level: u64,
#[prost(string, tag = "4")]
pub alternative_text: String,
}
#[derive(Message, PartialEq)]
pub struct NinePatchSetting {
#[prost(string, repeated, tag = "1")]
pub setting_list_list: Vec<String>,
}
#[derive(Message)]
pub struct FollowInfo {
#[prost(uint64, tag = "1")]
pub following_count: u64,
#[prost(uint64, tag = "2")]
pub follower_count: u64,
#[prost(uint64, tag = "3")]
pub follow_status: u64,
#[prost(uint64, tag = "4")]
pub push_status: u64,
#[prost(string, tag = "5")]
pub remark_name: String,
#[prost(string, tag = "6")]
pub follower_count_str: String,
#[prost(string, tag = "7")]
pub following_count_str: String,
}
#[derive(Message)]
pub struct PayGrade {
#[prost(int64, tag = "1")]
pub total_diamond_count: i64,
#[prost(message, optional, tag = "2")]
pub diamond_icon: Option<Image>,
#[prost(string, tag = "3")]
pub name: String,
#[prost(message, optional, tag = "4")]
pub icon: Option<Image>,
#[prost(string, tag = "5")]
pub next_name: String,
#[prost(int64, tag = "6")]
pub level: i64,
#[prost(message, optional, tag = "7")]
pub next_icon: Option<Image>,
#[prost(int64, tag = "8")]
pub next_diamond: i64,
#[prost(int64, tag = "9")]
pub now_diamond: i64,
#[prost(int64, tag = "10")]
pub this_grade_min_diamond: i64,
#[prost(int64, tag = "11")]
pub this_grade_max_diamond: i64,
#[prost(int64, tag = "12")]
pub pay_diamond_bak: i64,
#[prost(string, tag = "13")]
pub grade_describe: String,
#[prost(message, repeated, tag = "14")]
pub grade_icon_list: Vec<GradeIcon>,
#[prost(int64, tag = "15")]
pub screen_chat_type: i64,
#[prost(message, optional, tag = "16")]
pub im_icon: Option<Image>,
#[prost(message, optional, tag = "17")]
pub im_icon_with_level: Option<Image>,
#[prost(message, optional, tag = "18")]
pub live_icon: Option<Image>,
#[prost(message, optional, tag = "19")]
pub new_im_icon_with_level: Option<Image>,
#[prost(message, optional, tag = "20")]
pub new_live_icon: Option<Image>,
#[prost(int64, tag = "21")]
pub upgrade_need_consume: i64,
#[prost(string, tag = "22")]
pub next_privileges: String,
#[prost(message, optional, tag = "23")]
pub background: Option<Image>,
#[prost(message, optional, tag = "24")]
pub background_back: Option<Image>,
#[prost(int64, tag = "25")]
pub score: i64,
#[prost(message, optional, tag = "26")]
pub buff_info: Option<GradeBuffInfo>,
}
#[derive(Message)]
pub struct GradeIcon {
#[prost(message, optional, tag = "1")]
pub icon: Option<Image>,
#[prost(int64, tag = "2")]
pub icon_diamond: i64,
#[prost(int64, tag = "3")]
pub level: i64,
#[prost(string, tag = "4")]
pub level_str: String,
}
#[derive(Message)]
pub struct GradeBuffInfo {}
#[derive(Message)]
pub struct FansClub {
#[prost(message, optional, tag = "1")]
pub data: Option<FansClubData>,
#[prost(map = "int32, message", tag = "2")]
pub prefer_data: HashMap<i32, FansClubData>,
}
#[derive(Message, PartialEq)]
pub struct FansClubData {
#[prost(string, tag = "1")]
pub club_name: String,
#[prost(int32, tag = "2")]
pub level: i32,
#[prost(int32, tag = "3")]
pub user_fans_club_status: i32,
#[prost(message, optional, tag = "4")]
pub badge: Option<UserBadge>,
#[prost(int64, repeated, tag = "5")]
pub available_gift_ids: Vec<i64>,
#[prost(int64, tag = "6")]
pub anchor_id: i64,
}
#[derive(Message, PartialEq)]
pub struct UserBadge {
#[prost(map = "int32, message", tag = "1")]
pub icons: HashMap<i32, Image>,
#[prost(string, tag = "2")]
pub title: String,
}
#[derive(Message)]
pub struct PublicAreaCommon {
#[prost(message, optional, tag = "1")]
pub user_label: Option<Image>,
#[prost(uint64, tag = "2")]
pub user_consume_in_room: u64,
#[prost(uint64, tag = "3")]
pub user_send_gift_cnt_in_room: u64,
}
#[derive(Message)]
pub struct LandscapeAreaCommon {
#[prost(bool, tag = "1")]
pub show_head: bool,
#[prost(bool, tag = "2")]
pub show_nickname: bool,
#[prost(bool, tag = "3")]
pub show_font_color: bool,
#[prost(string, repeated, tag = "4")]
pub color_value_list: Vec<String>,
#[prost(enumeration = "CommentTypeTag", repeated, tag = "5")]
pub comment_type_tags_list: Vec<i32>,
}
#[derive(Message)]
pub struct Text {
#[prost(string, tag = "1")]
pub key: String,
#[prost(string, tag = "2")]
pub default_patter: String,
#[prost(message, optional, tag = "3")]
pub default_format: Option<TextFormat>,
#[prost(message, repeated, tag = "4")]
pub pieces_list: Vec<TextPiece>,
}
#[derive(Message)]
pub struct TextFormat {
#[prost(string, tag = "1")]
pub color: String,
#[prost(bool, tag = "2")]
pub bold: bool,
#[prost(bool, tag = "3")]
pub italic: bool,
#[prost(uint32, tag = "4")]
pub weight: u32,
#[prost(uint32, tag = "5")]
pub italic_angle: u32,
#[prost(uint32, tag = "6")]
pub font_size: u32,
#[prost(bool, tag = "7")]
pub use_heigh_light_color: bool,
#[prost(bool, tag = "8")]
pub use_remote_clor: bool,
}
#[derive(Message)]
pub struct TextPiece {
#[prost(bool, tag = "1")]
pub r#type: bool,
#[prost(message, optional, tag = "2")]
pub format: Option<TextFormat>,
#[prost(string, tag = "3")]
pub string_value: String,
#[prost(message, optional, tag = "4")]
pub user_value: Option<TextPieceUser>,
#[prost(message, optional, tag = "5")]
pub gift_value: Option<TextPieceGift>,
#[prost(message, optional, tag = "6")]
pub heart_value: Option<TextPieceHeart>,
#[prost(message, optional, tag = "7")]
pub pattern_ref_value: Option<TextPiecePatternRef>,
#[prost(message, optional, tag = "8")]
pub image_value: Option<TextPieceImage>,
}
#[derive(Message)]
pub struct TextPieceUser {
#[prost(message, optional, tag = "1")]
pub user: Option<User>,
#[prost(bool, tag = "2")]
pub with_colon: bool,
}
#[derive(Message)]
pub struct TextPieceGift {
#[prost(uint64, tag = "1")]
pub gift_id: u64,
#[prost(message, optional, tag = "2")]
pub name_ref: Option<PatternRef>,
}
#[derive(Message)]
pub struct PatternRef {
#[prost(string, tag = "1")]
pub key: String,
#[prost(string, tag = "2")]
pub default_pattern: String,
}
#[derive(Message)]
pub struct TextPieceHeart {
#[prost(string, tag = "1")]
pub color: String,
}
#[derive(Message)]
pub struct TextPiecePatternRef {
#[prost(string, tag = "1")]
pub key: String,
#[prost(string, tag = "2")]
pub default_pattern: String,
}
#[derive(Message)]
pub struct TextPieceImage {
#[prost(message, optional, tag = "1")]
pub image: Option<Image>,
#[prost(float, tag = "2")]
pub scaling_rate: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum CommentTypeTag {
CommentTypeTagUnknown = 0,
CommentTypeTagStar = 1,
}
#[derive(Message)]
pub struct DouyinChatMessage {
#[prost(message, optional, tag = "1")]
pub common: Option<Common>,
#[prost(message, optional, tag = "2")]
pub user: Option<User>,
#[prost(string, tag = "3")]
pub content: String,
#[prost(bool, tag = "4")]
pub visible_to_sender: bool,
#[prost(message, optional, tag = "5")]
pub background_image: Option<Image>,
#[prost(string, tag = "6")]
pub full_screen_text_color: String,
#[prost(message, optional, tag = "7")]
pub background_image_v2: Option<Image>,
#[prost(message, optional, tag = "9")]
pub public_area_common: Option<PublicAreaCommon>,
#[prost(message, optional, tag = "10")]
pub gift_image: Option<Image>,
#[prost(uint64, tag = "11")]
pub agree_msg_id: u64,
#[prost(uint32, tag = "12")]
pub priority_level: u32,
#[prost(message, optional, tag = "13")]
pub landscape_area_common: Option<LandscapeAreaCommon>,
#[prost(uint64, tag = "15")]
pub event_time: u64,
#[prost(bool, tag = "16")]
pub send_review: bool,
#[prost(bool, tag = "17")]
pub from_intercom: bool,
#[prost(bool, tag = "18")]
pub intercom_hide_user_card: bool,
#[prost(string, tag = "20")]
pub chat_by: String,
#[prost(uint32, tag = "21")]
pub individual_chat_priority: u32,
#[prost(message, optional, tag = "22")]
pub rtf_content: Option<Text>,
}
#[derive(Message)]
pub struct GiftMessage {
#[prost(message, optional, tag = "1")]
pub common: Option<Common>,
#[prost(uint64, tag = "2")]
pub gift_id: u64,
#[prost(uint64, tag = "3")]
pub fan_ticket_count: u64,
#[prost(uint64, tag = "4")]
pub group_count: u64,
#[prost(uint64, tag = "5")]
pub repeat_count: u64,
#[prost(uint64, tag = "6")]
pub combo_count: u64,
#[prost(message, optional, tag = "7")]
pub user: Option<User>,
#[prost(message, optional, tag = "8")]
pub to_user: Option<User>,
#[prost(uint32, tag = "9")]
pub repeat_end: u32,
#[prost(message, optional, tag = "10")]
pub text_effect: Option<TextEffect>,
#[prost(uint64, tag = "11")]
pub group_id: u64,
#[prost(uint64, tag = "12")]
pub income_taskgifts: u64,
#[prost(uint64, tag = "13")]
pub room_fan_ticket_count: u64,
#[prost(message, optional, tag = "14")]
pub priority: Option<GiftIMPriority>,
#[prost(message, optional, tag = "15")]
pub gift: Option<GiftStruct>,
#[prost(string, tag = "16")]
pub log_id: String,
#[prost(uint64, tag = "17")]
pub send_type: u64,
#[prost(message, optional, tag = "18")]
pub public_area_common: Option<PublicAreaCommon>,
#[prost(message, optional, tag = "19")]
pub tray_display_text: Option<Text>,
#[prost(uint64, tag = "20")]
pub banned_display_effects: u64,
#[prost(bool, tag = "25")]
pub display_for_self: bool,
#[prost(string, tag = "26")]
pub interact_gift_info: String,
#[prost(string, tag = "27")]
pub diy_item_info: String,
#[prost(uint64, repeated, tag = "28")]
pub min_asset_set_list: Vec<u64>,
#[prost(uint64, tag = "29")]
pub total_count: u64,
#[prost(uint32, tag = "30")]
pub client_gift_source: u32,
#[prost(uint64, repeated, tag = "32")]
pub to_user_ids_list: Vec<u64>,
#[prost(uint64, tag = "33")]
pub send_time: u64,
#[prost(uint64, tag = "34")]
pub force_display_effects: u64,
#[prost(string, tag = "35")]
pub trace_id: String,
#[prost(uint64, tag = "36")]
pub effect_display_ts: u64,
}
#[derive(Message)]
pub struct GiftStruct {
#[prost(message, optional, tag = "1")]
pub image: Option<Image>,
#[prost(string, tag = "2")]
pub describe: String,
#[prost(bool, tag = "3")]
pub notify: bool,
#[prost(uint64, tag = "4")]
pub duration: u64,
#[prost(uint64, tag = "5")]
pub id: u64,
#[prost(bool, tag = "7")]
pub for_linkmic: bool,
#[prost(bool, tag = "8")]
pub doodle: bool,
#[prost(bool, tag = "9")]
pub for_fansclub: bool,
#[prost(bool, tag = "10")]
pub combo: bool,
#[prost(uint32, tag = "11")]
pub r#type: u32,
#[prost(uint32, tag = "12")]
pub diamond_count: u32,
#[prost(bool, tag = "13")]
pub is_displayed_on_panel: bool,
#[prost(uint64, tag = "14")]
pub primary_effect_id: u64,
#[prost(message, optional, tag = "15")]
pub gift_label_icon: Option<Image>,
#[prost(string, tag = "16")]
pub name: String,
#[prost(string, tag = "17")]
pub region: String,
#[prost(string, tag = "18")]
pub manual: String,
#[prost(bool, tag = "19")]
pub for_custom: bool,
#[prost(message, optional, tag = "21")]
pub icon: Option<Image>,
#[prost(uint32, tag = "22")]
pub action_type: u32,
}
#[derive(Message)]
pub struct GiftIMPriority {
#[prost(uint64, repeated, tag = "1")]
pub queue_sizes_list: Vec<u64>,
#[prost(uint64, tag = "2")]
pub self_queue_priority: u64,
#[prost(uint64, tag = "3")]
pub priority: u64,
}
#[derive(Message)]
pub struct TextEffect {
#[prost(message, optional, tag = "1")]
pub portrait: Option<TextEffectDetail>,
#[prost(message, optional, tag = "2")]
pub landscape: Option<TextEffectDetail>,
}
#[derive(Message)]
pub struct TextEffectDetail {
#[prost(message, optional, tag = "1")]
pub text: Option<Text>,
#[prost(uint32, tag = "2")]
pub text_font_size: u32,
#[prost(message, optional, tag = "3")]
pub background: Option<Image>,
#[prost(uint32, tag = "4")]
pub start: u32,
#[prost(uint32, tag = "5")]
pub duration: u32,
#[prost(uint32, tag = "6")]
pub x: u32,
#[prost(uint32, tag = "7")]
pub y: u32,
#[prost(uint32, tag = "8")]
pub width: u32,
#[prost(uint32, tag = "9")]
pub height: u32,
#[prost(uint32, tag = "10")]
pub shadow_dx: u32,
#[prost(uint32, tag = "11")]
pub shadow_dy: u32,
#[prost(uint32, tag = "12")]
pub shadow_radius: u32,
#[prost(string, tag = "13")]
pub shadow_color: String,
#[prost(string, tag = "14")]
pub stroke_color: String,
#[prost(uint32, tag = "15")]
pub stroke_width: u32,
}
#[derive(Message)]
pub struct LikeMessage {
#[prost(message, optional, tag = "1")]
pub common: Option<Common>,
#[prost(uint64, tag = "2")]
pub count: u64,
#[prost(uint64, tag = "3")]
pub total: u64,
#[prost(uint64, tag = "4")]
pub color: u64,
#[prost(message, optional, tag = "5")]
pub user: Option<User>,
#[prost(string, tag = "6")]
pub icon: String,
#[prost(message, optional, tag = "7")]
pub double_like_detail: Option<DoubleLikeDetail>,
#[prost(message, optional, tag = "8")]
pub display_control_info: Option<DisplayControlInfo>,
#[prost(uint64, tag = "9")]
pub linkmic_guest_uid: u64,
#[prost(string, tag = "10")]
pub scene: String,
#[prost(message, optional, tag = "11")]
pub pico_display_info: Option<PicoDisplayInfo>,
}
#[derive(Message)]
pub struct DoubleLikeDetail {
#[prost(bool, tag = "1")]
pub double_flag: bool,
#[prost(uint32, tag = "2")]
pub seq_id: u32,
#[prost(uint32, tag = "3")]
pub renewals_num: u32,
#[prost(uint32, tag = "4")]
pub triggers_num: u32,
}
#[derive(Message)]
pub struct DisplayControlInfo {
#[prost(bool, tag = "1")]
pub show_text: bool,
#[prost(bool, tag = "2")]
pub show_icons: bool,
}
#[derive(Message)]
pub struct PicoDisplayInfo {
#[prost(uint64, tag = "1")]
pub combo_sum_count: u64,
#[prost(string, tag = "2")]
pub emoji: String,
#[prost(message, optional, tag = "3")]
pub emoji_icon: Option<Image>,
#[prost(string, tag = "4")]
pub emoji_text: String,
}
#[derive(Message)]
pub struct MemberMessage {
#[prost(message, optional, tag = "1")]
pub common: Option<Common>,
#[prost(message, optional, tag = "2")]
pub user: Option<User>,
#[prost(uint64, tag = "3")]
pub member_count: u64,
#[prost(message, optional, tag = "4")]
pub operator: Option<User>,
#[prost(bool, tag = "5")]
pub is_set_to_admin: bool,
#[prost(bool, tag = "6")]
pub is_top_user: bool,
#[prost(uint64, tag = "7")]
pub rank_score: u64,
#[prost(uint64, tag = "8")]
pub top_user_no: u64,
#[prost(uint64, tag = "9")]
pub enter_type: u64,
#[prost(uint64, tag = "10")]
pub action: u64,
#[prost(string, tag = "11")]
pub action_description: String,
#[prost(uint64, tag = "12")]
pub user_id: u64,
#[prost(message, optional, tag = "13")]
pub effect_config: Option<EffectConfig>,
#[prost(string, tag = "14")]
pub pop_str: String,
#[prost(message, optional, tag = "15")]
pub enter_effect_config: Option<EffectConfig>,
#[prost(message, optional, tag = "16")]
pub background_image: Option<Image>,
#[prost(message, optional, tag = "17")]
pub background_image_v2: Option<Image>,
#[prost(message, optional, tag = "18")]
pub anchor_display_text: Option<Text>,
#[prost(message, optional, tag = "19")]
pub public_area_common: Option<PublicAreaCommon>,
#[prost(uint64, tag = "20")]
pub user_enter_tip_type: u64,
#[prost(uint64, tag = "21")]
pub anchor_enter_tip_type: u64,
}
#[derive(Message)]
pub struct EffectConfig {
#[prost(uint64, tag = "1")]
pub r#type: u64,
#[prost(message, optional, tag = "2")]
pub icon: Option<Image>,
#[prost(uint64, tag = "3")]
pub avatar_pos: u64,
#[prost(message, optional, tag = "4")]
pub text: Option<Text>,
#[prost(message, optional, tag = "5")]
pub text_icon: Option<Image>,
#[prost(uint32, tag = "6")]
pub stay_time: u32,
#[prost(uint64, tag = "7")]
pub anim_asset_id: u64,
#[prost(message, optional, tag = "8")]
pub badge: Option<Image>,
#[prost(uint64, repeated, tag = "9")]
pub flex_setting_array_list: Vec<u64>,
#[prost(message, optional, tag = "10")]
pub text_icon_overlay: Option<Image>,
#[prost(message, optional, tag = "11")]
pub animated_badge: Option<Image>,
#[prost(bool, tag = "12")]
pub has_sweep_light: bool,
#[prost(uint64, repeated, tag = "13")]
pub text_flex_setting_array_list: Vec<u64>,
#[prost(uint64, tag = "14")]
pub center_anim_asset_id: u64,
#[prost(message, optional, tag = "15")]
pub dynamic_image: Option<Image>,
#[prost(map = "string, string", tag = "16")]
pub extra_map: HashMap<String, String>,
#[prost(uint64, tag = "17")]
pub mp4_anim_asset_id: u64,
#[prost(uint64, tag = "18")]
pub priority: u64,
#[prost(uint64, tag = "19")]
pub max_wait_time: u64,
#[prost(string, tag = "20")]
pub dress_id: String,
#[prost(uint64, tag = "21")]
pub alignment: u64,
#[prost(uint64, tag = "22")]
pub alignment_offset: u64,
}
// message PushFrame {
// uint64 seqId = 1;
// uint64 logId = 2;
// uint64 service = 3;
// uint64 method = 4;
// repeated HeadersList headersList = 5;
// string payloadEncoding = 6;
// string payloadType = 7;
// bytes payload = 8;
// }
#[derive(Message)]
pub struct PushFrame {
#[prost(uint64, tag = "1")]
pub seq_id: u64,
#[prost(uint64, tag = "2")]
pub log_id: u64,
#[prost(uint64, tag = "3")]
pub service: u64,
#[prost(uint64, tag = "4")]
pub method: u64,
#[prost(message, repeated, tag = "5")]
pub headers_list: Vec<HeadersList>,
#[prost(string, tag = "6")]
pub payload_encoding: String,
#[prost(string, tag = "7")]
pub payload_type: String,
#[prost(bytes, tag = "8")]
pub payload: Vec<u8>,
}
// message HeadersList {
// string key = 1;
// string value = 2;
// }
#[derive(Message)]
pub struct HeadersList {
#[prost(string, tag = "1")]
pub key: String,
#[prost(string, tag = "2")]
pub value: String,
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default","dialog:default"]}}
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*","Clip*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default","dialog:default"]}}

View File

@@ -15,14 +15,22 @@ pub struct Config {
pub post_notify: bool,
#[serde(default = "default_auto_subtitle")]
pub auto_subtitle: bool,
#[serde(default = "default_subtitle_generator_type")]
pub subtitle_generator_type: String,
#[serde(default = "default_whisper_model")]
pub whisper_model: String,
#[serde(default = "default_whisper_prompt")]
pub whisper_prompt: String,
#[serde(default = "default_openai_api_endpoint")]
pub openai_api_endpoint: String,
#[serde(default = "default_openai_api_key")]
pub openai_api_key: String,
#[serde(default = "default_clip_name_format")]
pub clip_name_format: String,
#[serde(default = "default_auto_generate_config")]
pub auto_generate: AutoGenerateConfig,
#[serde(default = "default_status_check_interval")]
pub status_check_interval: u64,
#[serde(skip)]
pub config_path: String,
}
@@ -37,6 +45,10 @@ fn default_auto_subtitle() -> bool {
false
}
fn default_subtitle_generator_type() -> String {
"whisper".to_string()
}
fn default_whisper_model() -> String {
"whisper_model.bin".to_string()
}
@@ -45,6 +57,14 @@ fn default_whisper_prompt() -> String {
"这是一段中文 你们好".to_string()
}
fn default_openai_api_endpoint() -> String {
"https://api.openai.com/v1".to_string()
}
fn default_openai_api_key() -> String {
"".to_string()
}
fn default_clip_name_format() -> String {
"[{room_id}][{live_id}][{title}][{created_at}].mp4".to_string()
}
@@ -56,11 +76,15 @@ fn default_auto_generate_config() -> AutoGenerateConfig {
}
}
fn default_status_check_interval() -> u64 {
30
}
impl Config {
pub fn load(
config_path: &PathBuf,
default_cache: &PathBuf,
default_output: &PathBuf,
default_cache: &Path,
default_output: &Path,
) -> Result<Self, String> {
if let Ok(content) = std::fs::read_to_string(config_path) {
if let Ok(mut config) = toml::from_str::<Config>(&content) {
@@ -83,13 +107,19 @@ impl Config {
clip_notify: true,
post_notify: true,
auto_subtitle: false,
whisper_model: "whisper_model.bin".to_string(),
whisper_prompt: "这是一段中文 你们好".to_string(),
clip_name_format: "[{room_id}][{live_id}][{title}][{created_at}].mp4".to_string(),
subtitle_generator_type: default_subtitle_generator_type(),
whisper_model: default_whisper_model(),
whisper_prompt: default_whisper_prompt(),
openai_api_endpoint: default_openai_api_endpoint(),
openai_api_key: default_openai_api_key(),
clip_name_format: default_clip_name_format(),
auto_generate: default_auto_generate_config(),
status_check_interval: default_status_check_interval(),
config_path: config_path.to_str().unwrap().into(),
};
config.save();
Ok(config)
}
@@ -100,11 +130,13 @@ impl Config {
}
}
#[allow(dead_code)]
pub fn set_cache_path(&mut self, path: &str) {
self.cache = path.to_string();
self.save();
}
#[allow(dead_code)]
pub fn set_output_path(&mut self, path: &str) {
self.output = path.into();
self.save();

View File

@@ -7,6 +7,7 @@ pub mod account;
pub mod message;
pub mod record;
pub mod recorder;
pub mod task;
pub mod video;
pub struct Database {

View File

@@ -3,6 +3,7 @@ use crate::recorder::PlatformType;
use super::Database;
use super::DatabaseError;
use chrono::Utc;
use rand::seq::SliceRandom;
use rand::Rng;
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
@@ -19,7 +20,11 @@ pub struct AccountRow {
// accounts
impl Database {
// CREATE TABLE accounts (uid INTEGER PRIMARY KEY, name TEXT, avatar TEXT, csrf TEXT, cookies TEXT, created_at TEXT);
pub async fn add_account(&self, platform: &str, cookies: &str) -> Result<AccountRow, DatabaseError> {
pub async fn add_account(
&self,
platform: &str,
cookies: &str,
) -> Result<AccountRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let platform = PlatformType::from_str(platform).unwrap();
@@ -100,13 +105,15 @@ impl Database {
avatar: &str,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3 and platform = $4")
.bind(name)
.bind(avatar)
.bind(uid as i64)
.bind(platform)
.execute(&lock)
.await?;
let sql = sqlx::query(
"UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3 and platform = $4",
)
.bind(name)
.bind(avatar)
.bind(uid as i64)
.bind(platform)
.execute(&lock)
.await?;
if sql.rows_affected() != 1 {
return Err(DatabaseError::NotFoundError);
}
@@ -122,20 +129,30 @@ impl Database {
pub async fn get_account(&self, platform: &str, uid: u64) -> Result<AccountRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(
sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE uid = $1 and platform = $2")
.bind(uid as i64)
.bind(platform)
.fetch_one(&lock)
.await?,
Ok(sqlx::query_as::<_, AccountRow>(
"SELECT * FROM accounts WHERE uid = $1 and platform = $2",
)
.bind(uid as i64)
.bind(platform)
.fetch_one(&lock)
.await?)
}
pub async fn get_account_by_platform(&self, platform: &str) -> Result<AccountRow, DatabaseError> {
pub async fn get_account_by_platform(
&self,
platform: &str,
) -> Result<AccountRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE platform = $1")
.bind(platform)
.fetch_one(&lock)
.await?)
let accounts =
sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE platform = $1")
.bind(platform)
.fetch_all(&lock)
.await?;
if accounts.is_empty() {
return Err(DatabaseError::NotFoundError);
}
// randomly select one account
let account = accounts.choose(&mut rand::thread_rng()).unwrap();
Ok(account.clone())
}
}

View File

@@ -0,0 +1,86 @@
use super::Database;
use super::DatabaseError;
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct TaskRow {
pub id: String,
#[sqlx(rename = "type")]
pub task_type: String,
pub status: String,
pub message: String,
pub metadata: String,
pub created_at: String,
}
impl Database {
pub async fn add_task(&self, task: &TaskRow) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let _ = sqlx::query(
"INSERT INTO tasks (id, type, status, message, metadata, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(&task.id)
.bind(&task.task_type)
.bind(&task.status)
.bind(&task.message)
.bind(&task.metadata)
.bind(&task.created_at)
.execute(&lock)
.await?;
Ok(())
}
pub async fn get_tasks(&self) -> Result<Vec<TaskRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let tasks = sqlx::query_as::<_, TaskRow>("SELECT * FROM tasks")
.fetch_all(&lock)
.await?;
Ok(tasks)
}
pub async fn update_task(
&self,
id: &str,
status: &str,
message: &str,
metadata: Option<&str>,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
if let Some(metadata) = metadata {
let _ = sqlx::query(
"UPDATE tasks SET status = $1, message = $2, metadata = $3 WHERE id = $4",
)
.bind(status)
.bind(message)
.bind(metadata)
.bind(id)
.execute(&lock)
.await?;
} else {
let _ = sqlx::query("UPDATE tasks SET status = $1, message = $2 WHERE id = $3")
.bind(status)
.bind(message)
.bind(id)
.execute(&lock)
.await?;
}
Ok(())
}
pub async fn delete_task(&self, id: &str) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let _ = sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(id)
.execute(&lock)
.await?;
Ok(())
}
pub async fn finish_pending_tasks(&self) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let _ = sqlx::query("UPDATE tasks SET status = 'failed' WHERE status = 'pending'")
.execute(&lock)
.await?;
Ok(())
}
}

View File

@@ -17,17 +17,34 @@ pub struct VideoRow {
pub tags: String,
pub area: i64,
pub created_at: String,
pub platform: String,
}
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct VideoNoCover {
pub id: i64,
pub room_id: u64,
pub file: String,
pub length: i64,
pub size: i64,
pub status: i64,
pub bvid: String,
pub title: String,
pub desc: String,
pub tags: String,
pub area: i64,
pub created_at: String,
pub platform: String,
}
impl Database {
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoNoCover>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
.bind(room_id as i64)
.fetch_all(&lock)
.await?,
)
let videos = sqlx::query_as::<_, VideoNoCover>("SELECT * FROM videos WHERE room_id = $1;")
.bind(room_id as i64)
.fetch_all(&lock)
.await?;
Ok(videos)
}
pub async fn get_video(&self, id: i64) -> Result<VideoRow, DatabaseError> {
@@ -66,7 +83,7 @@ impl Database {
pub async fn add_video(&self, video: &VideoRow) -> Result<VideoRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, length, size, status, bvid, title, desc, tags, area, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)")
let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, length, size, status, bvid, title, desc, tags, area, created_at, platform) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)")
.bind(video.room_id as i64)
.bind(&video.cover)
.bind(&video.file)
@@ -79,6 +96,7 @@ impl Database {
.bind(&video.tags)
.bind(video.area)
.bind(&video.created_at)
.bind(&video.platform)
.execute(&lock)
.await?;
let video = VideoRow {
@@ -97,4 +115,22 @@ impl Database {
.await?;
Ok(())
}
pub async fn get_all_videos(&self) -> Result<Vec<VideoNoCover>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let videos =
sqlx::query_as::<_, VideoNoCover>("SELECT * FROM videos ORDER BY created_at DESC;")
.fetch_all(&lock)
.await?;
Ok(videos)
}
pub async fn get_video_cover(&self, id: i64) -> Result<String, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let video = sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
.bind(id)
.fetch_one(&lock)
.await?;
Ok(video.cover)
}
}

View File

@@ -51,7 +51,7 @@ pub async fn clip_from_m3u8(
}
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(_level, content) => {
log::info!("{}", content);
log::debug!("{}", content);
}
FfmpegEvent::Error(e) => {
log::error!("Clip error: {}", e);
@@ -107,7 +107,7 @@ pub async fn extract_audio(file: &Path) -> Result<(), String> {
}
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(_level, content) => {
log::info!("{}", content);
log::debug!("{}", content);
}
_ => {}
}
@@ -195,7 +195,7 @@ pub async fn encode_video_subtitle(
}
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(_level, content) => {
log::info!("{}", content);
log::debug!("{}", content);
}
_ => {}
}
@@ -282,7 +282,7 @@ pub async fn encode_video_danmu(
.update(format!("压制中:{}", p.time).as_str());
}
FfmpegEvent::Log(_level, content) => {
log::info!("{}", content);
log::debug!("{}", content);
}
FfmpegEvent::LogEOF => break,
_ => {}
@@ -335,10 +335,10 @@ pub async fn check_ffmpeg() -> Result<String, String> {
}
}
if version.is_none() {
Err("Failed to parse version from output".into())
if let Some(version) = version {
Ok(version)
} else {
Ok(version.unwrap())
Err("Failed to parse version from output".into())
}
}
@@ -348,5 +348,5 @@ fn ffmpeg_path() -> PathBuf {
path.set_extension("exe");
}
return path;
path
}

View File

@@ -11,6 +11,7 @@ pub async fn get_config(state: state_type!()) -> Result<Config, ()> {
}
#[cfg_attr(feature = "gui", tauri::command)]
#[allow(dead_code)]
pub async fn set_cache_path(state: state_type!(), cache_path: String) -> Result<(), String> {
let old_cache_path = state.config.read().await.cache.clone();
if old_cache_path == cache_path {
@@ -77,6 +78,7 @@ pub async fn set_cache_path(state: state_type!(), cache_path: String) -> Result<
}
#[cfg_attr(feature = "gui", tauri::command)]
#[allow(dead_code)]
pub async fn set_output_path(state: state_type!(), output_path: String) -> Result<(), ()> {
let mut config = state.config.write().await;
let old_output_path = config.output.clone();
@@ -170,6 +172,42 @@ pub async fn update_whisper_prompt(state: state_type!(), whisper_prompt: String)
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn update_subtitle_generator_type(
state: state_type!(),
subtitle_generator_type: String,
) -> Result<(), ()> {
log::info!(
"Updating subtitle generator type to {}",
subtitle_generator_type
);
let mut config = state.config.write().await;
config.subtitle_generator_type = subtitle_generator_type;
config.save();
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn update_openai_api_key(state: state_type!(), openai_api_key: String) -> Result<(), ()> {
log::info!("Updating openai api key");
let mut config = state.config.write().await;
config.openai_api_key = openai_api_key;
config.save();
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn update_openai_api_endpoint(
state: state_type!(),
openai_api_endpoint: String,
) -> Result<(), ()> {
log::info!("Updating openai api endpoint to {}", openai_api_endpoint);
let mut config = state.config.write().await;
config.openai_api_endpoint = openai_api_endpoint;
config.save();
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn update_auto_generate(
state: state_type!(),
@@ -182,3 +220,17 @@ pub async fn update_auto_generate(
config.save();
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn update_status_check_interval(
state: state_type!(),
mut interval: u64,
) -> Result<(), ()> {
if interval < 10 {
interval = 10; // Minimum interval of 10 seconds
}
log::info!("Updating status check interval to {} seconds", interval);
state.config.write().await.status_check_interval = interval;
state.config.write().await.save();
Ok(())
}

View File

@@ -3,6 +3,7 @@ pub mod config;
pub mod macros;
pub mod message;
pub mod recorder;
pub mod task;
pub mod utils;
pub mod video;

View File

@@ -79,6 +79,7 @@ pub async fn remove_recorder(
platform: String,
room_id: u64,
) -> Result<(), String> {
log::info!("Remove recorder: {} {}", platform, room_id);
let platform = PlatformType::from_str(&platform).unwrap();
match state
.recorder_manager
@@ -90,9 +91,13 @@ pub async fn remove_recorder(
.db
.new_message("移除直播间", &format!("移除了直播间 {}", room_id))
.await?;
Ok(state.db.remove_recorder(room_id).await?)
log::info!("Removed recorder: {} {}", platform.as_str(), room_id);
Ok(())
}
Err(e) => {
log::error!("Failed to remove recorder: {}", e);
Err(e.to_string())
}
Err(e) => Err(e.to_string()),
}
}
@@ -254,45 +259,21 @@ pub async fn get_recent_record(
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn set_auto_start(
pub async fn set_enable(
state: state_type!(),
platform: String,
room_id: u64,
auto_start: bool,
enabled: bool,
) -> Result<(), String> {
log::info!("Set auto-start for recorder {platform} {room_id} {auto_start}");
log::info!("Set enable for recorder {platform} {room_id} {enabled}");
let platform = PlatformType::from_str(&platform).unwrap();
state
.recorder_manager
.set_auto_start(platform, room_id, auto_start)
.set_enable(platform, room_id, enabled)
.await;
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn force_start(
state: state_type!(),
platform: String,
room_id: u64,
) -> Result<(), String> {
log::info!("Force start recorder {platform} {room_id}");
let platform = PlatformType::from_str(&platform).unwrap();
state.recorder_manager.force_start(platform, room_id).await;
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn force_stop(
state: state_type!(),
platform: String,
room_id: u64,
) -> Result<(), String> {
log::info!("Force stop recorder {platform} {room_id}");
let platform = PlatformType::from_str(&platform).unwrap();
state.recorder_manager.force_stop(platform, room_id).await;
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn fetch_hls(state: state_type!(), uri: String) -> Result<Vec<u8>, String> {
// Handle wildcard pattern in the URI

View File

@@ -0,0 +1,15 @@
#[cfg(feature = "gui")]
use tauri::State as TauriState;
use crate::state::State;
use crate::{database::task::TaskRow, state_type};
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_tasks(state: state_type!()) -> Result<Vec<TaskRow>, String> {
Ok(state.db.get_tasks().await?)
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn delete_task(state: state_type!(), id: &str) -> Result<(), String> {
Ok(state.db.delete_task(id).await?)
}

View File

@@ -14,6 +14,7 @@ use {
tokio::io::AsyncWriteExt,
};
#[allow(dead_code)]
pub fn copy_dir_all(
src: impl AsRef<std::path::Path>,
dst: impl AsRef<std::path::Path>,
@@ -88,7 +89,7 @@ pub fn show_in_folder(path: String) {
pub struct DiskInfo {
disk: String,
total: u64,
free: u64,
pub free: u64,
}
#[cfg_attr(feature = "gui", tauri::command)]
@@ -101,11 +102,27 @@ pub async fn get_disk_info(state: state_type!()) -> Result<DiskInfo, ()> {
let cwd = std::env::current_dir().unwrap();
cache = cwd.join(cache);
}
get_disk_info_inner(cache).await
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn console_log(_state: state_type!(), level: &str, message: &str) -> Result<(), ()> {
match level {
"error" => log::error!("[frontend] {}", message),
"warn" => log::warn!("[frontend] {}", message),
"info" => log::info!("[frontend] {}", message),
_ => log::debug!("[frontend] {}", message),
}
Ok(())
}
pub async fn get_disk_info_inner(target: PathBuf) -> Result<DiskInfo, ()> {
#[cfg(target_os = "linux")]
{
// get disk info from df command
let output = tokio::process::Command::new("df")
.arg(cache)
.arg(target)
.output()
.await
.unwrap();
@@ -129,7 +146,7 @@ pub async fn get_disk_info(state: state_type!()) -> Result<DiskInfo, ()> {
{
// check system disk info
let disks = sysinfo::Disks::new_with_refreshed_list();
// get cache disk info
// get target disk info
let mut disk_info = DiskInfo {
disk: "".into(),
total: 0,
@@ -140,7 +157,7 @@ pub async fn get_disk_info(state: state_type!()) -> Result<DiskInfo, ()> {
let mut longest_match = 0;
for disk in disks.list() {
let mount_point = disk.mount_point().to_str().unwrap();
if cache.starts_with(mount_point) && mount_point.split("/").count() > longest_match {
if target.starts_with(mount_point) && mount_point.split("/").count() > longest_match {
disk_info.disk = mount_point.into();
disk_info.total = disk.total_space();
disk_info.free = disk.available_space();
@@ -211,7 +228,7 @@ pub async fn open_live(
format!("Live:{}:{}", room_id, live_id),
tauri::WebviewUrl::App(
format!(
"live_index.html?platform={}&room_id={}&live_id={}",
"index_live.html?platform={}&room_id={}&live_id={}",
platform.as_str(),
room_id,
live_id
@@ -242,3 +259,32 @@ pub async fn open_live(
Ok(())
}
#[cfg(feature = "gui")]
#[tauri::command]
pub async fn open_clip(state: state_type!(), video_id: i64) -> Result<(), String> {
log::info!("Open clip window: {}", video_id);
let builder = tauri::WebviewWindowBuilder::new(
&state.app_handle,
format!("Clip:{}", video_id),
tauri::WebviewUrl::App(format!("index_clip.html?id={}", video_id).into()),
)
.title(format!("Clip window:{}", video_id))
.theme(Some(Theme::Light))
.inner_size(1200.0, 800.0)
.effects(WindowEffectsConfig {
effects: vec![
tauri_utils::WindowEffect::Tabbed,
tauri_utils::WindowEffect::Mica,
],
state: None,
radius: None,
color: None,
});
if let Err(e) = builder.decorations(true).build() {
log::error!("clip window build failed: {}", e);
}
Ok(())
}

View File

@@ -1,14 +1,18 @@
use crate::database::video::VideoRow;
use crate::database::task::TaskRow;
use crate::database::video::{VideoNoCover, VideoRow};
use crate::ffmpeg;
use crate::handlers::utils::get_disk_info_inner;
use crate::progress_reporter::{
cancel_progress, EventEmitter, ProgressReporter, ProgressReporterTrait,
};
use crate::recorder::bilibili::profile::Profile;
use crate::recorder_manager::ClipRangeParams;
use crate::subtitle_generator::whisper::{self};
use crate::subtitle_generator::SubtitleGenerator;
use crate::subtitle_generator::whisper_cpp;
use crate::subtitle_generator::whisper_online;
use crate::subtitle_generator::{GenerateResult, SubtitleGenerator};
use chrono::Utc;
use std::path::Path;
use serde_json::json;
use std::path::{Path, PathBuf};
use crate::state::State;
use crate::state_type;
@@ -22,25 +26,70 @@ pub async fn clip_range(
event_id: String,
params: ClipRangeParams,
) -> Result<VideoRow, String> {
// check storage space, preserve 1GB for other usage
let output = state.config.read().await.output.clone();
let mut output = PathBuf::from(&output);
if output.is_relative() {
// get current working directory
let cwd = std::env::current_dir().unwrap();
output = cwd.join(output);
}
if let Ok(disk_info) = get_disk_info_inner(output).await {
// if free space is less than 1GB, return error
if disk_info.free < 1024 * 1024 * 1024 {
return Err("Storage space is not enough, clip canceled".to_string());
}
}
#[cfg(feature = "gui")]
let emitter = EventEmitter::new(state.app_handle.clone());
#[cfg(feature = "headless")]
let emitter = EventEmitter::new(state.progress_manager.get_event_sender());
let reporter = ProgressReporter::new(&emitter, &event_id).await?;
match clip_range_inner(state, &reporter, params).await {
let mut params_without_cover = params.clone();
params_without_cover.cover = "".to_string();
let task = TaskRow {
id: event_id.clone(),
task_type: "clip_range".to_string(),
status: "pending".to_string(),
message: "".to_string(),
metadata: json!({
"params": params_without_cover,
})
.to_string(),
created_at: Utc::now().to_rfc3339(),
};
state.db.add_task(&task).await?;
log::info!("Create task: {} {}", task.id, task.task_type);
match clip_range_inner(&state, &reporter, params).await {
Ok(video) => {
reporter.finish(true, "切片完成").await;
state
.db
.update_task(&event_id, "success", "切片完成", None)
.await?;
if state.config.read().await.auto_subtitle {
// generate a subtitle task event id
let subtitle_event_id = format!("{}_subtitle", event_id);
let result = generate_video_subtitle(state, subtitle_event_id, video.id).await;
if let Err(e) = result {
log::error!("Generate video subtitle error: {}", e);
}
}
Ok(video)
}
Err(e) => {
reporter.finish(false, &format!("切片失败: {}", e)).await;
state
.db
.update_task(&event_id, "failed", &format!("切片失败: {}", e), None)
.await?;
Err(e)
}
}
}
async fn clip_range_inner(
state: state_type!(),
state: &state_type!(),
reporter: &ProgressReporter,
params: ClipRangeParams,
) -> Result<VideoRow, String> {
@@ -88,27 +137,9 @@ async fn clip_range_inner(
desc: "".into(),
tags: "".into(),
area: 0,
platform: params.platform.clone(),
})
.await?;
if state.config.read().await.auto_subtitle
&& !state.config.read().await.whisper_model.is_empty()
{
log::info!("Auto subtitle enabled");
if let Ok(generator) = whisper::new(
Path::new(&state.config.read().await.whisper_model),
&state.config.read().await.whisper_prompt,
)
.await
{
reporter.update("提取音频中");
let audio_path = file.with_extension("wav");
ffmpeg::extract_audio(&file).await?;
reporter.update("生成字幕中");
generator
.generate_subtitle(reporter, &audio_path, &file.with_extension("srt"))
.await?;
}
}
state
.db
.new_message(
@@ -156,20 +187,44 @@ pub async fn upload_procedure(
#[cfg(feature = "headless")]
let emitter = EventEmitter::new(state.progress_manager.get_event_sender());
let reporter = ProgressReporter::new(&emitter, &event_id).await?;
match upload_procedure_inner(state, &reporter, uid, room_id, video_id, cover, profile).await {
let task = TaskRow {
id: event_id.clone(),
task_type: "upload_procedure".to_string(),
status: "pending".to_string(),
message: "".to_string(),
metadata: json!({
"uid": uid,
"room_id": room_id,
"video_id": video_id,
"profile": profile,
})
.to_string(),
created_at: Utc::now().to_rfc3339(),
};
state.db.add_task(&task).await?;
log::info!("Create task: {:?}", task);
match upload_procedure_inner(&state, &reporter, uid, room_id, video_id, cover, profile).await {
Ok(bvid) => {
reporter.finish(true, "投稿成功").await;
state
.db
.update_task(&event_id, "success", "投稿成功", None)
.await?;
Ok(bvid)
}
Err(e) => {
reporter.finish(false, &format!("投稿失败: {}", e)).await;
state
.db
.update_task(&event_id, "failed", &format!("投稿失败: {}", e), None)
.await?;
Err(e)
}
}
}
async fn upload_procedure_inner(
state: state_type!(),
state: &state_type!(),
reporter: &ProgressReporter,
uid: u64,
room_id: u64,
@@ -246,8 +301,26 @@ pub async fn get_video(state: state_type!(), id: i64) -> Result<VideoRow, String
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_videos(state: state_type!(), room_id: u64) -> Result<Vec<VideoRow>, String> {
Ok(state.db.get_videos(room_id).await?)
pub async fn get_videos(state: state_type!(), room_id: u64) -> Result<Vec<VideoNoCover>, String> {
state
.db
.get_videos(room_id)
.await
.map_err(|e| e.to_string())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_all_videos(state: state_type!()) -> Result<Vec<VideoNoCover>, String> {
state.db.get_all_videos().await.map_err(|e| e.to_string())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_video_cover(state: state_type!(), id: i64) -> Result<String, String> {
state
.db
.get_video_cover(id)
.await
.map_err(|e| e.to_string())
}
#[cfg_attr(feature = "gui", tauri::command)]
@@ -316,43 +389,109 @@ pub async fn generate_video_subtitle(
#[cfg(feature = "headless")]
let emitter = EventEmitter::new(state.progress_manager.get_event_sender());
let reporter = ProgressReporter::new(&emitter, &event_id).await?;
match generate_video_subtitle_inner(state, &reporter, id).await {
Ok(subtitle) => {
let task = TaskRow {
id: event_id.clone(),
task_type: "generate_video_subtitle".to_string(),
status: "pending".to_string(),
message: "".to_string(),
metadata: json!({
"video_id": id,
})
.to_string(),
created_at: Utc::now().to_rfc3339(),
};
state.db.add_task(&task).await?;
log::info!("Create task: {:?}", task);
match generate_video_subtitle_inner(&state, &reporter, id).await {
Ok(result) => {
reporter.finish(true, "字幕生成完成").await;
Ok(subtitle)
// for local whisper, we need to update the task status to success
state
.db
.update_task(
&event_id,
"success",
"字幕生成完成",
Some(
json!({
"task_id": result.subtitle_id,
"service": result.generator_type.as_str(),
})
.to_string()
.as_str(),
),
)
.await?;
Ok(result.subtitle_content)
}
Err(e) => {
reporter
.finish(false, &format!("字幕生成失败: {}", e))
.await;
state
.db
.update_task(&event_id, "failed", &format!("字幕生成失败: {}", e), None)
.await?;
Err(e)
}
}
}
async fn generate_video_subtitle_inner(
state: state_type!(),
state: &state_type!(),
reporter: &ProgressReporter,
id: i64,
) -> Result<String, String> {
) -> Result<GenerateResult, String> {
let video = state.db.get_video(id).await?;
let filepath = Path::new(state.config.read().await.output.as_str()).join(&video.file);
let file = Path::new(&filepath);
if let Ok(generator) = whisper::new(
Path::new(&state.config.read().await.whisper_model),
&state.config.read().await.whisper_prompt,
)
.await
{
let audio_path = file.with_extension("wav");
ffmpeg::extract_audio(file).await?;
let config = state.config.read().await;
let generator_type = config.subtitle_generator_type.as_str();
let subtitle = generator
.generate_subtitle(reporter, &audio_path, &file.with_extension("srt"))
.await?;
Ok(subtitle)
} else {
Err("Whisper model not found".to_string())
match generator_type {
"whisper" => {
if config.whisper_model.is_empty() {
return Err("Whisper model not configured".to_string());
}
if let Ok(generator) =
whisper_cpp::new(Path::new(&config.whisper_model), &config.whisper_prompt).await
{
let audio_path = file.with_extension("wav");
ffmpeg::extract_audio(file).await?;
let result = generator
.generate_subtitle(reporter, &audio_path, &file.with_extension("srt"))
.await?;
Ok(result)
} else {
Err("Failed to initialize Whisper model".to_string())
}
}
"whisper_online" => {
if config.openai_api_key.is_empty() {
return Err("API key not configured".to_string());
}
if let Ok(generator) = whisper_online::new(
Some(&config.openai_api_endpoint),
Some(&config.openai_api_key),
)
.await
{
let audio_path = file.with_extension("wav");
ffmpeg::extract_audio(file).await?;
let result = generator
.generate_subtitle(reporter, &audio_path, &file.with_extension("srt"))
.await?;
Ok(result)
} else {
Err("Failed to initialize Whisper Online".to_string())
}
}
_ => Err(format!(
"Unknown subtitle generator type: {}",
generator_type
)),
}
}
@@ -384,22 +523,44 @@ pub async fn encode_video_subtitle(
#[cfg(feature = "headless")]
let emitter = EventEmitter::new(state.progress_manager.get_event_sender());
let reporter = ProgressReporter::new(&emitter, &event_id).await?;
match encode_video_subtitle_inner(state, &reporter, id, srt_style).await {
let task = TaskRow {
id: event_id.clone(),
task_type: "encode_video_subtitle".to_string(),
status: "pending".to_string(),
message: "".to_string(),
metadata: json!({
"video_id": id,
"srt_style": srt_style,
})
.to_string(),
created_at: Utc::now().to_rfc3339(),
};
state.db.add_task(&task).await?;
log::info!("Create task: {:?}", task);
match encode_video_subtitle_inner(&state, &reporter, id, srt_style).await {
Ok(video) => {
reporter.finish(true, "字幕编码完成").await;
state
.db
.update_task(&event_id, "success", "字幕编码完成", None)
.await?;
Ok(video)
}
Err(e) => {
reporter
.finish(false, &format!("字幕编码失败: {}", e))
.await;
state
.db
.update_task(&event_id, "failed", &format!("字幕编码失败: {}", e), None)
.await?;
Err(e)
}
}
}
async fn encode_video_subtitle_inner(
state: state_type!(),
state: &state_type!(),
reporter: &ProgressReporter,
id: i64,
srt_style: String,
@@ -427,6 +588,7 @@ async fn encode_video_subtitle_inner(
desc: video.desc.clone(),
tags: video.tags.clone(),
area: video.area,
platform: video.platform,
})
.await?;

View File

@@ -3,30 +3,36 @@ use std::fmt::{self, Display};
use crate::{
config::Config,
database::{
account::AccountRow, message::MessageRow, record::RecordRow, recorder::RecorderRow,
video::VideoRow,
account::AccountRow,
message::MessageRow,
record::RecordRow,
recorder::RecorderRow,
task::TaskRow,
video::{VideoNoCover, VideoRow},
},
handlers::{
account::{
add_account, get_account_count, get_accounts, get_qr, get_qr_status, remove_account,
},
config::{
get_config, set_cache_path, set_output_path, update_auto_generate,
update_clip_name_format, update_notify, update_subtitle_setting, update_whisper_model,
get_config, update_auto_generate, update_clip_name_format, update_notify,
update_openai_api_endpoint, update_openai_api_key, update_status_check_interval,
update_subtitle_generator_type, update_subtitle_setting, update_whisper_model,
update_whisper_prompt,
},
message::{delete_message, get_messages, read_message},
recorder::{
add_recorder, delete_archive, export_danmu, fetch_hls, force_start, force_stop,
get_archive, get_archives, get_danmu_record, get_recent_record, get_recorder_list,
get_room_info, get_today_record_count, get_total_length, remove_recorder, send_danmaku,
set_auto_start, ExportDanmuOptions,
add_recorder, delete_archive, export_danmu, fetch_hls, get_archive, get_archives,
get_danmu_record, get_recent_record, get_recorder_list, get_room_info,
get_today_record_count, get_total_length, remove_recorder, send_danmaku, set_enable,
ExportDanmuOptions,
},
utils::{get_disk_info, DiskInfo},
task::{delete_task, get_tasks},
utils::{console_log, get_disk_info, DiskInfo},
video::{
cancel, clip_range, delete_video, encode_video_subtitle, generate_video_subtitle,
get_video, get_video_subtitle, get_video_typelist, get_videos, update_video_cover,
update_video_subtitle, upload_procedure,
get_all_videos, get_video, get_video_cover, get_video_subtitle, get_video_typelist,
get_videos, update_video_cover, update_video_subtitle, upload_procedure,
},
AccountInfo,
},
@@ -190,33 +196,17 @@ async fn handler_get_config(
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetCachePathRequest {
cache_path: String,
struct UpdateStatusCheckIntervalRequest {
interval: u64,
}
async fn handler_set_cache_path(
async fn handler_update_status_check_interval(
state: axum::extract::State<State>,
Json(cache_path): Json<SetCachePathRequest>,
Json(request): Json<UpdateStatusCheckIntervalRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
set_cache_path(state.0, cache_path.cache_path)
update_status_check_interval(state.0, request.interval)
.await
.expect("Failed to set cache path");
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetOutputPathRequest {
output_path: String,
}
async fn handler_set_output_path(
state: axum::extract::State<State>,
Json(output_path): Json<SetOutputPathRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
set_output_path(state.0, output_path.output_path)
.await
.expect("Failed to set output path");
.expect("Failed to update status check interval");
Ok(Json(ApiResponse::success(())))
}
@@ -309,6 +299,54 @@ async fn handler_update_whisper_prompt(
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateSubtitleGeneratorTypeRequest {
subtitle_generator_type: String,
}
async fn handler_update_subtitle_generator_type(
state: axum::extract::State<State>,
Json(param): Json<UpdateSubtitleGeneratorTypeRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
update_subtitle_generator_type(state.0, param.subtitle_generator_type)
.await
.expect("Failed to update subtitle generator type");
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateOpenaiApiEndpointRequest {
openai_api_endpoint: String,
}
async fn handler_update_openai_api_endpoint(
state: axum::extract::State<State>,
Json(param): Json<UpdateOpenaiApiEndpointRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
update_openai_api_endpoint(state.0, param.openai_api_endpoint)
.await
.expect("Failed to update openai api endpoint");
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateOpenaiApiKeyRequest {
openai_api_key: String,
}
async fn handler_update_openai_api_key(
state: axum::extract::State<State>,
Json(param): Json<UpdateOpenaiApiKeyRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
update_openai_api_key(state.0, param.openai_api_key)
.await
.expect("Failed to update openai api key");
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateAutoGenerateRequest {
@@ -532,46 +570,17 @@ async fn handler_get_recent_record(
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetAutoStartRequest {
struct SetEnableRequest {
platform: String,
room_id: u64,
auto_start: bool,
enabled: bool,
}
async fn handler_set_auto_start(
async fn handler_set_enable(
state: axum::extract::State<State>,
Json(param): Json<SetAutoStartRequest>,
Json(param): Json<SetEnableRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
set_auto_start(state.0, param.platform, param.room_id, param.auto_start).await?;
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ForceStartRequest {
platform: String,
room_id: u64,
}
async fn handler_force_start(
state: axum::extract::State<State>,
Json(param): Json<ForceStartRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
force_start(state.0, param.platform, param.room_id).await?;
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ForceStopRequest {
platform: String,
room_id: u64,
}
async fn handler_force_stop(
state: axum::extract::State<State>,
Json(param): Json<ForceStopRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
force_stop(state.0, param.platform, param.room_id).await?;
set_enable(state.0, param.platform, param.room_id, param.enabled).await?;
Ok(Json(ApiResponse::success(())))
}
@@ -651,14 +660,36 @@ async fn handler_get_video(
struct GetVideosRequest {
room_id: u64,
}
async fn handler_get_videos(
state: axum::extract::State<State>,
Json(param): Json<GetVideosRequest>,
) -> Result<Json<ApiResponse<Vec<VideoRow>>>, ApiError> {
) -> Result<Json<ApiResponse<Vec<VideoNoCover>>>, ApiError> {
let videos = get_videos(state.0, param.room_id).await?;
Ok(Json(ApiResponse::success(videos)))
}
async fn handler_get_all_videos(
state: axum::extract::State<State>,
) -> Result<Json<ApiResponse<Vec<VideoNoCover>>>, ApiError> {
let videos = get_all_videos(state.0).await?;
Ok(Json(ApiResponse::success(videos)))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GetVideoCoverRequest {
id: i64,
}
async fn handler_get_video_cover(
state: axum::extract::State<State>,
Json(param): Json<GetVideoCoverRequest>,
) -> Result<Json<ApiResponse<String>>, ApiError> {
let video_cover = get_video_cover(state.0, param.id).await?;
Ok(Json(ApiResponse::success(video_cover)))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeleteVideoRequest {
@@ -762,6 +793,14 @@ async fn handler_encode_video_subtitle(
encode_video_subtitle_param.event_id,
)))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ConsoleLogRequest {
level: String,
message: String,
}
async fn handler_get_disk_info(
state: axum::extract::State<State>,
) -> Result<Json<ApiResponse<DiskInfo>>, ApiError> {
@@ -771,6 +810,14 @@ async fn handler_get_disk_info(
Ok(Json(ApiResponse::success(disk_info)))
}
async fn handler_console_log(
state: axum::extract::State<State>,
Json(param): Json<ConsoleLogRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
let _ = console_log(state.0, &param.level, &param.message).await;
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HttpProxyRequest {
@@ -848,6 +895,27 @@ async fn handler_export_danmu(
Ok(Json(ApiResponse::success(result)))
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct DeleteTaskRequest {
id: String,
}
async fn handler_delete_task(
state: axum::extract::State<State>,
Json(params): Json<DeleteTaskRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
delete_task(state.0, &params.id).await?;
Ok(Json(ApiResponse::success(())))
}
async fn handler_get_tasks(
state: axum::extract::State<State>,
) -> Result<Json<ApiResponse<Vec<TaskRow>>>, ApiError> {
let tasks = get_tasks(state.0).await?;
Ok(Json(ApiResponse::success(tasks)))
}
async fn handler_hls(
state: axum::extract::State<State>,
Path(uri): Path<String>,
@@ -876,7 +944,7 @@ async fn handler_hls(
.map_err(|_| StatusCode::NOT_FOUND)?;
// Set appropriate content type based on file extension
let content_type = match filename.split('.').last() {
let content_type = match filename.split('.').next_back() {
Some("m3u8") => "application/vnd.apple.mpegurl",
Some("ts") => "video/mp2t",
Some("aac") => "audio/aac",
@@ -1088,93 +1156,121 @@ pub async fn start_api_server(state: State) {
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
let mut app = Router::new()
// Serve static files from dist directory
.nest_service("/", ServeDir::new("./dist"))
// Account commands
.route("/api/get_accounts", post(handler_get_accounts))
.route("/api/add_account", post(handler_add_account))
.route("/api/remove_account", post(handler_remove_account))
.route("/api/get_account_count", post(handler_get_account_count))
.route("/api/get_qr", post(handler_get_qr))
.route("/api/get_qr_status", post(handler_get_qr_status))
.route("/api/get_account_count", post(handler_get_account_count));
// Only add add/remove routes if not in readonly mode
if !state.readonly {
app = app
.route("/api/get_qr", post(handler_get_qr))
.route("/api/get_qr_status", post(handler_get_qr_status))
.route("/api/add_account", post(handler_add_account))
.route("/api/remove_account", post(handler_remove_account))
.route(
"/api/update_whisper_model",
post(handler_update_whisper_model),
)
.route(
"/api/update_subtitle_setting",
post(handler_update_subtitle_setting),
)
.route(
"/api/update_clip_name_format",
post(handler_update_clip_name_format),
)
.route("/api/add_recorder", post(handler_add_recorder))
.route("/api/remove_recorder", post(handler_remove_recorder))
.route("/api/delete_archive", post(handler_delete_archive))
.route("/api/send_danmaku", post(handler_send_danmaku))
.route("/api/set_enable", post(handler_set_enable))
.route("/api/upload_procedure", post(handler_upload_procedure))
.route("/api/cancel", post(handler_cancel))
.route("/api/delete_video", post(handler_delete_video))
.route(
"/api/generate_video_subtitle",
post(handler_generate_video_subtitle),
)
.route(
"/api/update_video_subtitle",
post(handler_update_video_subtitle),
)
.route("/api/update_video_cover", post(handler_update_video_cover))
.route(
"/api/encode_video_subtitle",
post(handler_encode_video_subtitle),
)
.route("/api/update_notify", post(handler_update_notify))
.route(
"/api/update_status_check_interval",
post(handler_update_status_check_interval),
)
.route(
"/api/update_whisper_prompt",
post(handler_update_whisper_prompt),
)
.route(
"/api/update_subtitle_generator_type",
post(handler_update_subtitle_generator_type),
)
.route(
"/api/update_openai_api_endpoint",
post(handler_update_openai_api_endpoint),
)
.route(
"/api/update_openai_api_key",
post(handler_update_openai_api_key),
)
.route(
"/api/update_auto_generate",
post(handler_update_auto_generate),
);
} else {
log::info!("Running in readonly mode, some api routes are disabled");
}
app = app
// Config commands
.route("/api/get_config", post(handler_get_config))
.route("/api/set_cache_path", post(handler_set_cache_path))
.route("/api/set_output_path", post(handler_set_output_path))
.route("/api/update_notify", post(handler_update_notify))
.route(
"/api/update_whisper_model",
post(handler_update_whisper_model),
)
.route(
"/api/update_subtitle_setting",
post(handler_update_subtitle_setting),
)
.route(
"/api/update_clip_name_format",
post(handler_update_clip_name_format),
)
.route(
"/api/update_whisper_prompt",
post(handler_update_whisper_prompt),
)
.route(
"/api/update_auto_generate",
post(handler_update_auto_generate),
)
// Message commands
.route("/api/get_messages", post(handler_get_messages))
.route("/api/read_message", post(handler_read_message))
.route("/api/delete_message", post(handler_delete_message))
// Recorder commands
.route("/api/get_recorder_list", post(handler_get_recorder_list))
.route("/api/add_recorder", post(handler_add_recorder))
.route("/api/remove_recorder", post(handler_remove_recorder))
.route("/api/get_room_info", post(handler_get_room_info))
.route("/api/get_archives", post(handler_get_archives))
.route("/api/get_archive", post(handler_get_archive))
.route("/api/delete_archive", post(handler_delete_archive))
.route("/api/get_danmu_record", post(handler_get_danmu_record))
.route("/api/send_danmaku", post(handler_send_danmaku))
.route("/api/get_total_length", post(handler_get_total_length))
.route(
"/api/get_today_record_count",
post(handler_get_today_record_count),
)
.route("/api/get_recent_record", post(handler_get_recent_record))
.route("/api/set_auto_start", post(handler_set_auto_start))
.route("/api/force_start", post(handler_force_start))
.route("/api/force_stop", post(handler_force_stop))
// Video commands
.route("/api/clip_range", post(handler_clip_range))
.route("/api/upload_procedure", post(handler_upload_procedure))
.route("/api/cancel", post(handler_cancel))
.route("/api/get_video", post(handler_get_video))
.route("/api/get_videos", post(handler_get_videos))
.route("/api/delete_video", post(handler_delete_video))
.route("/api/get_video_cover", post(handler_get_video_cover))
.route("/api/get_all_videos", post(handler_get_all_videos))
.route("/api/get_video_typelist", post(handler_get_video_typelist))
.route("/api/update_video_cover", post(handler_update_video_cover))
.route(
"/api/generate_video_subtitle",
post(handler_generate_video_subtitle),
)
.route("/api/get_video_subtitle", post(handler_get_video_subtitle))
.route(
"/api/update_video_subtitle",
post(handler_update_video_subtitle),
)
.route(
"/api/encode_video_subtitle",
post(handler_encode_video_subtitle),
)
.route("/api/delete_task", post(handler_delete_task))
.route("/api/get_tasks", post(handler_get_tasks))
.route("/api/export_danmu", post(handler_export_danmu))
// Utils commands
.route("/api/get_disk_info", post(handler_get_disk_info))
.route("/api/console_log", post(handler_console_log))
.route("/api/fetch", post(handler_fetch))
.route("/hls/*uri", get(handler_hls))
.route("/output/*uri", get(handler_output))
.route("/api/sse", get(handler_sse))
.route("/api/sse", get(handler_sse));
let router = app
.layer(cors)
.layer(DefaultBodyLimit::max(20 * 1024 * 1024))
.with_state(state);
@@ -1183,5 +1279,5 @@ pub async fn start_api_server(state: State) {
log::info!("API server listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
axum::serve(listener, router).await.unwrap();
}

View File

@@ -73,8 +73,8 @@ async fn open_log_file(log_dir: &Path) -> Result<File, Box<dyn std::error::Error
if file_size > 1024 * 1024 {
// move original file to backup
let date_str = Utc::now().format("%Y-%m-%d_%H-%M-%S").to_string();
let backup_filename = log_dir.join(&format!("bsr-{date_str}.log"));
let _ = fs::rename(&log_filename, backup_filename).await?;
let backup_filename = log_dir.join(format!("bsr-{date_str}.log"));
fs::rename(&log_filename, backup_filename).await?;
}
}
@@ -100,6 +100,7 @@ async fn setup_logging(log_dir: &Path) -> Result<(), Box<dyn std::error::Error>>
.add_filter_ignore_str("sqlx")
.add_filter_ignore_str("reqwest")
.add_filter_ignore_str("h2")
.add_filter_ignore_str("danmu_stream")
.build();
simplelog::CombinedLogger::init(vec![
@@ -140,6 +141,20 @@ fn get_migrations() -> Vec<Migration> {
sql: r#"ALTER TABLE recorders ADD COLUMN auto_start INTEGER NOT NULL DEFAULT 1;"#,
kind: MigrationKind::Up,
},
// add platform column to videos table
Migration {
version: 3,
description: "add_platform_column",
sql: r#"ALTER TABLE videos ADD COLUMN platform TEXT;"#,
kind: MigrationKind::Up,
},
// add task table to record encode/upload task
Migration {
version: 4,
description: "add_task_table",
sql: r#"CREATE TABLE tasks (id TEXT PRIMARY KEY, type TEXT, status TEXT, message TEXT, metadata TEXT, created_at TEXT);"#,
kind: MigrationKind::Up,
},
]
}
@@ -213,6 +228,7 @@ async fn setup_server_state(args: Args) -> Result<State, Box<dyn std::error::Err
.expect("Failed to run migrations");
db.set(db_pool).await;
db.finish_pending_tasks().await?;
let progress_manager = Arc::new(ProgressManager::new());
let emitter = EventEmitter::new(progress_manager.get_event_sender());
@@ -225,6 +241,7 @@ async fn setup_server_state(args: Args) -> Result<State, Box<dyn std::error::Err
config,
recorder_manager,
progress_manager,
readonly: args.readonly,
})
}
@@ -264,6 +281,7 @@ async fn setup_app_state(app: &tauri::App) -> Result<State, Box<dyn std::error::
tauri_plugin_sql::DbPool::Sqlite(pool) => Some(pool),
};
db_clone.set(sqlite_pool.unwrap().clone()).await;
db_clone.finish_pending_tasks().await?;
let recorder_manager = Arc::new(RecorderManager::new(
app.app_handle().clone(),
@@ -360,7 +378,8 @@ fn setup_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::W
fn setup_event_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
builder.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
if !window.label().starts_with("Live") {
// main window is not closable
if window.label() == "main" {
window.hide().unwrap();
api.prevent_close();
}
@@ -385,7 +404,11 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
crate::handlers::config::update_subtitle_setting,
crate::handlers::config::update_clip_name_format,
crate::handlers::config::update_whisper_prompt,
crate::handlers::config::update_subtitle_generator_type,
crate::handlers::config::update_openai_api_key,
crate::handlers::config::update_openai_api_endpoint,
crate::handlers::config::update_auto_generate,
crate::handlers::config::update_status_check_interval,
crate::handlers::message::get_messages,
crate::handlers::message::read_message,
crate::handlers::message::delete_message,
@@ -402,15 +425,15 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
crate::handlers::recorder::get_total_length,
crate::handlers::recorder::get_today_record_count,
crate::handlers::recorder::get_recent_record,
crate::handlers::recorder::set_auto_start,
crate::handlers::recorder::force_start,
crate::handlers::recorder::force_stop,
crate::handlers::recorder::set_enable,
crate::handlers::recorder::fetch_hls,
crate::handlers::video::clip_range,
crate::handlers::video::upload_procedure,
crate::handlers::video::cancel,
crate::handlers::video::get_video,
crate::handlers::video::get_videos,
crate::handlers::video::get_all_videos,
crate::handlers::video::get_video_cover,
crate::handlers::video::delete_video,
crate::handlers::video::get_video_typelist,
crate::handlers::video::update_video_cover,
@@ -418,11 +441,15 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
crate::handlers::video::get_video_subtitle,
crate::handlers::video::update_video_subtitle,
crate::handlers::video::encode_video_subtitle,
crate::handlers::task::get_tasks,
crate::handlers::task::delete_task,
crate::handlers::utils::show_in_folder,
crate::handlers::utils::export_to_file,
crate::handlers::utils::get_disk_info,
crate::handlers::utils::open_live,
crate::handlers::utils::open_clip,
crate::handlers::utils::open_log_folder,
crate::handlers::utils::console_log,
])
}
@@ -467,6 +494,10 @@ struct Args {
/// Path to the database folder
#[arg(short, long, default_value_t = String::from("./data"))]
db: String,
/// ReadOnly mode
#[arg(short, long, default_value_t = false)]
readonly: bool,
}
#[cfg(feature = "headless")]

View File

@@ -1,5 +1,4 @@
use async_trait::async_trait;
use serde::Serialize;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::LazyLock;
@@ -10,6 +9,7 @@ use crate::progress_manager::Event;
#[cfg(feature = "gui")]
use {
crate::recorder::danmu::DanmuEntry,
serde::Serialize,
tauri::{AppHandle, Emitter},
};
@@ -89,7 +89,7 @@ impl EventEmitter {
"progress-finished",
FinishEvent {
id,
success: success.clone(),
success: *success,
message,
},
)

View File

@@ -81,7 +81,6 @@ pub trait Recorder: Send + Sync + 'static {
async fn info(&self) -> RecorderInfo;
async fn comments(&self, live_id: &str) -> Result<Vec<DanmuEntry>, errors::RecorderError>;
async fn is_recording(&self, live_id: &str) -> bool;
async fn force_start(&self);
async fn force_stop(&self);
async fn set_auto_start(&self, auto_start: bool);
async fn enable(&self);
async fn disable(&self);
}

View File

@@ -14,17 +14,17 @@ use super::danmu::{DanmuEntry, DanmuStorage};
use super::entry::TsEntry;
use chrono::Utc;
use client::{BiliClient, BiliStream, RoomInfo, StreamType, UserInfo};
use danmu_stream::danmu_stream::DanmuStream;
use danmu_stream::provider::ProviderType;
use danmu_stream::DanmuMessageType;
use errors::BiliClientError;
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
use m3u8_rs::{Playlist, QuotedOrUnquoted, VariantStream};
use rand::Rng;
use regex::Regex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio::task::JoinHandle;
use url::Url;
use crate::config::Config;
@@ -32,7 +32,7 @@ use crate::database::{Database, DatabaseError};
use async_trait::async_trait;
#[cfg(not(feature = "headless"))]
#[cfg(feature = "gui")]
use {tauri::AppHandle, tauri_plugin_notification::NotificationExt};
/// A recorder for BiliBili live streams
@@ -42,7 +42,7 @@ use {tauri::AppHandle, tauri_plugin_notification::NotificationExt};
// TODO implement StreamType::TS
#[derive(Clone)]
pub struct BiliRecorder {
#[cfg(not(feature = "headless"))]
#[cfg(feature = "gui")]
app_handle: AppHandle,
emitter: EventEmitter,
client: Arc<RwLock<BiliClient>>,
@@ -54,17 +54,19 @@ pub struct BiliRecorder {
user_info: Arc<RwLock<UserInfo>>,
live_status: Arc<RwLock<bool>>,
live_id: Arc<RwLock<String>>,
manual_stop_id: Arc<RwLock<Option<String>>>,
cover: Arc<RwLock<Option<String>>>,
entry_store: Arc<RwLock<Option<EntryStore>>>,
is_recording: Arc<RwLock<bool>>,
auto_start: Arc<RwLock<bool>>,
force_update: Arc<AtomicBool>,
last_update: Arc<RwLock<i64>>,
quit: Arc<Mutex<bool>>,
live_stream: Arc<RwLock<Option<BiliStream>>>,
danmu_storage: Arc<RwLock<Option<DanmuStorage>>>,
live_end_channel: broadcast::Sender<RecorderEvent>,
enabled: Arc<RwLock<bool>>,
danmu_task: Arc<Mutex<Option<JoinHandle<()>>>>,
record_task: Arc<Mutex<Option<JoinHandle<()>>>>,
}
impl From<DatabaseError> for super::errors::RecorderError {
@@ -80,7 +82,7 @@ impl From<BiliClientError> for super::errors::RecorderError {
}
pub struct BiliRecorderOptions {
#[cfg(not(feature = "headless"))]
#[cfg(feature = "gui")]
pub app_handle: AppHandle,
pub emitter: EventEmitter,
pub db: Arc<Database>,
@@ -112,7 +114,7 @@ impl BiliRecorder {
}
let recorder = Self {
#[cfg(not(feature = "headless"))]
#[cfg(feature = "gui")]
app_handle: options.app_handle,
emitter: options.emitter,
client: Arc::new(RwLock::new(client)),
@@ -125,9 +127,7 @@ impl BiliRecorder {
live_status: Arc::new(RwLock::new(live_status)),
entry_store: Arc::new(RwLock::new(None)),
is_recording: Arc::new(RwLock::new(false)),
auto_start: Arc::new(RwLock::new(options.auto_start)),
live_id: Arc::new(RwLock::new(String::new())),
manual_stop_id: Arc::new(RwLock::new(None)),
cover: Arc::new(RwLock::new(cover)),
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
force_update: Arc::new(AtomicBool::new(false)),
@@ -135,6 +135,10 @@ impl BiliRecorder {
live_stream: Arc::new(RwLock::new(None)),
danmu_storage: Arc::new(RwLock::new(None)),
live_end_channel: options.channel,
enabled: Arc::new(RwLock::new(options.auto_start)),
danmu_task: Arc::new(Mutex::new(None)),
record_task: Arc::new(Mutex::new(None)),
};
log::info!("Recorder for room {} created.", options.room_id);
Ok(recorder)
@@ -153,17 +157,10 @@ impl BiliRecorder {
return false;
}
let live_id = self.live_id.read().await.clone();
self.manual_stop_id
.read()
.await
.as_ref()
.is_none_or(|v| v != &live_id)
*self.enabled.read().await
}
async fn check_status(&self) -> bool {
log::info!("[{}]Check room status", self.room_id);
match self
.client
.read()
@@ -178,15 +175,15 @@ impl BiliRecorder {
// handle live notification
if *self.live_status.read().await != live_status {
log::info!(
"[{}]Live status changed to {}, auto_start: {}",
"[{}]Live status changed to {}, enabled: {}",
self.room_id,
live_status,
*self.auto_start.read().await
*self.enabled.read().await
);
if live_status {
if self.config.read().await.live_start_notify {
#[cfg(not(feature = "headless"))]
#[cfg(feature = "gui")]
self.app_handle
.notification()
.builder()
@@ -211,7 +208,7 @@ impl BiliRecorder {
*self.cover.write().await = Some(cover_base64);
}
} else if self.config.read().await.live_end_notify {
#[cfg(not(feature = "headless"))]
#[cfg(feature = "gui")]
self.app_handle
.notification()
.builder()
@@ -241,8 +238,8 @@ impl BiliRecorder {
return false;
}
// no need to check stream if should not record and auto_start is false
if !self.should_record().await && !*self.auto_start.read().await {
// no need to check stream if should not record
if !self.should_record().await {
return true;
}
@@ -381,47 +378,46 @@ impl BiliRecorder {
Ok(stream)
}
async fn danmu(&self) {
async fn danmu(&self) -> Result<(), super::errors::RecorderError> {
let cookies = self.account.cookies.clone();
let uid: u64 = self.account.uid;
while !*self.quit.lock().await {
let (tx, rx) = mpsc::unbounded_channel();
let ws = ws_socket_object(tx, uid, self.room_id, cookies.as_str());
if let Err(e) = tokio::select! {v = ws => v, v = self.recv(self.room_id,rx) => v} {
log::error!("danmu error: {}", e);
}
// reconnect after 3s
log::warn!("danmu will reconnect after 3s");
tokio::time::sleep(Duration::from_secs(3)).await;
let room_id = self.room_id;
let danmu_stream = DanmuStream::new(ProviderType::BiliBili, &cookies, room_id).await;
if danmu_stream.is_err() {
let err = danmu_stream.err().unwrap();
log::error!("Failed to create danmu stream: {}", err);
return Err(super::errors::RecorderError::DanmuStreamError { err });
}
let danmu_stream = danmu_stream.unwrap();
log::info!("danmu thread {} quit.", self.room_id);
}
// create a task to receive danmu message
let danmu_stream_clone = danmu_stream.clone();
tokio::spawn(async move {
let _ = danmu_stream_clone.start().await;
});
async fn recv(
&self,
room: u64,
mut rx: UnboundedReceiver<WsStreamMessageType>,
) -> Result<(), FelgensError> {
while let Some(msg) = rx.recv().await {
if *self.quit.lock().await {
break;
}
if let WsStreamMessageType::DanmuMsg(msg) = msg {
self.emitter.emit(&Event::DanmuReceived {
room,
ts: msg.timestamp as i64,
content: msg.msg.clone(),
});
if *self.live_status.read().await {
// save danmu
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
storage.add_line(msg.timestamp as i64, &msg.msg).await;
loop {
if let Ok(Some(msg)) = danmu_stream.recv().await {
match msg {
DanmuMessageType::DanmuMessage(danmu) => {
self.emitter.emit(&Event::DanmuReceived {
room: self.room_id,
ts: danmu.timestamp,
content: danmu.message.clone(),
});
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
storage.add_line(danmu.timestamp, &danmu.message).await;
}
}
}
} else {
log::error!("Failed to receive danmu message");
return Err(super::errors::RecorderError::DanmuStreamError {
err: danmu_stream::DanmuStreamError::WebsocketError {
err: "Failed to receive danmu message".to_string(),
},
});
}
}
Ok(())
}
async fn get_playlist(&self) -> Result<Playlist, super::errors::RecorderError> {
@@ -777,13 +773,12 @@ impl BiliRecorder {
// check stream is nearly expired
// WHY: when program started, all stream is fetched nearly at the same time, so they will expire toggether,
// this might meet server rate limit. So we add a random offset to make request spread over time.
let mut rng = rand::thread_rng();
let pre_offset = rng.gen_range(120..=300);
// no need to update stream as it's not expired yet
let pre_offset = rand::random::<u64>() % 181 + 120; // Random number between 120 and 300
// no need to update stream as it's not expired yet
let current_stream = self.live_stream.read().await.clone();
if current_stream
.as_ref()
.is_some_and(|s| s.expire - Utc::now().timestamp() < pre_offset)
.is_some_and(|s| s.expire - Utc::now().timestamp() < pre_offset as i64)
{
log::info!("Stream is nearly expired, force update");
self.force_update.store(true, Ordering::Relaxed);
@@ -833,73 +828,71 @@ impl BiliRecorder {
impl super::Recorder for BiliRecorder {
async fn run(&self) {
let self_clone = self.clone();
thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
while !*self_clone.quit.lock().await {
let mut connection_fail_count = 0;
let mut rng = rand::thread_rng();
if self_clone.check_status().await {
// Live status is ok, start recording.
while self_clone.should_record().await {
match self_clone.update_entries().await {
Ok(ms) => {
if ms < 1000 {
thread::sleep(std::time::Duration::from_millis(
(1000 - ms) as u64,
));
}
if ms >= 3000 {
log::warn!(
"[{}]Update entries cost too long: {}ms",
self_clone.room_id,
ms
);
}
*self_clone.is_recording.write().await = true;
connection_fail_count = 0;
*self.danmu_task.lock().await = Some(tokio::spawn(async move {
log::info!("Start fetching danmu for room {}", self_clone.room_id);
let _ = self_clone.danmu().await;
}));
let self_clone = self.clone();
*self.record_task.lock().await = Some(tokio::spawn(async move {
log::info!("Start running recorder for room {}", self_clone.room_id);
while !*self_clone.quit.lock().await {
let mut connection_fail_count = 0;
if self_clone.check_status().await {
// Live status is ok, start recording.
while self_clone.should_record().await {
match self_clone.update_entries().await {
Ok(ms) => {
if ms < 1000 {
tokio::time::sleep(Duration::from_millis((1000 - ms) as u64))
.await;
}
Err(e) => {
log::error!(
"[{}]Update entries error: {}",
if ms >= 3000 {
log::warn!(
"[{}]Update entries cost too long: {}ms",
self_clone.room_id,
e
ms
);
if let RecorderError::BiliClientError { err: _ } = e {
connection_fail_count =
std::cmp::min(5, connection_fail_count + 1);
}
break;
}
*self_clone.is_recording.write().await = true;
connection_fail_count = 0;
}
Err(e) => {
log::error!("[{}]Update entries error: {}", self_clone.room_id, e);
if let RecorderError::BiliClientError { err: _ } = e {
connection_fail_count =
std::cmp::min(5, connection_fail_count + 1);
}
break;
}
}
*self_clone.is_recording.write().await = false;
// go check status again after random 2-5 secs
let secs = rng.gen_range(2..=5);
tokio::time::sleep(Duration::from_secs(
secs + 2_u64.pow(connection_fail_count),
))
.await;
continue;
}
// Every 10s check live status.
thread::sleep(std::time::Duration::from_secs(10));
*self_clone.is_recording.write().await = false;
// go check status again after random 2-5 secs
let secs = rand::random::<u64>() % 4 + 2;
tokio::time::sleep(Duration::from_secs(
secs + 2_u64.pow(connection_fail_count),
))
.await;
continue;
}
log::info!("recording thread {} quit.", self_clone.room_id);
});
});
// Thread for danmaku
let self_clone = self.clone();
thread::spawn(move || {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
self_clone.danmu().await;
});
});
let interval = self_clone.config.read().await.status_check_interval;
tokio::time::sleep(Duration::from_secs(interval)).await;
}
}));
}
async fn stop(&self) {
log::debug!("Stop recorder for room {}", self.room_id);
*self.quit.lock().await = true;
if let Some(danmu_task) = self.danmu_task.lock().await.as_mut() {
let _ = danmu_task.abort();
}
if let Some(record_task) = self.record_task.lock().await.as_mut() {
let _ = record_task.abort();
}
log::info!("Recorder for room {} quit.", self.room_id);
}
/// timestamp is the id of live stream
@@ -961,7 +954,7 @@ impl super::Recorder for BiliRecorder {
current_live_id: self.live_id.read().await.clone(),
live_status: *self.live_status.read().await,
is_recording: *self.is_recording.read().await,
auto_start: *self.auto_start.read().await,
auto_start: *self.enabled.read().await,
platform: PlatformType::BiliBili.as_str().to_string(),
}
}
@@ -999,15 +992,11 @@ impl super::Recorder for BiliRecorder {
*self.live_id.read().await == live_id && *self.live_status.read().await
}
async fn force_start(&self) {
*self.manual_stop_id.write().await = None;
async fn enable(&self) {
*self.enabled.write().await = true;
}
async fn force_stop(&self) {
*self.manual_stop_id.write().await = Some(self.live_id.read().await.clone());
}
async fn set_auto_start(&self, auto_start: bool) {
*self.auto_start.write().await = auto_start;
async fn disable(&self) {
*self.enabled.write().await = false;
}
}

View File

@@ -242,7 +242,7 @@ impl BiliClient {
let params = self.get_sign(params).await?;
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let res: serde_json::Value = self
let resp = self
.client
.get(format!(
"https://api.bilibili.com/x/space/wbi/acc/info?{}",
@@ -250,15 +250,24 @@ impl BiliClient {
))
.headers(headers)
.send()
.await?
.json()
.await?;
if res["code"].as_i64().unwrap_or(-1) != 0 {
log::error!(
"Get user info failed {}",
res["code"].as_i64().unwrap_or(-1)
);
return Err(BiliClientError::InvalidCode);
if !resp.status().is_success() {
if resp.status() == reqwest::StatusCode::PRECONDITION_FAILED {
return Err(BiliClientError::SecurityControlError);
}
return Err(BiliClientError::InvalidResponseStatus {
status: resp.status(),
});
}
let res: serde_json::Value = resp.json().await?;
let code = res["code"]
.as_u64()
.ok_or(BiliClientError::InvalidResponseJson { resp: res.clone() })?;
if code != 0 {
log::error!("Get user info failed {}", code);
return Err(BiliClientError::InvalidMessageCode { code });
}
Ok(UserInfo {
user_id,
@@ -275,7 +284,7 @@ impl BiliClient {
) -> Result<RoomInfo, BiliClientError> {
let mut headers = self.headers.clone();
headers.insert("cookie", account.cookies.parse().unwrap());
let res: serde_json::Value = self
let response = self
.client
.get(format!(
"https://api.live.bilibili.com/room/v1/Room/get_info?room_id={}",
@@ -283,12 +292,23 @@ impl BiliClient {
))
.headers(headers)
.send()
.await?
.json()
.await?;
let code = res["code"].as_u64().ok_or(BiliClientError::InvalidValue)?;
if !response.status().is_success() {
if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
return Err(BiliClientError::SecurityControlError);
}
return Err(BiliClientError::InvalidResponseStatus {
status: response.status(),
});
}
let res: serde_json::Value = response.json().await?;
let code = res["code"]
.as_u64()
.ok_or(BiliClientError::InvalidResponseJson { resp: res.clone() })?;
if code != 0 {
return Err(BiliClientError::InvalidCode);
return Err(BiliClientError::InvalidMessageCode { code });
}
let room_id = res["data"]["room_id"]
@@ -362,11 +382,11 @@ impl BiliClient {
.headers(self.headers.clone())
.send()
.await?;
let mut file = std::fs::File::create(file_path)?;
let mut file = tokio::fs::File::create(file_path).await?;
let bytes = res.bytes().await?;
let size = bytes.len() as u64;
let mut content = std::io::Cursor::new(bytes);
std::io::copy(&mut content, &mut file)?;
tokio::io::copy(&mut content, &mut file).await?;
Ok(size)
}
@@ -713,19 +733,19 @@ impl BiliClient {
.await
{
Ok(raw_resp) => {
let json = raw_resp.json().await?;
if let Ok(resp) = serde_json::from_value::<GeneralResponse>(json) {
let json: Value = raw_resp.json().await?;
if let Ok(resp) = serde_json::from_value::<GeneralResponse>(json.clone()) {
match resp.data {
response::Data::VideoSubmit(data) => Ok(data),
_ => Err(BiliClientError::InvalidResponse),
}
} else {
println!("Parse response failed");
log::error!("Parse response failed: {}", json);
Err(BiliClientError::InvalidResponse)
}
}
Err(e) => {
println!("Send failed {}", e);
log::error!("Send failed {}", e);
Err(BiliClientError::InvalidResponse)
}
}
@@ -753,19 +773,19 @@ impl BiliClient {
.await
{
Ok(raw_resp) => {
let json = raw_resp.json().await?;
if let Ok(resp) = serde_json::from_value::<GeneralResponse>(json) {
let json: Value = raw_resp.json().await?;
if let Ok(resp) = serde_json::from_value::<GeneralResponse>(json.clone()) {
match resp.data {
response::Data::Cover(data) => Ok(data.url),
_ => Err(BiliClientError::InvalidResponse),
}
} else {
println!("Parse response failed");
log::error!("Parse response failed: {}", json);
Err(BiliClientError::InvalidResponse)
}
}
Err(e) => {
println!("Send failed {}", e);
log::error!("Send failed {}", e);
Err(BiliClientError::InvalidResponse)
}
}

View File

@@ -3,7 +3,9 @@ use custom_error::custom_error;
custom_error! {pub BiliClientError
InvalidResponse = "Invalid response",
InitClientError = "Client init error",
InvalidCode = "Invalid Code",
InvalidResponseStatus{ status: reqwest::StatusCode } = "Invalid response status: {status}",
InvalidResponseJson{ resp: serde_json::Value } = "Invalid response json: {resp}",
InvalidMessageCode{ code: u64 } = "Invalid message code: {code}",
InvalidValue = "Invalid value",
InvalidUrl = "Invalid url",
InvalidFormat = "Invalid stream format",
@@ -13,6 +15,7 @@ custom_error! {pub BiliClientError
EmptyCache = "Empty cache",
ClientError{err: reqwest::Error} = "Client error: {err}",
IOError{err: std::io::Error} = "IO error: {err}",
SecurityControlError = "Security control error",
}
impl From<reqwest::Error> for BiliClientError {

View File

@@ -12,6 +12,7 @@ pub struct GeneralResponse {
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum Data {
VideoSubmit(VideoSubmitData),
Cover(CoverData),

View File

@@ -7,15 +7,23 @@ use super::{
UserInfo,
};
use crate::database::Database;
use crate::progress_manager::Event;
use crate::progress_reporter::EventEmitter;
use crate::recorder_manager::RecorderEvent;
use crate::{config::Config, database::account::AccountRow};
use async_trait::async_trait;
use chrono::Utc;
use client::DouyinClientError;
use danmu_stream::danmu_stream::DanmuStream;
use danmu_stream::provider::ProviderType;
use danmu_stream::DanmuMessageType;
use rand::random;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, RwLock};
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio::task::JoinHandle;
use super::danmu::DanmuStorage;
#[cfg(not(feature = "headless"))]
use {tauri::AppHandle, tauri_plugin_notification::NotificationExt};
@@ -42,34 +50,42 @@ impl From<DouyinClientError> for RecorderError {
pub struct DouyinRecorder {
#[cfg(not(feature = "headless"))]
app_handle: AppHandle,
emitter: EventEmitter,
client: client::DouyinClient,
db: Arc<Database>,
pub room_id: u64,
pub room_info: Arc<RwLock<Option<response::DouyinRoomInfoResponse>>>,
pub stream_url: Arc<RwLock<Option<String>>>,
pub entry_store: Arc<RwLock<Option<EntryStore>>>,
pub live_id: Arc<RwLock<String>>,
pub live_status: Arc<RwLock<LiveStatus>>,
manual_stop_id: Arc<RwLock<Option<String>>>,
account: AccountRow,
room_id: u64,
room_info: Arc<RwLock<Option<response::DouyinRoomInfoResponse>>>,
stream_url: Arc<RwLock<Option<String>>>,
entry_store: Arc<RwLock<Option<EntryStore>>>,
danmu_store: Arc<RwLock<Option<DanmuStorage>>>,
live_id: Arc<RwLock<String>>,
live_status: Arc<RwLock<LiveStatus>>,
is_recording: Arc<RwLock<bool>>,
auto_start: Arc<RwLock<bool>>,
running: Arc<RwLock<bool>>,
last_update: Arc<RwLock<i64>>,
config: Arc<RwLock<Config>>,
live_end_channel: broadcast::Sender<RecorderEvent>,
enabled: Arc<RwLock<bool>>,
danmu_stream_task: Arc<Mutex<Option<JoinHandle<()>>>>,
danmu_task: Arc<Mutex<Option<JoinHandle<()>>>>,
record_task: Arc<Mutex<Option<JoinHandle<()>>>>,
}
impl DouyinRecorder {
#[allow(clippy::too_many_arguments)]
pub async fn new(
#[cfg(not(feature = "headless"))] app_handle: AppHandle,
emitter: EventEmitter,
room_id: u64,
config: Arc<RwLock<Config>>,
douyin_account: &AccountRow,
account: &AccountRow,
db: &Arc<Database>,
auto_start: bool,
enabled: bool,
channel: broadcast::Sender<RecorderEvent>,
) -> Result<Self, super::errors::RecorderError> {
let client = client::DouyinClient::new(douyin_account);
let client = client::DouyinClient::new(account);
let room_info = client.get_room_info(room_id).await?;
let mut live_status = LiveStatus::Offline;
if room_info.data.room_status == 0 {
@@ -79,21 +95,27 @@ impl DouyinRecorder {
Ok(Self {
#[cfg(not(feature = "headless"))]
app_handle,
emitter,
db: db.clone(),
account: account.clone(),
room_id,
live_id: Arc::new(RwLock::new(String::new())),
entry_store: Arc::new(RwLock::new(None)),
danmu_store: Arc::new(RwLock::new(None)),
client,
room_info: Arc::new(RwLock::new(Some(room_info))),
stream_url: Arc::new(RwLock::new(None)),
live_status: Arc::new(RwLock::new(live_status)),
manual_stop_id: Arc::new(RwLock::new(None)),
running: Arc::new(RwLock::new(false)),
is_recording: Arc::new(RwLock::new(false)),
auto_start: Arc::new(RwLock::new(auto_start)),
enabled: Arc::new(RwLock::new(enabled)),
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
config,
live_end_channel: channel,
danmu_stream_task: Arc::new(Mutex::new(None)),
danmu_task: Arc::new(Mutex::new(None)),
record_task: Arc::new(Mutex::new(None)),
})
}
@@ -102,13 +124,7 @@ impl DouyinRecorder {
return false;
}
let live_id = self.live_id.read().await.clone();
self.manual_stop_id
.read()
.await
.as_ref()
.is_none_or(|v| v != &live_id)
*self.enabled.read().await
}
async fn check_status(&self) -> bool {
@@ -124,7 +140,7 @@ impl DouyinRecorder {
"[{}]Live status changed to {}, auto_start: {}",
self.room_id,
live_status,
*self.auto_start.read().await
*self.enabled.read().await
);
if live_status {
@@ -175,53 +191,68 @@ impl DouyinRecorder {
let should_record = self.should_record().await;
if !should_record && !*self.auto_start.read().await {
if !should_record {
return true;
}
if should_record {
// Get stream URL when live starts
if !info.data.data[0]
.stream_url
// Get stream URL when live starts
if !info.data.data[0]
.stream_url
.as_ref()
.unwrap()
.hls_pull_url
.is_empty()
{
*self.live_id.write().await = info.data.data[0].id_str.clone();
// create a new record
let cover_url = info.data.data[0]
.cover
.as_ref()
.unwrap()
.hls_pull_url
.is_empty()
.map(|cover| cover.url_list[0].clone());
let cover = if let Some(url) = cover_url {
Some(self.client.get_cover_base64(&url).await.unwrap())
} else {
None
};
if let Err(e) = self
.db
.add_record(
PlatformType::Douyin,
self.live_id.read().await.as_str(),
self.room_id,
&info.data.data[0].title,
cover,
None,
)
.await
{
*self.live_id.write().await = info.data.data[0].id_str.clone();
// create a new record
let cover_url = info.data.data[0]
.cover
.as_ref()
.map(|cover| cover.url_list[0].clone());
let cover = if let Some(url) = cover_url {
Some(self.client.get_cover_base64(&url).await.unwrap())
} else {
None
};
if let Err(e) = self
.db
.add_record(
PlatformType::Douyin,
self.live_id.read().await.as_str(),
self.room_id,
&info.data.data[0].title,
cover,
None,
)
.await
{
log::error!("Failed to add record: {}", e);
}
// setup entry store
let work_dir = self.get_work_dir(self.live_id.read().await.as_str()).await;
let entry_store = EntryStore::new(&work_dir).await;
*self.entry_store.write().await = Some(entry_store);
log::error!("Failed to add record: {}", e);
}
return true;
// setup entry store
let work_dir = self.get_work_dir(self.live_id.read().await.as_str()).await;
let entry_store = EntryStore::new(&work_dir).await;
*self.entry_store.write().await = Some(entry_store);
// setup danmu store
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
let danmu_store = DanmuStorage::new(&danmu_file_path).await;
*self.danmu_store.write().await = danmu_store;
// start danmu task
if let Some(danmu_task) = self.danmu_task.lock().await.as_mut() {
danmu_task.abort();
}
if let Some(danmu_stream_task) = self.danmu_stream_task.lock().await.as_mut() {
danmu_stream_task.abort();
}
let live_id = self.live_id.read().await.clone();
let self_clone = self.clone();
*self.danmu_task.lock().await = Some(tokio::spawn(async move {
log::info!("Start fetching danmu for live {}", live_id);
let _ = self_clone.danmu().await;
}));
}
true
@@ -233,6 +264,53 @@ impl DouyinRecorder {
}
}
async fn danmu(&self) -> Result<(), super::errors::RecorderError> {
let cookies = self.account.cookies.clone();
let live_id = self
.live_id
.read()
.await
.clone()
.parse::<u64>()
.unwrap_or(0);
let danmu_stream = DanmuStream::new(ProviderType::Douyin, &cookies, live_id).await;
if danmu_stream.is_err() {
let err = danmu_stream.err().unwrap();
log::error!("Failed to create danmu stream: {}", err);
return Err(super::errors::RecorderError::DanmuStreamError { err });
}
let danmu_stream = danmu_stream.unwrap();
let danmu_stream_clone = danmu_stream.clone();
*self.danmu_stream_task.lock().await = Some(tokio::spawn(async move {
let _ = danmu_stream_clone.start().await;
}));
loop {
if let Ok(Some(msg)) = danmu_stream.recv().await {
match msg {
DanmuMessageType::DanmuMessage(danmu) => {
self.emitter.emit(&Event::DanmuReceived {
room: self.room_id,
ts: danmu.timestamp,
content: danmu.message.clone(),
});
if let Some(storage) = self.danmu_store.read().await.as_ref() {
storage.add_line(danmu.timestamp, &danmu.message).await;
}
}
}
} else {
log::error!("Failed to receive danmu message");
return Err(super::errors::RecorderError::DanmuStreamError {
err: danmu_stream::DanmuStreamError::WebsocketError {
err: "Failed to receive danmu message".to_string(),
},
});
}
}
}
async fn reset(&self) {
*self.entry_store.write().await = None;
*self.live_id.write().await = String::new();
@@ -439,7 +517,7 @@ impl Recorder for DouyinRecorder {
*self.running.write().await = true;
let self_clone = self.clone();
tokio::spawn(async move {
*self.record_task.lock().await = Some(tokio::spawn(async move {
while *self_clone.running.read().await {
let mut connection_fail_count = 0;
if self_clone.check_status().await {
@@ -480,27 +558,39 @@ impl Recorder for DouyinRecorder {
.await;
continue;
}
// Check live status every 10s
tokio::time::sleep(Duration::from_secs(10)).await;
let interval = self_clone.config.read().await.status_check_interval;
tokio::time::sleep(Duration::from_secs(interval)).await;
}
log::info!("recording thread {} quit.", self_clone.room_id);
});
}));
}
async fn stop(&self) {
*self.running.write().await = false;
// stop 3 tasks
if let Some(danmu_task) = self.danmu_task.lock().await.as_mut() {
let _ = danmu_task.abort();
}
if let Some(danmu_stream_task) = self.danmu_stream_task.lock().await.as_mut() {
let _ = danmu_stream_task.abort();
}
if let Some(record_task) = self.record_task.lock().await.as_mut() {
let _ = record_task.abort();
}
log::info!("Recorder for room {} quit.", self.room_id);
}
async fn m3u8_content(&self, live_id: &str, start: i64, end: i64) -> String {
self.generate_m3u8(live_id, start, end).await
}
async fn master_m3u8(&self, _live_id: &str, start: i64, end: i64) -> String {
async fn master_m3u8(&self, live_id: &str, start: i64, end: i64) -> String {
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:6\n";
m3u8_content += format!(
"#EXT-X-STREAM-INF:{}\n",
"BANDWIDTH=1280000,RESOLUTION=1920x1080,CODECS=\"avc1.64001F,mp4a.40.2\""
"#EXT-X-STREAM-INF:BANDWIDTH=1280000,RESOLUTION=1920x1080,CODECS=\"avc1.64001F,mp4a.40.2\",DANMU={}\n",
self.first_segment_ts(live_id).await / 1000
)
.as_str();
m3u8_content += &format!("playlist.m3u8?start={}&end={}\n", start, end);
@@ -566,28 +656,46 @@ impl Recorder for DouyinRecorder {
current_live_id: self.live_id.read().await.clone(),
live_status: *self.live_status.read().await == LiveStatus::Live,
is_recording: *self.is_recording.read().await,
auto_start: *self.auto_start.read().await,
auto_start: *self.enabled.read().await,
platform: PlatformType::Douyin.as_str().to_string(),
}
}
async fn comments(&self, _live_id: &str) -> Result<Vec<DanmuEntry>, RecorderError> {
Ok(vec![])
async fn comments(&self, live_id: &str) -> Result<Vec<DanmuEntry>, RecorderError> {
Ok(if live_id == *self.live_id.read().await {
// just return current cache content
match self.danmu_store.read().await.as_ref() {
Some(storage) => storage.get_entries().await,
None => Vec::new(),
}
} else {
// load disk cache
let cache_file_path = format!(
"{}/douyin/{}/{}/{}",
self.config.read().await.cache,
self.room_id,
live_id,
"danmu.txt"
);
log::debug!("loading danmu cache from {}", cache_file_path);
let storage = DanmuStorage::new(&cache_file_path).await;
if storage.is_none() {
return Ok(Vec::new());
}
let storage = storage.unwrap();
storage.get_entries().await
})
}
async fn is_recording(&self, live_id: &str) -> bool {
*self.live_id.read().await == live_id && *self.live_status.read().await == LiveStatus::Live
}
async fn force_start(&self) {
*self.manual_stop_id.write().await = None;
async fn enable(&self) {
*self.enabled.write().await = true;
}
async fn force_stop(&self) {
*self.manual_stop_id.write().await = Some(self.live_id.read().await.clone());
}
async fn set_auto_start(&self, auto_start: bool) {
*self.auto_start.write().await = auto_start;
async fn disable(&self) {
*self.enabled.write().await = false;
}
}

View File

@@ -2,8 +2,6 @@ use crate::database::account::AccountRow;
use base64::Engine;
use m3u8_rs::{MediaPlaylist, Playlist};
use reqwest::{Client, Error as ReqwestError};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use super::response::DouyinRoomInfoResponse;
use std::fmt;
@@ -120,10 +118,11 @@ impl DouyinClient {
));
}
let content = response.bytes().await?;
let mut file = File::create(path).await?;
file.write_all(&content).await?;
Ok(content.len() as u64)
let mut file = tokio::fs::File::create(path).await?;
let bytes = response.bytes().await?;
let size = bytes.len() as u64;
let mut content = std::io::Cursor::new(bytes);
tokio::io::copy(&mut content, &mut file).await?;
Ok(size)
}
}

View File

@@ -264,7 +264,7 @@ impl EntryStore {
for (i, e) in entries_in_range.iter().enumerate() {
let discontinuous = e.sequence < previous_seq || e.sequence - previous_seq > 1;
if discontinuous {
m3u8_content += "#EXT-X-DISCONTINUITY\n".into();
m3u8_content += "#EXT-X-DISCONTINUITY\n";
}
// Add date time under these situations.
if i == 0 || i == entries_in_range.len() - 1 || force_time || discontinuous {

View File

@@ -19,4 +19,5 @@ custom_error! {pub RecorderError
BiliClientError {err: super::bilibili::errors::BiliClientError} = "BiliClient error: {err}",
DouyinClientError {err: DouyinClientError} = "DouyinClient error: {err}",
IoError {err: std::io::Error} = "IO error: {err}",
DanmuStreamError {err: danmu_stream::DanmuStreamError} = "Danmu stream error: {err}",
}

View File

@@ -15,7 +15,7 @@ use crate::recorder::RecorderInfo;
use chrono::Utc;
use custom_error::custom_error;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
@@ -32,7 +32,7 @@ pub struct RecorderList {
pub recorders: Vec<RecorderInfo>,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ClipRangeParams {
pub title: String,
pub cover: String,
@@ -47,6 +47,7 @@ pub struct ClipRangeParams {
pub offset: i64,
/// Encode danmu after clip
pub danmu: bool,
pub local_offset: i64,
}
#[derive(Debug, Clone)]
@@ -65,6 +66,7 @@ pub struct RecorderManager {
db: Arc<Database>,
config: Arc<RwLock<Config>>,
recorders: Arc<RwLock<HashMap<String, Box<dyn Recorder>>>>,
to_remove: Arc<RwLock<HashSet<String>>>,
event_tx: broadcast::Sender<RecorderEvent>,
is_migrating: Arc<AtomicBool>,
}
@@ -120,6 +122,7 @@ impl RecorderManager {
db,
config,
recorders: Arc::new(RwLock::new(HashMap::new())),
to_remove: Arc::new(RwLock::new(HashSet::new())),
event_tx,
is_migrating: Arc::new(AtomicBool::new(false)),
};
@@ -146,6 +149,7 @@ impl RecorderManager {
db: self.db.clone(),
config: self.config.clone(),
recorders: self.recorders.clone(),
to_remove: self.to_remove.clone(),
event_tx: self.event_tx.clone(),
is_migrating: self.is_migrating.clone(),
}
@@ -198,13 +202,14 @@ impl RecorderManager {
let clip_config = ClipRangeParams {
title: live_record.title,
cover: "".into(),
platform: live_record.platform,
platform: live_record.platform.clone(),
room_id,
live_id: live_id.to_string(),
x: 0,
y: 0,
offset: recorder.first_segment_ts(live_id).await,
danmu: encode_danmu,
local_offset: 0,
};
let clip_filename = self.config.read().await.generate_clip_name(&clip_config);
@@ -244,6 +249,7 @@ impl RecorderManager {
desc: "".into(),
tags: "".into(),
area: 0,
platform: live_record.platform.clone(),
})
.await
{
@@ -291,7 +297,9 @@ impl RecorderManager {
let mut recorders_to_add = Vec::new();
for (platform, room_id) in recorder_map.keys() {
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if !self.recorders.read().await.contains_key(&recorder_id) {
if !self.recorders.read().await.contains_key(&recorder_id)
&& !self.to_remove.read().await.contains(&recorder_id)
{
recorders_to_add.push((*platform, *room_id));
}
}
@@ -337,7 +345,7 @@ impl RecorderManager {
let recorder: Box<dyn Recorder + 'static> = match platform {
PlatformType::BiliBili => Box::new(
BiliRecorder::new(BiliRecorderOptions {
#[cfg(not(feature = "headless"))]
#[cfg(feature = "gui")]
app_handle: self.app_handle.clone(),
emitter: self.emitter.clone(),
db: self.db.clone(),
@@ -351,8 +359,9 @@ impl RecorderManager {
),
PlatformType::Douyin => Box::new(
DouyinRecorder::new(
#[cfg(not(feature = "headless"))]
#[cfg(feature = "gui")]
self.app_handle.clone(),
self.emitter.clone(),
room_id,
self.config.clone(),
account,
@@ -387,6 +396,10 @@ impl RecorderManager {
self.recorders.write().await.clear();
}
/// Remove a recorder from the manager
///
/// This will stop the recorder and remove it from the manager
/// and remove the related cache folder
pub async fn remove_recorder(
&self,
platform: PlatformType,
@@ -398,14 +411,27 @@ impl RecorderManager {
return Err(RecorderManagerError::NotFound { room_id });
}
// remove from db
self.db.remove_recorder(room_id).await?;
// add to to_remove
log::debug!("Add to to_remove: {}", recorder_id);
self.to_remove.write().await.insert(recorder_id.clone());
// stop recorder
log::debug!("Stop recorder: {}", recorder_id);
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
recorder_ref.stop().await;
}
// remove recorder
log::debug!("Remove recorder from manager: {}", recorder_id);
self.recorders.write().await.remove(&recorder_id);
// remove from to_remove
log::debug!("Remove from to_remove: {}", recorder_id);
self.to_remove.write().await.remove(&recorder_id);
// remove related cache folder
let cache_folder = format!(
"{}/{}/{}",
@@ -413,6 +439,7 @@ impl RecorderManager {
platform.as_str(),
room_id
);
log::debug!("Remove cache folder: {}", cache_folder);
let _ = tokio::fs::remove_dir_all(cache_folder).await;
log::info!("Recorder {} cache folder removed", room_id);
@@ -489,16 +516,17 @@ impl RecorderManager {
}
log::info!(
"Filter danmus in range [{}, {}] with offset {}",
"Filter danmus in range [{}, {}] with global offset {} and local offset {}",
params.x,
params.y,
params.offset
params.offset,
params.local_offset
);
let mut danmus = danmus.unwrap();
log::debug!("First danmu entry: {:?}", danmus.first());
// update entry ts to offset
for d in &mut danmus {
d.ts -= (params.x + params.offset) * 1000;
d.ts -= (params.x + params.offset + params.local_offset) * 1000;
}
if params.x != 0 || params.y != 0 {
danmus.retain(|x| x.ts >= 0 && x.ts <= (params.y - params.x) * 1000);
@@ -718,29 +746,19 @@ impl RecorderManager {
}
}
pub async fn set_auto_start(&self, platform: PlatformType, room_id: u64, auto_start: bool) {
pub async fn set_enable(&self, platform: PlatformType, room_id: u64, enabled: bool) {
// update RecordRow auto_start field
if let Err(e) = self.db.update_recorder(platform, room_id, auto_start).await {
if let Err(e) = self.db.update_recorder(platform, room_id, enabled).await {
log::error!("Failed to update recorder auto_start: {}", e);
}
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
recorder_ref.set_auto_start(auto_start).await;
}
}
pub async fn force_start(&self, platform: PlatformType, room_id: u64) {
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
recorder_ref.force_start().await;
}
}
pub async fn force_stop(&self, platform: PlatformType, room_id: u64) {
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
recorder_ref.force_stop().await;
if enabled {
recorder_ref.enable().await;
} else {
recorder_ref.disable().await;
}
}
}
}

View File

@@ -26,4 +26,6 @@ pub struct State {
pub app_handle: tauri::AppHandle,
#[cfg(feature = "headless")]
pub progress_manager: Arc<ProgressManager>,
#[cfg(feature = "headless")]
pub readonly: bool,
}

View File

@@ -3,12 +3,22 @@ use std::path::Path;
use crate::progress_reporter::ProgressReporterTrait;
pub mod whisper;
pub mod whisper_cpp;
pub mod whisper_online;
// subtitle_generator types
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub enum SubtitleGeneratorType {
Whisper,
WhisperOnline,
}
#[derive(Debug, Clone)]
pub struct GenerateResult {
pub generator_type: SubtitleGeneratorType,
pub subtitle_id: String,
pub subtitle_content: String,
}
impl SubtitleGeneratorType {
@@ -16,12 +26,14 @@ impl SubtitleGeneratorType {
pub fn as_str(&self) -> &'static str {
match self {
SubtitleGeneratorType::Whisper => "whisper",
SubtitleGeneratorType::WhisperOnline => "whisper_online",
}
}
#[allow(dead_code)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"whisper" => Some(SubtitleGeneratorType::Whisper),
"whisper_online" => Some(SubtitleGeneratorType::WhisperOnline),
_ => None,
}
}
@@ -34,5 +46,5 @@ pub trait SubtitleGenerator {
reporter: &impl ProgressReporterTrait,
video_path: &Path,
output_path: &Path,
) -> Result<String, String>;
) -> Result<GenerateResult, String>;
}

View File

@@ -1,6 +1,9 @@
use async_trait::async_trait;
use crate::progress_reporter::ProgressReporterTrait;
use crate::{
progress_reporter::ProgressReporterTrait,
subtitle_generator::{GenerateResult, SubtitleGeneratorType},
};
use async_std::sync::{Arc, RwLock};
use std::path::Path;
use tokio::io::AsyncWriteExt;
@@ -37,7 +40,7 @@ impl SubtitleGenerator for WhisperCPP {
reporter: &impl ProgressReporterTrait,
audio_path: &Path,
output_path: &Path,
) -> Result<String, String> {
) -> Result<GenerateResult, String> {
log::info!("Generating subtitle for {:?}", audio_path);
let start_time = std::time::Instant::now();
let audio = hound::WavReader::open(audio_path).map_err(|e| e.to_string())?;
@@ -128,7 +131,11 @@ impl SubtitleGenerator for WhisperCPP {
log::info!("Subtitle generated: {:?}", output_path);
log::info!("Time taken: {} seconds", start_time.elapsed().as_secs_f64());
Ok(subtitle)
Ok(GenerateResult {
generator_type: SubtitleGeneratorType::Whisper,
subtitle_id: "".to_string(),
subtitle_content: subtitle,
})
}
}

View File

@@ -0,0 +1,232 @@
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use std::path::Path;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use crate::{
progress_reporter::ProgressReporterTrait,
subtitle_generator::{GenerateResult, SubtitleGenerator, SubtitleGeneratorType},
};
#[derive(Debug, Clone)]
pub struct WhisperOnline {
client: Client,
api_url: String,
api_key: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WhisperResponse {
segments: Vec<WhisperSegment>,
}
#[derive(Debug, Deserialize)]
struct WhisperSegment {
start: f64,
end: f64,
text: String,
}
pub async fn new(api_url: Option<&str>, api_key: Option<&str>) -> Result<WhisperOnline, String> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(300)) // 5 minutes timeout
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let api_url = api_url.unwrap_or("https://api.openai.com/v1");
let api_url = api_url.to_string() + "/audio/transcriptions";
Ok(WhisperOnline {
client,
api_url: api_url.to_string(),
api_key: api_key.map(|k| k.to_string()),
})
}
#[async_trait]
impl SubtitleGenerator for WhisperOnline {
async fn generate_subtitle(
&self,
reporter: &impl ProgressReporterTrait,
audio_path: &Path,
output_path: &Path,
) -> Result<GenerateResult, String> {
log::info!("Generating subtitle online for {:?}", audio_path);
let start_time = std::time::Instant::now();
// Read audio file
reporter.update("读取音频文件中");
let audio_data = fs::read(audio_path)
.await
.map_err(|e| format!("Failed to read audio file: {}", e))?;
// Get file extension for proper MIME type
let file_extension = audio_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("wav");
let mime_type = match file_extension.to_lowercase().as_str() {
"wav" => "audio/wav",
"mp3" => "audio/mpeg",
"m4a" => "audio/mp4",
"flac" => "audio/flac",
_ => "audio/wav",
};
// Build form data with proper file part
let file_part = reqwest::multipart::Part::bytes(audio_data)
.mime_str(mime_type)
.map_err(|e| format!("Failed to set MIME type: {}", e))?
.file_name(
audio_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
);
let form = reqwest::multipart::Form::new()
.part("file", file_part)
.text("model", "whisper-1")
.text("response_format", "verbose_json")
.text("temperature", "0.0");
// Build HTTP request
let mut req_builder = self.client.post(&self.api_url);
if let Some(api_key) = &self.api_key {
req_builder = req_builder.header("Authorization", format!("Bearer {}", api_key));
}
reporter.update("上传音频中");
let response = req_builder
.multipart(form)
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(format!(
"API request failed with status {}: {}",
status, error_text
));
}
// Get the raw response text first for debugging
let response_text = response
.text()
.await
.map_err(|e| format!("Failed to get response text: {}", e))?;
// Try to parse as JSON
let whisper_response: WhisperResponse =
serde_json::from_str(&response_text).map_err(|e| {
println!("{}", response_text);
log::error!(
"Failed to parse JSON response. Raw response: {}",
response_text
);
format!("Failed to parse response: {}", e)
})?;
// Generate SRT format subtitle
let mut subtitle = String::new();
for (i, segment) in whisper_response.segments.iter().enumerate() {
let format_time = |timestamp: f64| {
let hours = (timestamp / 3600.0).floor();
let minutes = ((timestamp - hours * 3600.0) / 60.0).floor();
let seconds = timestamp - hours * 3600.0 - minutes * 60.0;
format!("{:02}:{:02}:{:06.3}", hours, minutes, seconds).replace(".", ",")
};
let line = format!(
"{}\n{} --> {}\n{}\n\n",
i + 1,
format_time(segment.start),
format_time(segment.end),
segment.text.trim(),
);
subtitle.push_str(&line);
}
// Write subtitle to file
let mut output_file = fs::File::create(output_path)
.await
.map_err(|e| format!("Failed to create output file: {}", e))?;
output_file
.write_all(subtitle.as_bytes())
.await
.map_err(|e| format!("Failed to write subtitle file: {}", e))?;
log::info!("Online subtitle generated: {:?}", output_path);
log::info!("Time taken: {} seconds", start_time.elapsed().as_secs_f64());
Ok(GenerateResult {
generator_type: SubtitleGeneratorType::WhisperOnline,
subtitle_id: "".to_string(),
subtitle_content: subtitle,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
// Mock reporter for testing
#[derive(Clone)]
struct MockReporter {}
#[async_trait]
impl ProgressReporterTrait for MockReporter {
fn update(&self, message: &str) {
println!("Mock update: {}", message);
}
async fn finish(&self, success: bool, message: &str) {
if success {
println!("Mock finish: {}", message);
} else {
println!("Mock error: {}", message);
}
}
}
impl MockReporter {
fn new() -> Self {
MockReporter {}
}
}
#[tokio::test]
async fn test_create_whisper_online() {
let result = new(Some("https://api.openai.com/v1"), Some("test-key")).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_generate_subtitle() {
let result = new(Some("https://api.openai.com/v1"), Some("sk-****")).await;
assert!(result.is_ok());
let result = result.unwrap();
let result = result
.generate_subtitle(
&MockReporter::new(),
Path::new("tests/audio/test.wav"),
Path::new("tests/audio/test.srt"),
)
.await;
println!("{:?}", result);
assert!(result.is_ok());
let result = result.unwrap();
println!("{}", result.subtitle_content);
}
}

View File

@@ -5,7 +5,12 @@
import Setting from "./page/Setting.svelte";
import Account from "./page/Account.svelte";
import About from "./page/About.svelte";
import { log } from "./lib/invoker";
import Clip from "./page/Clip.svelte";
import Task from "./page/Task.svelte";
let active = "总览";
log.info("App loaded");
</script>
<main>
@@ -25,6 +30,12 @@
<div class="page" class:visible={active == "直播间"}>
<Room />
</div>
<div class="page" class:visible={active == "切片"}>
<Clip />
</div>
<div class="page" class:visible={active == "任务"}>
<Task />
</div>
<div class="page" class:visible={active == "账号"}>
<Account />
</div>

97
src/AppClip.svelte Normal file
View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { invoke } from "./lib/invoker";
import { onMount } from "svelte";
import VideoPreview from "./lib/VideoPreview.svelte";
import type { Config, VideoItem } from "./lib/interface";
import { convertFileSrc, set_title } from "./lib/invoker";
let video: VideoItem | null = null;
let videos: any[] = [];
let showVideoPreview = false;
let roomId: number | null = null;
let config: Config = null;
invoke("get_config").then((c) => {
config = c as Config;
console.log(config);
});
onMount(async () => {
const videoId = new URLSearchParams(window.location.search).get("id");
if (videoId) {
try {
// 获取视频信息
const videoData = await invoke("get_video", { id: parseInt(videoId) });
roomId = (videoData as VideoItem).room_id;
// update window title to file name
set_title((videoData as VideoItem).file);
// 获取房间下的所有视频列表
if (roomId) {
videos = (
(await invoke("get_videos", { roomId: roomId })) as VideoItem[]
).map((v) => {
return {
id: v.id,
value: v.id,
name: v.file,
file: convertFileSrc(config.output + "/" + v.file),
cover: v.cover,
};
});
}
// find video in videos
video = videos.find((v) => v.id === parseInt(videoId));
// 显示视频预览
showVideoPreview = true;
} catch (error) {
console.error("Failed to load video:", error);
}
}
console.log(video);
});
async function handleVideoChange(newVideo: VideoItem) {
video = newVideo;
}
async function handleVideoListUpdate() {
if (roomId) {
const videosData = await invoke("get_videos", { roomId });
videos = (videosData as VideoItem[]).map((v) => {
return {
id: v.id,
value: v.id,
name: v.file,
file: convertFileSrc(config.output + "/" + v.file),
cover: v.cover,
};
});
}
}
</script>
{#if showVideoPreview && video && roomId}
<VideoPreview
bind:show={showVideoPreview}
{video}
{videos}
{roomId}
onVideoChange={handleVideoChange}
onVideoListUpdate={handleVideoListUpdate}
/>
{:else}
<main
class="flex items-center justify-center h-screen bg-[#1c1c1e] text-white"
>
<div class="text-center">
<div
class="animate-spin h-8 w-8 border-2 border-[#0A84FF] border-t-transparent rounded-full mx-auto mb-4"
></div>
<p>加载中...</p>
</div>
</main>
{/if}

View File

@@ -5,24 +5,22 @@
TAURI_ENV,
convertFileSrc,
listen,
log,
} from "./lib/invoker";
import Player from "./lib/Player.svelte";
import type { AccountInfo, RecordItem } from "./lib/db";
import type { RecordItem } from "./lib/db";
import { ChevronRight, ChevronLeft, Play, Pen } from "lucide-svelte";
import {
type Profile,
type VideoItem,
type Config,
type Marker,
type ProgressUpdate,
type ProgressFinished,
type DanmuEntry,
clipRange,
generateEventId,
} from "./lib/interface";
import TypeSelect from "./lib/TypeSelect.svelte";
import MarkerPanel from "./lib/MarkerPanel.svelte";
import CoverEditor from "./lib/CoverEditor.svelte";
import VideoPreview from "./lib/VideoPreview.svelte";
import { onDestroy, onMount } from "svelte";
const urlParams = new URLSearchParams(window.location.search);
@@ -32,8 +30,8 @@
const focus_start = parseInt(urlParams.get("start") || "0");
const focus_end = parseInt(urlParams.get("end") || "0");
// get profile in local storage with a default value
let profile: Profile = get_profile();
log.info("AppLive loaded", room_id, platform, live_id);
let config: Config = null;
invoke("get_config").then((c) => {
@@ -41,59 +39,130 @@
console.log(config);
});
function get_profile(): Profile {
const profile_str = window.localStorage.getItem("profile-" + room_id);
if (profile_str && profile_str.includes("videos")) {
return JSON.parse(profile_str);
}
return default_profile();
}
$: {
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
}
function default_profile(): Profile {
return {
videos: [],
cover: "",
cover43: null,
title: "",
copyright: 1,
tid: 27,
tag: "",
desc_format_id: 9999,
desc: "",
recreate: -1,
dynamic: "",
interactive: 0,
act_reserve_create: 0,
no_disturbance: 0,
no_reprint: 0,
subtitle: {
open: 0,
lan: "",
},
dolby: 0,
lossless_music: 0,
up_selection_reply: false,
up_close_danmu: false,
up_close_reply: false,
web_os: 0,
};
}
let current_clip_event_id = null;
let current_post_event_id = null;
let danmu_enabled = false;
// 弹幕相关变量
let danmu_records: DanmuEntry[] = [];
let filtered_danmu: DanmuEntry[] = [];
let danmu_search_text = "";
// 虚拟滚动相关变量
let danmu_container_height = 0;
let danmu_item_height = 80; // 预估每个弹幕项的高度
let visible_start_index = 0;
let visible_end_index = 0;
let scroll_top = 0;
let container_ref: HTMLElement;
let scroll_timeout: ReturnType<typeof setTimeout>;
// 计算可见区域的弹幕
function calculate_visible_danmu() {
if (!container_ref || filtered_danmu.length === 0) return;
const container_height = container_ref.clientHeight;
const buffer = 10; // 缓冲区,多渲染几个项目
visible_start_index = Math.max(
0,
Math.floor(scroll_top / danmu_item_height) - buffer
);
visible_end_index = Math.min(
filtered_danmu.length,
Math.ceil((scroll_top + container_height) / danmu_item_height) + buffer
);
}
// 处理滚动事件(带防抖)
function handle_scroll(event: Event) {
const target = event.target as HTMLElement;
scroll_top = target.scrollTop;
// 清除之前的定时器
if (scroll_timeout) {
clearTimeout(scroll_timeout);
}
// 防抖处理,避免频繁计算
scroll_timeout = setTimeout(() => {
calculate_visible_danmu();
}, 16); // 约60fps
}
// 监听容器大小变化
function handle_resize() {
if (container_ref) {
danmu_container_height = container_ref.clientHeight;
calculate_visible_danmu();
}
}
// 监听弹幕数据变化,更新过滤结果
$: {
if (danmu_records) {
// 如果当前有搜索文本,重新过滤
if (danmu_search_text) {
filter_danmu();
} else {
// 否则直接复制所有弹幕
filtered_danmu = [...danmu_records];
}
// 重新计算可见区域
calculate_visible_danmu();
}
}
// 监听容器引用变化
$: if (container_ref) {
handle_resize();
}
// 过滤弹幕
function filter_danmu() {
filtered_danmu = danmu_records.filter((danmu) => {
// 只按内容过滤
if (
danmu_search_text &&
!danmu.content.toLowerCase().includes(danmu_search_text.toLowerCase())
) {
return false;
}
return true;
});
}
// 监听弹幕搜索变化
$: {
if (danmu_search_text !== undefined && danmu_records) {
filter_danmu();
}
}
// 格式化时间
function format_time(ts: number): string {
const date = new Date(ts);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
const seconds = date.getSeconds().toString().padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 跳转到弹幕时间点
function seek_to_danmu(danmu: DanmuEntry) {
if (player) {
const time_in_seconds = danmu.ts / 1000 - global_offset;
player.seek(time_in_seconds);
}
}
const update_listener = listen<ProgressUpdate>(`progress-update`, (e) => {
console.log("progress-update event", e.payload.id);
let event_id = e.payload.id;
if (event_id === current_clip_event_id) {
update_clip_prompt(e.payload.content);
} else if (event_id === current_post_event_id) {
update_post_prompt(e.payload.content);
}
});
const finished_listener = listen<ProgressFinished>(
@@ -108,19 +177,18 @@
alert("请检查 ffmpeg 是否配置正确:" + e.payload.message);
}
current_clip_event_id = null;
} else if (event_id === current_post_event_id) {
update_post_prompt(`投稿`);
if (!e.payload.success) {
alert(e.payload.message);
}
current_post_event_id = null;
}
},
}
);
onDestroy(() => {
update_listener?.then((fn) => fn());
finished_listener?.then((fn) => fn());
// 清理滚动定时器
if (scroll_timeout) {
clearTimeout(scroll_timeout);
}
});
let archive: RecordItem = null;
@@ -151,17 +219,13 @@
return canvas.toDataURL();
}
let preview = false;
let show_cover_editor = false;
let show_clip_confirm = false;
let text_style = {
position: { x: 8, y: 8 },
fontSize: 24,
color: "#FF7F00",
};
let uid_selected = 0;
let video_selected = 0;
let accounts = [];
let videos = [];
let selected_video = null;
@@ -177,17 +241,13 @@
// Initialize video element when component is mounted
onMount(() => {
video = document.getElementById("video") as HTMLVideoElement;
});
invoke("get_accounts").then((account_info: AccountInfo) => {
accounts = account_info.accounts.map((a) => {
return {
value: a.uid,
name: a.name,
platform: a.platform,
};
});
accounts = accounts.filter((a) => a.platform === "bilibili");
// 初始化虚拟滚动
setTimeout(() => {
if (container_ref) {
handle_resize();
}
}, 100);
});
get_video_list();
@@ -197,7 +257,7 @@
console.log(a);
archive = a;
set_title(`[${room_id}]${archive.title}`);
},
}
);
function update_clip_prompt(str: string) {
@@ -208,13 +268,6 @@
}
}
function update_post_prompt(str: string) {
const span = document.getElementById("post-prompt");
if (span) {
span.textContent = str;
}
}
async function get_video_list() {
videos = (
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
@@ -229,15 +282,19 @@
});
}
function find_video(e) {
async function find_video(e) {
if (!e.target) {
selected_video = null;
return;
}
const id = parseInt(e.target.value);
selected_video = videos.find((v) => {
let target_video = videos.find((v) => {
return v.value == id;
});
if (target_video) {
target_video.cover = await invoke("get_video_cover", { id: id });
}
selected_video = target_video;
console.log("video selected", videos, selected_video, e, id);
}
@@ -260,7 +317,7 @@
update_clip_prompt(`切片生成中`);
let event_id = generateEventId();
current_clip_event_id = event_id;
let new_video = await clipRange(event_id, {
let new_video = (await clipRange(event_id, {
title: archive.title,
room_id: room_id,
platform: platform,
@@ -270,8 +327,10 @@
y: Math.floor(focus_start + end),
danmu: danmu_enabled,
offset: global_offset,
});
console.log("video file generatd:", new_video);
local_offset:
parseInt(localStorage.getItem(`local_offset:${live_id}`) || "0", 10) ||
0,
})) as VideoItem;
await get_video_list();
video_selected = new_video.id;
selected_video = videos.find((v) => {
@@ -282,30 +341,6 @@
}
}
async function do_post() {
if (!selected_video) {
return;
}
let event_id = generateEventId();
current_post_event_id = event_id;
update_post_prompt(`投稿上传中`);
// update profile in local storage
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
invoke("upload_procedure", {
uid: uid_selected,
eventId: event_id,
roomId: room_id,
videoId: video_selected,
cover: selected_video.cover,
profile: profile,
}).then(async () => {
video_selected = 0;
await get_video_list();
});
}
async function cancel_clip() {
if (!current_clip_event_id) {
return;
@@ -313,13 +348,6 @@
invoke("cancel", { eventId: current_clip_event_id });
}
async function cancel_post() {
if (!current_post_event_id) {
return;
}
invoke("cancel", { eventId: current_post_event_id });
}
async function delete_video() {
if (!selected_video) {
return;
@@ -335,13 +363,13 @@
let markers: Marker[] = [];
// load markers from local storage
markers = JSON.parse(
window.localStorage.getItem(`markers:${room_id}:${live_id}`) || "[]",
window.localStorage.getItem(`markers:${room_id}:${live_id}`) || "[]"
);
$: {
// makers changed, save to local storage
window.localStorage.setItem(
`markers:${room_id}:${live_id}`,
JSON.stringify(markers),
JSON.stringify(markers)
);
}
@@ -357,6 +385,10 @@
a.download = video_name;
a.click();
}
async function open_clip(video_id: number) {
await invoke("open_clip", { videoId: video_id });
}
</script>
<main>
@@ -401,6 +433,7 @@
bind:end
bind:global_offset
bind:this={player}
bind:danmu_records
{focus_start}
{focus_end}
{platform}
@@ -416,19 +449,6 @@
markers = markers.sort((a, b) => a.offset - b.offset);
}}
/>
<VideoPreview
bind:show={preview}
video={selected_video}
roomId={room_id}
{videos}
onVideoChange={(video) => {
selected_video = video;
}}
onClose={() => {
preview = false;
}}
onVideoListUpdate={get_video_list}
/>
</div>
<div
class="flex relative h-screen border-solid bg-gray-950 border-l-2 border-gray-800 text-white transition-all duration-300 ease-in-out"
@@ -456,18 +476,11 @@
class:opacity-100={!rpanel_collapsed}
class:invisible={rpanel_collapsed}
>
<!-- 顶部标题栏 -->
<div
class="flex-none sticky top-0 z-10 backdrop-blur-xl bg-[#1c1c1e]/80 px-6 py-4 border-b border-gray-800/50"
>
<h2 class="text-lg font-medium">视频投稿</h2>
</div>
<!-- 内容区域 -->
<div class="flex-1 overflow-y-auto">
<div class="px-6 py-4 space-y-8">
<div class="flex-1 overflow-hidden flex flex-col">
<div class="px-6 py-4 space-y-8 flex flex-col h-full">
<!-- 切片操作区 -->
<section class="space-y-3">
<section class="space-y-3 flex-shrink-0">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-300">切片列表</h3>
<div class="flex space-x-2">
@@ -530,34 +543,23 @@
transition-all duration-200 hover:bg-[#0A84FF]/90
disabled:opacity-50 disabled:cursor-not-allowed"
>
保存
下载
</button>
{/if}
</div>
</section>
<!-- 封面预览 -->
{#if selected_video && selected_video.id != -1}
<section>
<section class="flex-shrink-0">
<div class="group">
<div
class="text-sm text-gray-400 mb-2 flex items-center justify-between"
>
<span>视频封面</span>
<button
class="text-[#0A84FF] hover:text-[#0A84FF]/80 transition-colors duration-200 flex items-center space-x-1"
on:click={() => (show_cover_editor = true)}
>
<Pen class="w-4 h-4" />
<span class="text-xs">创建新封面</span>
</button>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
id="capture"
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50 cursor-pointer group"
on:click={() => {
on:click={async () => {
pauseVideo();
preview = true;
await open_clip(selected_video.id);
}}
>
<div
@@ -580,160 +582,92 @@
</section>
{/if}
<!-- 表单区域 -->
<section class="space-y-8">
<!-- 基本信息 -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-400">基本信息</h3>
<!-- 标题 -->
<div class="space-y-2">
<label
for="title"
class="block text-sm font-medium text-gray-300">标题</label
>
<input
id="title"
type="text"
bind:value={profile.title}
placeholder="输入视频标题"
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none
hover:border-gray-700/50"
/>
</div>
<!-- 视频分区 -->
<div class="space-y-2">
<label
for="tid"
class="block text-sm font-medium text-gray-300"
>视频分区</label
>
<div class="w-full" id="tid">
<TypeSelect bind:value={profile.tid} />
</div>
</div>
<!-- 投稿账号 -->
<div id="uid" class="space-y-2">
<label
for="uid"
class="block text-sm font-medium text-gray-300"
>投稿账号</label
>
<select
bind:value={uid_selected}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none appearance-none
hover:border-gray-700/50"
>
{#each accounts as account}
<option value={account.value}>{account.name}</option>
{/each}
</select>
</div>
<!-- 弹幕列表区 -->
<section class="space-y-3 flex flex-col flex-1 min-h-0">
<div class="flex items-center justify-between flex-shrink-0">
<h3 class="text-sm font-medium text-gray-300">弹幕列表</h3>
</div>
<!-- 详细信息 -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-400">详细信息</h3>
<!-- 描述 -->
<div class="space-y-2">
<label
for="desc"
class="block text-sm font-medium text-gray-300">描述</label
>
<textarea
id="desc"
bind:value={profile.desc}
placeholder="输入视频描述"
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none resize-none h-32
hover:border-gray-700/50"
/>
</div>
<!-- 标签 -->
<div class="space-y-2">
<label
for="tag"
class="block text-sm font-medium text-gray-300">标签</label
>
<div class="space-y-3 flex flex-col flex-1 min-h-0">
<!-- 搜索 -->
<div class="space-y-2 flex-shrink-0">
<input
id="tag"
type="text"
bind:value={profile.tag}
placeholder="输入视频标签,用逗号分隔"
bind:value={danmu_search_text}
placeholder="搜索弹幕内容..."
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none
hover:border-gray-700/50"
placeholder-gray-500"
/>
</div>
<!-- 动态 -->
<div class="space-y-2">
<label
for="dynamic"
class="block text-sm font-medium text-gray-300">动态</label
>
<textarea
id="dynamic"
bind:value={profile.dynamic}
placeholder="输入动态内容"
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none resize-none h-32
hover:border-gray-700/50"
<!-- 弹幕统计 -->
<div class="text-xs text-gray-400 flex-shrink-0">
{danmu_records.length} 条弹幕,显示 {filtered_danmu.length}
</div>
<!-- 弹幕列表 -->
<div
bind:this={container_ref}
on:scroll={handle_scroll}
class="flex-1 overflow-y-auto space-y-2 sidebar-scrollbar min-h-0 danmu-container"
>
<!-- 顶部占位符 -->
<div
style="height: {visible_start_index * danmu_item_height}px;"
/>
<!-- 可见的弹幕项 -->
{#each filtered_danmu.slice(visible_start_index, visible_end_index) as danmu, index (visible_start_index + index)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="p-3 bg-[#2c2c2e] rounded-lg border border-gray-800/50
hover:border-[#0A84FF]/50 transition-all duration-200
cursor-pointer group danmu-item"
style="content-visibility: auto; contain-intrinsic-size: {danmu_item_height}px;"
on:click={() => seek_to_danmu(danmu)}
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<p
class="text-sm text-white break-words leading-relaxed"
>
{danmu.content}
</p>
</div>
<div class="ml-3 flex-shrink-0">
<span
class="text-xs text-gray-400 bg-[#1c1c1e] px-2 py-1 rounded
group-hover:text-[#0A84FF] transition-colors duration-200"
>
{format_time(danmu.ts)}
</span>
</div>
</div>
</div>
{/each}
<!-- 底部占位符 -->
<div
style="height: {(filtered_danmu.length -
visible_end_index) *
danmu_item_height}px;"
/>
{#if filtered_danmu.length === 0}
<div class="text-center py-8 text-gray-500">
{danmu_records.length === 0
? "暂无弹幕数据"
: "没有匹配的弹幕"}
</div>
{/if}
</div>
</div>
</section>
<!-- 投稿按钮 -->
{#if selected_video}
<div class="h-10" />
{/if}
</div>
</div>
<!-- 底部按钮 -->
{#if selected_video}
<div
class="flex-none sticky bottom-0 px-6 py-4 bg-gradient-to-t from-[#1c1c1e] via-[#1c1c1e] to-transparent"
>
<div class="flex gap-3">
<button
on:click={do_post}
disabled={current_post_event_id != null}
class="flex-1 px-4 py-2.5 bg-[#0A84FF] text-white rounded-lg
transition-all duration-200 hover:bg-[#0A84FF]/90
disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center space-x-2"
>
{#if current_post_event_id != null}
<div
class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"
/>
{/if}
<span id="post-prompt">投稿</span>
</button>
{#if current_post_event_id != null}
<button
on:click={() => cancel_post()}
class="w-24 px-3 py-2 bg-red-500 text-white rounded-lg
transition-all duration-200 hover:bg-red-500/90
flex items-center justify-center"
>
取消
</button>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
@@ -780,17 +714,6 @@
</div>
{/if}
<CoverEditor
bind:show={show_cover_editor}
video={selected_video}
on:coverUpdate={(event) => {
selected_video = {
...selected_video,
cover: event.detail.cover,
};
}}
/>
<style>
main {
width: 100vw;
@@ -820,4 +743,34 @@
background-color: rgb(3 7 18 / var(--tw-bg-opacity));
transform: translateY(-50%);
}
/* 弹幕列表滚动条样式 */
.sidebar-scrollbar::-webkit-scrollbar {
width: 6px;
}
.sidebar-scrollbar::-webkit-scrollbar-track {
background: rgba(44, 44, 46, 0.3);
border-radius: 3px;
}
.sidebar-scrollbar::-webkit-scrollbar-thumb {
background: rgba(10, 132, 255, 0.5);
border-radius: 3px;
}
.sidebar-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(10, 132, 255, 0.7);
}
/* 虚拟滚动优化 */
.danmu-container {
will-change: scroll-position;
contain: layout style paint;
}
.danmu-item {
contain: layout style paint;
will-change: transform;
}
</style>

View File

@@ -1,5 +1,13 @@
<script>
import { Info, LayoutDashboard, Settings, Users, Video } from "lucide-svelte";
import {
FileVideo,
Info,
LayoutDashboard,
List,
Settings,
Users,
Video,
} from "lucide-svelte";
import { hasNewVersion } from "./stores/version";
import SidebarItem from "./SidebarItem.svelte";
import { createEventDispatcher } from "svelte";
@@ -30,6 +38,16 @@
<Video class="w-5 h-5" />
</div>
</SidebarItem>
<SidebarItem label="切片" {activeUrl} on:activeChange={navigate}>
<div slot="icon">
<FileVideo class="w-5 h-5" />
</div>
</SidebarItem>
<SidebarItem label="任务" {activeUrl} on:activeChange={navigate}>
<div slot="icon">
<List class="w-5 h-5" />
</div>
</SidebarItem>
<SidebarItem label="账号" {activeUrl} on:activeChange={navigate}>
<div slot="icon">
<Users class="w-5 h-5" />

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Play, X, Type, Palette, Move, Plus, Trash2 } from "lucide-svelte";
import { invoke } from "../lib/invoker";
import { invoke, log } from "../lib/invoker";
import { onMount, createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
@@ -83,7 +83,7 @@
scheduleRedraw();
};
backgroundImage.onerror = (e) => {
console.error("Failed to load image:", e);
log.error("Failed to load image:", e);
};
backgroundImage.src = videoFrame;
}
@@ -236,7 +236,10 @@
function handleVideoLoaded() {
isVideoLoaded = true;
duration = videoElement.duration;
updateCoverFromVideo();
// 延迟一点时间确保视频完全准备好
setTimeout(() => {
updateCoverFromVideo();
}, 100);
}
function handleTimeUpdate() {
@@ -248,10 +251,13 @@
const time = parseFloat(target.value);
if (videoElement) {
videoElement.currentTime = time;
updateCoverFromVideo();
}
}
function handleVideoSeeked() {
updateCoverFromVideo();
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
@@ -259,15 +265,46 @@
}
function updateCoverFromVideo() {
if (!videoElement) return;
if (!videoElement || !isVideoLoaded) return;
const tempCanvas = document.createElement("canvas");
tempCanvas.width = videoElement.videoWidth;
tempCanvas.height = videoElement.videoHeight;
const tempCtx = tempCanvas.getContext("2d");
tempCtx.drawImage(videoElement, 0, 0, tempCanvas.width, tempCanvas.height);
videoFrame = tempCanvas.toDataURL("image/jpeg");
loadBackgroundImage();
// 确保视频已经准备好并且有有效的尺寸
if (videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
// 如果视频尺寸无效,等待一下再重试
setTimeout(() => {
if (
videoElement &&
videoElement.videoWidth > 0 &&
videoElement.videoHeight > 0
) {
updateCoverFromVideo();
}
}, 100);
return;
}
try {
const tempCanvas = document.createElement("canvas");
tempCanvas.width = videoElement.videoWidth;
tempCanvas.height = videoElement.videoHeight;
const tempCtx = tempCanvas.getContext("2d");
if (!tempCtx) {
log.error("Failed to get canvas context");
return;
}
tempCtx.drawImage(
videoElement,
0,
0,
tempCanvas.width,
tempCanvas.height
);
videoFrame = tempCanvas.toDataURL("image/jpeg");
loadBackgroundImage();
} catch (error) {
log.error("Failed to capture video frame:", error);
}
}
function handleClose() {
@@ -333,6 +370,10 @@
// 监听 show 变化,当模态框显示时重新绘制
$: if (show && ctx) {
setTimeout(() => {
if (isVideoLoaded && videoElement) {
// 如果视频已加载,更新封面
updateCoverFromVideo();
}
loadBackgroundImage();
resizeCanvas();
}, 50);
@@ -402,6 +443,7 @@
crossorigin="anonymous"
on:loadedmetadata={handleVideoLoaded}
on:timeupdate={handleTimeUpdate}
on:seeked={handleVideoSeeked}
/>
<!-- Video Controls -->

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { get } from "./invoker";
import { get, log } from "./invoker";
export let src = "";
export let iclass = "";
let b = "";
@@ -16,9 +16,8 @@
async function init() {
try {
b = await getImage(src);
console.log(b);
} catch (e) {
console.error(e);
log.error("Failed to get image:", e);
}
}
init();

View File

@@ -86,7 +86,7 @@
<Tooltip>清空</Tooltip>
</div>
<div class="overflow-y-auto">
<div class="overflow-y-auto sidebar-scrollbar">
{#each markers as marker, i}
<div class="marker-entry">
<div class="marker-control">

View File

@@ -3,7 +3,7 @@
</script>
<script lang="ts">
import { invoke, TAURI_ENV, ENDPOINT, listen } from "../lib/invoker";
import { invoke, TAURI_ENV, ENDPOINT, listen, log } from "../lib/invoker";
import type { AccountInfo } from "./db";
import type { Marker, RecorderList, RecorderInfo } from "./interface";
@@ -30,6 +30,7 @@
export let focus_start = 0;
export let focus_end = 0;
export let markers: Marker[] = [];
export let danmu_records: DanmuEntry[] = [];
export function seek(offset: number) {
video.currentTime = offset;
}
@@ -39,6 +40,10 @@
let show_export = false;
let recorders: RecorderInfo[] = [];
// local setting of danmu offset
let local_offset: number =
parseInt(localStorage.getItem(`local_offset:${live_id}`) || "0", 10) || 0;
// save start and end to localStorage
function saveStartEnd() {
localStorage.setItem(`${live_id}_start`, (start + focus_start).toString());
@@ -67,8 +72,6 @@
timedOut: false,
};
let manifestPrintCnt = 0;
const pendingRequest = new Promise((resolve, reject) => {
invoke("fetch_hls", { uri: uri })
.then((data: number[]) => {
@@ -96,13 +99,6 @@
console.warn("No DANMU OFFSET found");
}
}
// Print manifest for debugging every 30 times.
if (manifestPrintCnt == 0) {
console.log(m3u8Content);
} else {
manifestPrintCnt = (manifestPrintCnt + 1) % 30;
}
}
// Set content-type based on URI extension
@@ -126,14 +122,14 @@
resolve(response);
})
.catch((error) => {
console.error("Network error:", error);
log.error("Network error:", error);
reject(
new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.NETWORK,
shaka.util.Error.Code.OPERATION_ABORTED,
error.message || "Network request failed",
),
error.message || "Network request failed"
)
);
});
});
@@ -223,7 +219,7 @@
// This runs if the asynchronous load is successful.
console.log("The video has now been loaded!");
} catch (error) {
console.error("Error code", error.code, "object", error);
log.error("Error code", error.code, "object", error);
if (error.code == 3000) {
// reload
setTimeout(() => {
@@ -236,8 +232,9 @@
error.code +
"\n" +
"Error message: " +
error.message,
error.message
);
log.error("Error code", error.code, "object", error);
}
}
@@ -256,7 +253,7 @@
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
const shakaBottomControls = document.querySelector(
".shaka-bottom-controls.shaka-no-propagation",
".shaka-bottom-controls.shaka-no-propagation"
);
const selfSeekbar = document.createElement("div");
selfSeekbar.className = "shaka-seek-bar shaka-no-propagation";
@@ -277,7 +274,7 @@
let danmu_enabled = true;
// get danmaku record
let danmu_records: DanmuEntry[] = (await invoke("get_danmu_record", {
danmu_records = (await invoke("get_danmu_record", {
roomId: room_id,
liveId: live_id,
platform: platform,
@@ -287,36 +284,36 @@
let ts = parseInt(live_id);
if (platform == "bilibili") {
let danmu_displayed = {};
// history danmaku sender
setInterval(() => {
if (video.paused || !danmu_enabled || danmu_records.length == 0) {
let danmu_displayed = {};
// history danmaku sender
setInterval(() => {
if (video.paused || !danmu_enabled || danmu_records.length == 0) {
return;
}
// using live source
if (isLive() && get_total() - video.currentTime <= 5) {
return;
}
const cur = Math.floor(
(video.currentTime + global_offset + focus_start + local_offset) * 1000
);
let danmus = danmu_records.filter((v) => {
return v.ts >= cur - 1000 && v.ts < cur;
});
danmus.forEach((v) => {
if (danmu_displayed[v.ts]) {
delete danmu_displayed[v.ts];
return;
}
danmu_handler(v.content);
});
}, 1000);
// using live source
if (isLive() && get_total() - video.currentTime <= 5) {
return;
}
const cur = Math.floor(
(video.currentTime + global_offset + focus_start) * 1000,
);
let danmus = danmu_records.filter((v) => {
return v.ts >= cur - 1000 && v.ts < cur;
});
danmus.forEach((v) => {
if (danmu_displayed[v.ts]) {
delete danmu_displayed[v.ts];
return;
}
danmu_handler(v.content);
});
}, 1000);
if (isLive()) {
if (isLive()) {
if (platform == "bilibili") {
// add a account select
const accountSelect = document.createElement("select");
accountSelect.style.height = "30px";
@@ -327,7 +324,6 @@
accountSelect.style.padding = "0 10px";
accountSelect.style.boxSizing = "border-box";
accountSelect.style.fontSize = "1em";
// get accounts from tauri
const account_info = (await invoke("get_accounts")) as AccountInfo;
account_info.accounts.forEach((account) => {
@@ -370,184 +366,279 @@
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
// listen to danmaku event
await listen("danmu:" + room_id, (event: { payload: DanmuEntry }) => {
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
danmu_records.push(event.payload);
return;
}
if (Object.keys(danmu_displayed).length > 1000) {
danmu_displayed = {};
}
danmu_displayed[event.payload.ts] = true;
danmu_records.push(event.payload);
danmu_handler(event.payload.content);
});
}
// create a danmaku toggle button
const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启";
danmakuToggle.style.height = "30px";
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
danmakuToggle.style.color = "white";
danmakuToggle.style.border = "1px solid gray";
danmakuToggle.style.padding = "0 10px";
danmakuToggle.style.boxSizing = "border-box";
danmakuToggle.style.fontSize = "1em";
danmakuToggle.addEventListener("click", async () => {
danmu_enabled = !danmu_enabled;
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
// clear background color
danmakuToggle.style.backgroundColor = danmu_enabled
? "rgba(0, 128, 255, 0.5)"
: "rgba(255, 0, 0, 0.5)";
});
// create a area that overlay half top of the video, which shows danmakus floating from right to left
const overlay = document.createElement("div");
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.position = "absolute";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
overlay.style.paddingTop = "10%";
// place overlay to the top of the video
video.parentElement.appendChild(overlay);
// Store the positions of the last few danmakus to avoid overlap
const danmakuPositions = [];
function danmu_handler(content: string) {
const danmaku = document.createElement("p");
danmaku.style.position = "absolute";
// Calculate a random position for the danmaku
let topPosition = 0;
let attempts = 0;
do {
topPosition = Math.random() * 30;
attempts++;
} while (
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
attempts < 10
);
// Record the position
danmakuPositions.push(topPosition);
if (danmakuPositions.length > 10) {
danmakuPositions.shift(); // Keep the last 10 positions
// listen to danmaku event
await listen("danmu:" + room_id, (event: { payload: DanmuEntry }) => {
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
danmu_records = [...danmu_records, event.payload];
return;
}
danmaku.style.top = `${topPosition}%`;
danmaku.style.right = "0";
danmaku.style.color = "white";
danmaku.style.fontSize = "1.2em";
danmaku.style.whiteSpace = "nowrap";
danmaku.style.transform = "translateX(100%)";
danmaku.style.transition = "transform 10s linear";
danmaku.style.pointerEvents = "none";
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = content;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
});
danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku);
});
}
shakaSpacer.appendChild(danmakuToggle);
if (Object.keys(danmu_displayed).length > 1000) {
danmu_displayed = {};
}
danmu_displayed[event.payload.ts] = true;
danmu_records = [...danmu_records, event.payload];
danmu_handler(event.payload.content);
});
}
// create a playback rate select to of shaka-spacer
const playbackRateSelect = document.createElement("select");
playbackRateSelect.style.height = "30px";
playbackRateSelect.style.minWidth = "60px";
playbackRateSelect.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
playbackRateSelect.style.color = "white";
playbackRateSelect.style.border = "1px solid gray";
playbackRateSelect.style.padding = "0 10px";
playbackRateSelect.style.boxSizing = "border-box";
playbackRateSelect.style.fontSize = "1em";
playbackRateSelect.style.right = "10px";
playbackRateSelect.style.position = "absolute";
playbackRateSelect.innerHTML = `
<option value="0.5">0.5x</option>
<option value="1">1x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
<option value="5">5x</option>
`;
// default playback rate is 1
playbackRateSelect.value = "1";
playbackRateSelect.addEventListener("change", () => {
const rate = parseFloat(playbackRateSelect.value);
video.playbackRate = rate;
// create a danmaku toggle button
const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启";
danmakuToggle.style.height = "30px";
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
danmakuToggle.style.color = "white";
danmakuToggle.style.border = "1px solid gray";
danmakuToggle.style.padding = "0 10px";
danmakuToggle.style.boxSizing = "border-box";
danmakuToggle.style.fontSize = "1em";
danmakuToggle.addEventListener("click", async () => {
danmu_enabled = !danmu_enabled;
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
// clear background color
danmakuToggle.style.backgroundColor = danmu_enabled
? "rgba(0, 128, 255, 0.5)"
: "rgba(255, 0, 0, 0.5)";
});
shakaSpacer.appendChild(playbackRateSelect);
// create a area that overlay half top of the video, which shows danmakus floating from right to left
const overlay = document.createElement("div");
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.position = "absolute";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
overlay.style.paddingTop = "10%";
// place overlay to the top of the video
video.parentElement.appendChild(overlay);
// Store the positions of the last few danmakus to avoid overlap
const danmakuPositions = [];
function danmu_handler(content: string) {
const danmaku = document.createElement("p");
danmaku.style.position = "absolute";
// Calculate a random position for the danmaku
let topPosition = 0;
let attempts = 0;
do {
topPosition = Math.random() * 30;
attempts++;
} while (
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
attempts < 10
);
// Record the position
danmakuPositions.push(topPosition);
if (danmakuPositions.length > 10) {
danmakuPositions.shift(); // Keep the last 10 positions
}
danmaku.style.top = `${topPosition}%`;
danmaku.style.right = "0";
danmaku.style.color = "white";
danmaku.style.fontSize = "1.2em";
danmaku.style.whiteSpace = "nowrap";
danmaku.style.transform = "translateX(100%)";
danmaku.style.transition = "transform 10s linear";
danmaku.style.pointerEvents = "none";
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = content;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
});
danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku);
});
}
shakaSpacer.appendChild(danmakuToggle);
// create a playback rate button and menu
const playbackRateButton = document.createElement("button");
playbackRateButton.style.height = "30px";
playbackRateButton.style.minWidth = "30px";
playbackRateButton.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
playbackRateButton.style.color = "white";
playbackRateButton.style.border = "1px solid gray";
playbackRateButton.style.padding = "0 10px";
playbackRateButton.style.boxSizing = "border-box";
playbackRateButton.style.fontSize = "1em";
playbackRateButton.style.right = "10px";
playbackRateButton.style.position = "absolute";
playbackRateButton.innerText = "⚙️";
const SettingMenu = document.createElement("div");
SettingMenu.style.position = "absolute";
SettingMenu.style.bottom = "40px";
SettingMenu.style.right = "10px";
SettingMenu.style.backgroundColor = "rgba(0, 0, 0, 0.8)";
SettingMenu.style.border = "1px solid gray";
SettingMenu.style.padding = "8px";
SettingMenu.style.display = "none";
SettingMenu.style.zIndex = "1000";
// Add danmaku offset input
const offsetContainer = document.createElement("div");
offsetContainer.style.marginBottom = "8px";
const offsetLabel = document.createElement("label");
offsetLabel.innerText = "弹幕偏移(秒):";
offsetLabel.style.color = "white";
offsetLabel.style.marginRight = "8px";
const offsetInput = document.createElement("input");
offsetInput.type = "number";
offsetInput.value = "0";
offsetInput.style.width = "60px";
offsetInput.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
offsetInput.style.color = "white";
offsetInput.style.border = "1px solid gray";
offsetInput.style.padding = "2px 4px";
offsetInput.style.boxSizing = "border-box";
offsetContainer.appendChild(offsetLabel);
offsetContainer.appendChild(offsetInput);
SettingMenu.appendChild(offsetContainer);
// Add divider
const divider = document.createElement("hr");
divider.style.border = "none";
divider.style.borderTop = "1px solid gray";
divider.style.margin = "8px 0";
SettingMenu.appendChild(divider);
// Add playback rate options
const rates = [0.5, 1, 1.5, 2, 5];
rates.forEach((rate) => {
const rateButton = document.createElement("button");
rateButton.innerText = `${rate}x`;
rateButton.style.display = "block";
rateButton.style.width = "100%";
rateButton.style.padding = "4px 8px";
rateButton.style.margin = "2px 0";
rateButton.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
rateButton.style.color = "white";
rateButton.style.border = "1px solid gray";
rateButton.style.cursor = "pointer";
rateButton.style.textAlign = "left";
if (rate === 1) {
rateButton.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
}
rateButton.addEventListener("click", () => {
video.playbackRate = rate;
// Update active state
rates.forEach((r) => {
const btn = SettingMenu.querySelector(
`button[data-rate="${r}"]`
) as HTMLButtonElement;
if (btn) {
btn.style.backgroundColor =
r === rate ? "rgba(0, 128, 255, 0.5)" : "rgba(0, 0, 0, 0.5)";
}
});
});
rateButton.setAttribute("data-rate", rate.toString());
SettingMenu.appendChild(rateButton);
});
// Handle offset input changes
offsetInput.addEventListener("change", () => {
const offset = parseFloat(offsetInput.value);
if (!isNaN(offset)) {
local_offset = offset;
localStorage.setItem(`local_offset:${live_id}`, offset.toString());
}
});
// Toggle menu visibility
playbackRateButton.addEventListener("click", () => {
SettingMenu.style.display =
SettingMenu.style.display === "none" ? "block" : "none";
// if display is block, button background color should be red
if (SettingMenu.style.display === "block") {
playbackRateButton.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
} else {
playbackRateButton.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
}
});
// Close menu when clicking outside
document.addEventListener("click", (e) => {
if (
!playbackRateButton.contains(e.target as Node) &&
!SettingMenu.contains(e.target as Node)
) {
SettingMenu.style.display = "none";
}
});
shakaSpacer.appendChild(playbackRateButton);
shakaSpacer.appendChild(SettingMenu);
let danmu_statistics: { ts: number; count: number }[] = [];
if (platform == "bilibili") {
// create a danmu statistics select into shaka-spacer
let statisticKey = "";
const statisticKeyInput = document.createElement("input");
statisticKeyInput.style.height = "30px";
statisticKeyInput.style.width = "100px";
statisticKeyInput.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
statisticKeyInput.style.color = "white";
statisticKeyInput.style.border = "1px solid gray";
statisticKeyInput.style.padding = "0 10px";
statisticKeyInput.style.boxSizing = "border-box";
statisticKeyInput.style.fontSize = "1em";
statisticKeyInput.style.right = "75px";
statisticKeyInput.placeholder = "弹幕统计过滤";
statisticKeyInput.style.position = "absolute";
// create a danmu statistics select into shaka-spacer
let statisticKey = "";
const statisticKeyInput = document.createElement("input");
statisticKeyInput.style.height = "30px";
statisticKeyInput.style.width = "100px";
statisticKeyInput.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
statisticKeyInput.style.color = "white";
statisticKeyInput.style.border = "1px solid gray";
statisticKeyInput.style.padding = "0 10px";
statisticKeyInput.style.boxSizing = "border-box";
statisticKeyInput.style.fontSize = "1em";
statisticKeyInput.style.right = "55px";
statisticKeyInput.placeholder = "弹幕统计过滤";
statisticKeyInput.style.position = "absolute";
function update_statistics() {
let counts = {};
danmu_records.forEach((e) => {
if (statisticKey != "" && !e.content.includes(statisticKey)) {
return;
}
const timeSlot = Math.floor(e.ts / 10000) * 10000; // 将时间戳向下取整到10秒
counts[timeSlot] = (counts[timeSlot] || 0) + 1;
});
danmu_statistics = [];
for (let ts in counts) {
danmu_statistics.push({ ts: parseInt(ts), count: counts[ts] });
function update_statistics() {
let counts = {};
danmu_records.forEach((e) => {
if (statisticKey != "" && !e.content.includes(statisticKey)) {
return;
}
}
update_statistics();
if (isLive()) {
setInterval(async () => {
update_statistics();
}, 10 * 1000);
}
statisticKeyInput.addEventListener("change", () => {
statisticKey = statisticKeyInput.value;
update_statistics();
const timeSlot =
Math.floor((e.ts + local_offset * 1000) / 10000) * 10000; // 将时间戳向下取整到10秒
counts[timeSlot] = (counts[timeSlot] || 0) + 1;
});
shakaSpacer.appendChild(statisticKeyInput);
danmu_statistics = [];
for (let ts in counts) {
danmu_statistics.push({ ts: parseInt(ts), count: counts[ts] });
}
}
update_statistics();
if (isLive()) {
setInterval(async () => {
update_statistics();
}, 10 * 1000);
}
statisticKeyInput.addEventListener("change", () => {
statisticKey = statisticKeyInput.value;
update_statistics();
});
shakaSpacer.appendChild(statisticKeyInput);
// shaka-spacer should be flex-direction: column
shakaSpacer.style.flexDirection = "column";
@@ -671,11 +762,11 @@
});
const seekbarContainer = selfSeekbar.querySelector(
".shaka-seek-bar-container.self-defined",
".shaka-seek-bar-container.self-defined"
) as HTMLElement;
const statisticGraph = document.createElement(
"canvas",
"canvas"
) as HTMLCanvasElement;
statisticGraph.style.pointerEvents = "none";
statisticGraph.style.position = "absolute";
@@ -768,7 +859,7 @@
}%, rgba(255, 255, 255, 0.2) ${first_point * 100}%)`;
// render markers in shaka-ad-markers
const adMarkers = document.querySelector(
".shaka-ad-markers",
".shaka-ad-markers"
) as HTMLElement;
if (adMarkers) {
// clean previous markers

View File

@@ -81,11 +81,14 @@
</script>
{#if show}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="fixed inset-0 bg-black/50 z-[1100] flex items-center justify-center"
on:click|self={handleClose}
>
<div class="bg-[#1c1c1e] rounded-lg w-[600px] max-h-[80vh] overflow-y-auto">
<div
class="bg-[#1c1c1e] rounded-lg w-[600px] max-h-[80vh] overflow-y-auto sidebar-scrollbar"
>
<!-- 顶部标题栏 -->
<div
class="flex items-center justify-between p-4 border-b border-gray-800/50"
@@ -113,7 +116,7 @@
bind:value={style.fontName}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none"
transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
<div class="space-y-2">
@@ -124,7 +127,7 @@
bind:value={style.fontSize}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none"
transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
</div>
@@ -135,23 +138,25 @@
<h3 class="text-sm font-medium text-gray-300">颜色设置</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block text-sm text-gray-400">字体颜色</label>
<input
type="color"
bind:value={style.fontColor}
class="w-full h-10 bg-[#2c2c2e] rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none"
transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
<div class="space-y-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block text-sm text-gray-400">描边颜色</label>
<input
type="color"
bind:value={style.outlineColor}
class="w-full h-10 bg-[#2c2c2e] rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none"
transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
</div>
@@ -161,6 +166,7 @@
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-300">描边设置</h3>
<div class="space-y-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block text-sm text-gray-400">描边宽度</label>
<input
type="range"
@@ -182,7 +188,7 @@
bind:value={style.alignment}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none"
transition duration-200 outline-none hover:border-gray-700/50"
>
{#each alignmentOptions as option}
<option value={option.value} title={option.description}>
@@ -198,7 +204,7 @@
bind:value={style.marginV}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none"
transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
</div>
@@ -210,7 +216,7 @@
bind:value={style.marginL}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none"
transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
<div class="space-y-2">
@@ -220,7 +226,7 @@
bind:value={style.marginR}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none"
transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { invoke } from "../lib/invoker";
import { Dropdown, DropdownItem, Select } from "flowbite-svelte";
import { ChevronDownOutline } from "flowbite-svelte-icons";
import { scale } from "svelte/transition";
import type { Children, VideoType } from "./interface";
export let value = 0;
let parentSelected: VideoType;
@@ -9,6 +9,7 @@
let parentOpen = false;
let areaOpen = false;
let items: VideoType[] = [];
async function get_video_typelist() {
items = (await invoke("get_video_typelist")) as VideoType[];
// find parentSelected by value
@@ -29,58 +30,143 @@
value = areaSelected.id;
}
}
function handleParentClick() {
parentOpen = !parentOpen;
areaOpen = false;
}
function handleAreaClick() {
areaOpen = !areaOpen;
parentOpen = false;
}
function selectParent(item: VideoType) {
parentSelected = item;
areaSelected = parentSelected.children[0];
value = areaSelected.id;
parentOpen = false;
}
function selectArea(child: Children) {
areaSelected = child;
value = child.id;
areaOpen = false;
}
// Close dropdowns when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest(".type-select-container")) {
parentOpen = false;
areaOpen = false;
}
}
get_video_typelist();
</script>
<div class="flex">
<button
class="z-10 w-2/5 inline-flex justify-between items-center py-2.5 px-4 text-sm font-medium text-center rounded-s-lg focus:border-primary-500 focus:ring-primary-500 bg-gray-700 text-white placeholder-gray-400 border border-gray-600"
type="button"
>
{parentSelected ? parentSelected.name : ""}
<ChevronDownOutline class="w-6 h-6 ms-2" />
</button>
<Dropdown
bind:open={parentOpen}
containerClass="divide-y z-50 h-48 overflow-y-auto w-24"
>
{#each items as item}
<DropdownItem
on:click={() => {
parentOpen = false;
areaOpen = false;
parentSelected = item;
areaSelected = parentSelected.children[0];
value = areaSelected.id;
}}
class="flex items-center">{item.name}</DropdownItem
<svelte:window on:click={handleClickOutside} />
<div class="type-select-container flex w-full max-w-md">
<!-- Parent Select -->
<div class="relative flex-1">
<button
class="w-full inline-flex justify-between items-center px-3 py-2 text-sm font-medium text-left bg-[#1c1c1e] text-white border border-gray-600 rounded-l-lg hover:bg-[#2c2c2e] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200"
type="button"
on:click={handleParentClick}
>
<span class="truncate">{parentSelected ? parentSelected.name : ""}</span>
<ChevronDownOutline
class="w-4 h-4 ml-2 flex-shrink-0 transition-transform duration-200 {parentOpen
? 'rotate-180'
: ''}"
/>
</button>
{#if parentOpen}
<div
class="absolute top-full left-0 mt-1 w-full bg-[#1c1c1e] border border-gray-600 rounded-lg shadow-lg z-50 max-h-48 overflow-y-auto"
transition:scale={{ duration: 150, start: 0.95 }}
>
{/each}
</Dropdown>
<button
class="z-10 w-3/5 inline-flex justify-between items-center py-2.5 px-4 text-sm font-medium text-center rounded-e-lg focus:border-primary-500 focus:ring-primary-500 bg-gray-700 text-white placeholder-gray-400 border border-gray-600"
type="button"
>
{areaSelected ? areaSelected.name : ""}
<ChevronDownOutline class="w-6 h-6 ms-2" />
</button>
<Dropdown
bind:open={areaOpen}
containerClass="divide-y z-50 h-48 overflow-y-auto min-w-32 bg-gray-700 text-gray-200 rounded-lg border-gray-100 border-gray-600 divide-gray-100 divide-gray-600 shadow-md"
>
{#each parentSelected.children as child}
<DropdownItem
on:click={() => {
areaOpen = false;
parentOpen = false;
areaSelected = child;
value = child.id;
}}
class="flex items-center">{child.name}</DropdownItem
{#each items as item}
<button
class="w-full px-3 py-2 text-sm text-left text-white hover:bg-[#2c2c2e] first:rounded-t-lg last:rounded-b-lg transition-colors duration-150 {parentSelected?.id ===
item.id
? 'bg-blue-900/20 text-blue-400'
: ''}"
on:click={() => selectParent(item)}
>
{item.name}
</button>
{/each}
</div>
{/if}
</div>
<!-- Area Select -->
<div class="relative flex-1">
<button
class="w-full inline-flex justify-between items-center px-3 py-2 text-sm font-medium text-left bg-[#1c1c1e] text-white border border-l-0 border-gray-600 rounded-r-lg hover:bg-[#2c2c2e] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200"
type="button"
on:click={handleAreaClick}
>
<span class="truncate">{areaSelected ? areaSelected.name : ""}</span>
<ChevronDownOutline
class="w-4 h-4 ml-2 flex-shrink-0 transition-transform duration-200 {areaOpen
? 'rotate-180'
: ''}"
/>
</button>
{#if areaOpen}
<div
class="absolute top-full right-0 mt-1 w-full bg-[#1c1c1e] border border-gray-600 rounded-lg shadow-lg z-50 max-h-48 overflow-y-auto"
transition:scale={{ duration: 150, start: 0.95 }}
>
{/each}
</Dropdown>
{#each parentSelected?.children || [] as child}
<button
class="w-full px-3 py-2 text-sm text-left text-white hover:bg-[#2c2c2e] first:rounded-t-lg last:rounded-b-lg transition-colors duration-150 {areaSelected?.id ===
child.id
? 'bg-blue-900/20 text-blue-400'
: ''}"
on:click={() => selectArea(child)}
>
{child.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
<style>
/* Custom scrollbar for dropdowns */
:global(
.type-select-container div[class*="overflow-y-auto"]::-webkit-scrollbar
) {
width: 6px;
}
:global(
.type-select-container
div[class*="overflow-y-auto"]::-webkit-scrollbar-track
) {
background: transparent;
}
:global(
.type-select-container
div[class*="overflow-y-auto"]::-webkit-scrollbar-thumb
) {
background: rgba(75, 85, 99, 0.5);
border-radius: 3px;
}
:global(
.type-select-container
div[class*="overflow-y-auto"]::-webkit-scrollbar-thumb:hover
) {
background: rgba(75, 85, 99, 0.7);
}
</style>

View File

@@ -11,6 +11,7 @@
BrainCircuit,
Eraser,
Download,
Pen,
} from "lucide-svelte";
import {
generateEventId,
@@ -19,14 +20,20 @@
type ProgressUpdate,
type SubtitleStyle,
type VideoItem,
type Profile,
type Config,
} from "./interface";
import SubtitleStyleEditor from "./SubtitleStyleEditor.svelte";
import { invoke, TAURI_ENV, listen } from "../lib/invoker";
import { onDestroy } from "svelte";
import CoverEditor from "./CoverEditor.svelte";
import TypeSelect from "./TypeSelect.svelte";
import { invoke, TAURI_ENV, listen, log, close_window } from "../lib/invoker";
import { onDestroy, onMount } from "svelte";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { listen as tauriListen } from "@tauri-apps/api/event";
import type { AccountInfo } from "./db";
export let show = false;
export let video: VideoItem;
export let onClose: () => void;
export let roomId: number;
export let videos: any[] = [];
export let onVideoChange: ((video: VideoItem) => void) | undefined =
@@ -76,6 +83,100 @@
let current_encode_event_id = null;
let current_generate_event_id = null;
let windowCloseUnlisten: (() => void) | null = null;
let activeTab = "subtitle"; // 添加当前激活的 tab
// 投稿相关变量
let current_post_event_id = null;
let config: Config = null;
let accounts: any[] = [];
let uid_selected = 0;
let show_cover_editor = false;
// 获取 profile 从 localStorage
function get_profile(): Profile {
const profile_str = window.localStorage.getItem("profile-" + roomId);
if (profile_str && profile_str.includes("videos")) {
return JSON.parse(profile_str);
}
return default_profile();
}
let profile: Profile = get_profile();
$: {
window.localStorage.setItem("profile-" + roomId, JSON.stringify(profile));
}
function default_profile(): Profile {
return {
videos: [],
cover: "",
cover43: null,
title: "",
copyright: 1,
tid: 27,
tag: "",
desc_format_id: 9999,
desc: "",
recreate: -1,
dynamic: "",
interactive: 0,
act_reserve_create: 0,
no_disturbance: 0,
no_reprint: 0,
subtitle: {
open: 0,
lan: "",
},
dolby: 0,
lossless_music: 0,
up_selection_reply: false,
up_close_danmu: false,
up_close_reply: false,
web_os: 0,
};
}
// on window close, save subtitles
onMount(async () => {
if (TAURI_ENV) {
// 使用 Tauri 的全局事件监听器
try {
windowCloseUnlisten = await tauriListen(
"tauri://close-requested",
async () => {
await saveSubtitles();
}
);
} catch (error) {
log.warn("Failed to listen to window close event:", error);
}
} else {
// 在非 Tauri 环境中使用 beforeunload
window.addEventListener("beforeunload", async () => {
await saveSubtitles();
});
}
// 初始化投稿相关数据
try {
// 获取配置
config = (await invoke("get_config")) as Config;
// 获取账号列表
const account_info: AccountInfo = await invoke("get_accounts");
accounts = account_info.accounts
.filter((a) => a.platform === "bilibili")
.map((a) => ({
value: a.uid,
name: a.name,
platform: a.platform,
}));
} catch (error) {
console.error("Failed to initialize upload data:", error);
}
});
const update_listener = listen<ProgressUpdate>(`progress-update`, (e) => {
let event_id = e.payload.id;
@@ -84,6 +185,8 @@
update_encode_prompt(e.payload.content);
} else if (event_id == current_generate_event_id) {
update_generate_prompt(e.payload.content);
} else if (event_id === current_post_event_id) {
update_post_prompt(e.payload.content);
}
});
@@ -101,12 +204,22 @@
alert("生成字幕失败: " + e.payload.message);
}
current_generate_event_id = null;
} else if (event_id === current_post_event_id) {
update_post_prompt(`投稿`);
if (!e.payload.success) {
alert(e.payload.message);
}
current_post_event_id = null;
}
});
onDestroy(() => {
update_listener?.then((fn) => fn());
finish_listener?.then((fn) => fn());
// 清理窗口关闭事件监听器
if (windowCloseUnlisten) {
windowCloseUnlisten();
}
});
function update_encode_prompt(content: string) {
@@ -123,6 +236,51 @@
}
}
function update_post_prompt(str: string) {
const span = document.getElementById("post-prompt");
if (span) {
span.textContent = str;
}
}
// 投稿相关函数
async function do_post() {
if (!video) {
return;
}
let event_id = generateEventId();
current_post_event_id = event_id;
update_post_prompt(`投稿上传中`);
// update profile in local storage
window.localStorage.setItem("profile-" + roomId, JSON.stringify(profile));
invoke("upload_procedure", {
uid: uid_selected,
eventId: event_id,
roomId: roomId,
videoId: video.id,
cover: video.cover,
profile: profile,
}).then(async () => {
uid_selected = 0;
await onVideoListUpdate?.();
});
}
async function cancel_post() {
if (!current_post_event_id) {
return;
}
invoke("cancel", { eventId: current_post_event_id });
}
function pauseVideo() {
if (videoElement) {
videoElement.pause();
}
}
// 监听当前字幕索引变化
$: if (currentSubtitleIndex >= 0 && subtitleElements[currentSubtitleIndex]) {
subtitleElements[currentSubtitleIndex].scrollIntoView({
@@ -244,22 +402,6 @@
loadSubtitleStyle();
}
async function handleClose() {
if (videoElement) {
videoElement.pause();
videoElement.currentTime = 0;
}
isPlaying = false;
currentTime = 0;
currentSubtitle = "";
currentSubtitleIndex = -1;
subtitleElements = [];
isVideoLoaded = false;
await saveSubtitles(); // 保存字幕
subtitles = []; // 清空字幕列表
onClose();
}
async function handleVideoLoaded() {
isVideoLoaded = true;
if (videoElement) {
@@ -290,7 +432,7 @@
timeMarkers = Array.from(
{ length: Math.min(Math.ceil(duration / interval) + 1, maxMarkers) },
(_, i) => Math.min(i * interval, duration),
(_, i) => Math.min(i * interval, duration)
);
}
@@ -367,7 +509,7 @@
subtitles = subtitles.map((s, i) =>
i === index
? { ...s, startTime: newStartTimeFinal, endTime: newEndTime }
: s,
: s
);
subtitles = subtitles.sort((a, b) => a.startTime - b.startTime);
}
@@ -392,7 +534,7 @@
const newTime = Math.max(0, sub.startTime + delta);
if (newTime < sub.endTime - 0.1) {
subtitles = subtitles.map((s, i) =>
i === index ? { ...s, startTime: newTime } : s,
i === index ? { ...s, startTime: newTime } : s
);
subtitles = subtitles.sort((a, b) => a.startTime - b.startTime);
}
@@ -400,7 +542,7 @@
const newTime = Math.min(videoElement.duration, sub.endTime + delta);
if (newTime > sub.startTime + 0.1) {
subtitles = subtitles.map((s, i) =>
i === index ? { ...s, endTime: newTime } : s,
i === index ? { ...s, endTime: newTime } : s
);
subtitles = subtitles.sort((a, b) => a.startTime - b.startTime);
}
@@ -410,7 +552,7 @@
function handleTimelineMouseDown(
e: MouseEvent,
index: number,
isStart: boolean,
isStart: boolean
) {
draggingSubtitle = { index, isStart };
document.addEventListener("mousemove", handleTimelineMouseMove);
@@ -514,7 +656,7 @@
function getCurrentSubtitleIndex(): number {
return subtitles.findIndex(
(sub) => currentTime >= sub.startTime && currentTime < sub.endTime,
(sub) => currentTime >= sub.startTime && currentTime < sub.endTime
);
}
@@ -549,7 +691,7 @@
function handleVideoSelect(e: Event) {
const selectedVideo = videos.find(
(v) => v.id === Number((e.target as HTMLSelectElement).value),
(v) => v.id === Number((e.target as HTMLSelectElement).value)
);
if (selectedVideo) {
// 清空字幕列表
@@ -590,13 +732,6 @@
class="h-14 border-b border-gray-800/50 bg-[#2c2c2e] flex items-center px-4 justify-between"
>
<div class="flex items-center space-x-4">
<button
class="flex items-center space-x-2 text-gray-300 hover:text-white transition-colors duration-200 px-3 py-1.5 rounded-md hover:bg-gray-700/50"
on:click={handleClose}
>
<ArrowLeft class="w-4 h-4" />
<span class="text-sm">返回</span>
</button>
<!-- 视频选择器 -->
<div class="relative flex items-center space-x-2">
<select
@@ -631,8 +766,8 @@
const newVideo = videos[0];
onVideoChange?.(newVideo);
} else {
// 如果列表为空,关闭预览
await handleClose();
// 如果列表为空,关闭窗口
close_window();
}
} catch (error) {
console.error(error);
@@ -883,7 +1018,7 @@
<!-- 时间轴容器 -->
<div
class="h-24 overflow-x-auto overflow-y-hidden"
class="h-24 overflow-x-auto overflow-y-hidden sidebar-scrollbar"
bind:this={timelineContainer}
on:wheel|preventDefault={handleWheel}
>
@@ -958,141 +1093,326 @@
<!-- 字幕编辑面板 -->
<div
class="w-80 border-l border-gray-800/50 bg-[#2c2c2e] overflow-y-auto"
class="w-80 border-l border-gray-800/50 bg-[#2c2c2e] overflow-y-auto sidebar-scrollbar"
>
<div class="p-4 space-y-4">
<div class="w-full sticky top-0 bg-[#2c2c2e] z-10 pb-4">
<div class="flex flex-col space-y-2">
<div class="flex space-x-2">
<button
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
on:click={() => (showStyleEditor = true)}
>
<Settings class="w-4 h-4" />
<span>字幕样式</span>
</button>
<button
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
on:click={clearSubtitles}
>
<Eraser class="w-4 h-4" />
<span>清空列表</span>
</button>
</div>
<div class="flex space-x-2">
<button
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-1 border border-gray-700"
on:click={generateSubtitles}
disabled={current_generate_event_id !== null}
>
{#if current_generate_event_id !== null}
<svg
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<BrainCircuit class="w-4 h-4" />
{/if}
<span id="generate-prompt">AI 生成字幕</span>
</button>
<button
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
on:click={addSubtitle}
>
<Plus class="w-4 h-4" />
<span>手动添加</span>
</button>
</div>
</div>
</div>
<!-- 字幕列表 -->
<div class="space-y-2">
{#each subtitles as subtitle, index}
<!-- Tab 导航 -->
<div class="flex border-b border-gray-800/50 bg-[#1c1c1e]">
<button
class="px-6 py-3 text-sm font-medium transition-all duration-200 relative"
class:text-white={activeTab === "subtitle"}
class:text-gray-400={activeTab !== "subtitle"}
class:bg-[#2c2c2e]={activeTab === "subtitle"}
class:bg-transparent={activeTab !== "subtitle"}
on:click={() => (activeTab = "subtitle")}
>
字幕
{#if activeTab === "subtitle"}
<div
bind:this={subtitleElements[index]}
class="p-3 bg-[#1c1c1e] rounded-lg space-y-2 transition-colors duration-200"
class:bg-[#2c2c2e]={currentSubtitleIndex === index}
class:border={currentSubtitleIndex === index}
class:border-[#0A84FF]={currentSubtitleIndex === index}
>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-1">
<button
class="text-sm text-[#0A84FF] hover:text-[#0A84FF]/80"
on:click={() => seekToTime(subtitle.startTime)}
>
{formatTime(subtitle.startTime)}
</button>
<button
class="p-0.5 text-gray-400 hover:text-white"
on:click={() => adjustTime(index, true, -0.1)}
>
<Minus class="w-3 h-3" />
</button>
<button
class="p-0.5 text-gray-400 hover:text-white"
on:click={() => adjustTime(index, true, 0.1)}
>
<Plus class="w-3 h-3" />
</button>
</div>
<span class="text-gray-400"></span>
<div class="flex items-center space-x-1">
<button
class="text-sm text-[#0A84FF] hover:text-[#0A84FF]/80"
on:click={() => seekToTime(subtitle.endTime)}
>
{formatTime(subtitle.endTime)}
</button>
<button
class="p-0.5 text-gray-400 hover:text-white"
on:click={() => adjustTime(index, false, -0.1)}
>
<Minus class="w-3 h-3" />
</button>
<button
class="p-0.5 text-gray-400 hover:text-white"
on:click={() => adjustTime(index, false, 0.1)}
>
<Plus class="w-3 h-3" />
</button>
</div>
</div>
class="absolute bottom-0 left-0 right-0 h-0.5 bg-[#0A84FF]"
></div>
{/if}
</button>
<button
class="px-6 py-3 text-sm font-medium transition-all duration-200 relative"
class:text-white={activeTab === "upload"}
class:text-gray-400={activeTab !== "upload"}
class:bg-[#2c2c2e]={activeTab === "upload"}
class:bg-transparent={activeTab !== "upload"}
on:click={() => (activeTab = "upload")}
>
快速投稿
{#if activeTab === "upload"}
<div
class="absolute bottom-0 left-0 right-0 h-0.5 bg-[#0A84FF]"
></div>
{/if}
</button>
</div>
<!-- Tab 内容 -->
{#if activeTab === "subtitle"}
<!-- 字幕 Tab 内容 -->
<div class="p-4 space-y-4">
<div class="w-full sticky top-0 bg-[#2c2c2e] z-10 pb-4">
<div class="flex flex-col space-y-2">
<div class="flex space-x-2">
<button
class="text-sm text-red-500 hover:text-red-400"
on:click={async () => await removeSubtitle(index)}
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
on:click={() => (showStyleEditor = true)}
>
删除
<Settings class="w-4 h-4" />
<span>字幕样式</span>
</button>
<button
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
on:click={clearSubtitles}
>
<Eraser class="w-4 h-4" />
<span>清空列表</span>
</button>
</div>
<div class="flex space-x-2">
<button
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-1 border border-gray-700"
on:click={generateSubtitles}
disabled={current_generate_event_id !== null}
>
{#if current_generate_event_id !== null}
<svg
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<BrainCircuit class="w-4 h-4" />
{/if}
<span id="generate-prompt">AI 生成字幕</span>
</button>
<button
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
on:click={addSubtitle}
>
<Plus class="w-4 h-4" />
<span>手动添加</span>
</button>
</div>
</div>
</div>
<!-- 字幕列表 -->
<div class="space-y-2">
{#each subtitles as subtitle, index}
<div
bind:this={subtitleElements[index]}
class="p-3 bg-[#1c1c1e] rounded-lg space-y-2 transition-colors duration-200"
class:bg-[#2c2c2e]={currentSubtitleIndex === index}
class:border={currentSubtitleIndex === index}
class:border-[#0A84FF]={currentSubtitleIndex === index}
>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-1">
<button
class="text-sm text-[#0A84FF] hover:text-[#0A84FF]/80"
on:click={() => seekToTime(subtitle.startTime)}
>
{formatTime(subtitle.startTime)}
</button>
<button
class="p-0.5 text-gray-400 hover:text-white"
on:click={() => adjustTime(index, true, -0.1)}
>
<Minus class="w-3 h-3" />
</button>
<button
class="p-0.5 text-gray-400 hover:text-white"
on:click={() => adjustTime(index, true, 0.1)}
>
<Plus class="w-3 h-3" />
</button>
</div>
<span class="text-gray-400"></span>
<div class="flex items-center space-x-1">
<button
class="text-sm text-[#0A84FF] hover:text-[#0A84FF]/80"
on:click={() => seekToTime(subtitle.endTime)}
>
{formatTime(subtitle.endTime)}
</button>
<button
class="p-0.5 text-gray-400 hover:text-white"
on:click={() => adjustTime(index, false, -0.1)}
>
<Minus class="w-3 h-3" />
</button>
<button
class="p-0.5 text-gray-400 hover:text-white"
on:click={() => adjustTime(index, false, 0.1)}
>
<Plus class="w-3 h-3" />
</button>
</div>
</div>
<button
class="text-sm text-red-500 hover:text-red-400"
on:click={async () => await removeSubtitle(index)}
>
删除
</button>
</div>
<input
type="text"
bind:value={subtitle.text}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none hover:border-gray-700/50"
placeholder="输入字幕文本"
/>
</div>
{/each}
</div>
</div>
{:else if activeTab === "upload"}
<!-- 投稿 Tab 内容 -->
<div class="p-4 space-y-6">
<!-- 封面预览 -->
{#if video && video.id != -1}
<section>
<div class="group">
<div
class="text-sm text-gray-400 mb-2 flex items-center justify-between"
>
<span>视频封面</span>
<button
class="text-[#0A84FF] hover:text-[#0A84FF]/80 transition-colors duration-200 flex items-center space-x-1"
on:click={() => (show_cover_editor = true)}
>
<Pen class="w-4 h-4" />
<span class="text-xs">创建新封面</span>
</button>
</div>
<div
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50"
>
<img src={video.cover} alt="视频封面" class="w-full" />
</div>
</div>
</section>
{/if}
<!-- 基本信息 -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-400">基本信息</h3>
<!-- 标题 -->
<div class="space-y-2">
<label
for="title"
class="block text-sm font-medium text-gray-300">标题</label
>
<input
id="title"
type="text"
bind:value={subtitle.text}
class="w-full px-2 py-1 bg-[#2c2c2e] text-white rounded border border-gray-700 focus:border-[#0A84FF] outline-none"
placeholder="输入字幕文本"
bind:value={profile.title}
placeholder="输入视频标题"
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
{/each}
<!-- 视频分区 -->
<div class="space-y-2">
<label for="tid" class="block text-sm font-medium text-gray-300"
>视频分区</label
>
<div class="w-full" id="tid">
<TypeSelect bind:value={profile.tid} />
</div>
</div>
<!-- 投稿账号 -->
<div class="space-y-2">
<label for="uid" class="block text-sm font-medium text-gray-300"
>投稿账号</label
>
<select
bind:value={uid_selected}
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none appearance-none hover:border-gray-700/50"
>
<option value={0}>选择账号</option>
{#each accounts as account}
<option value={account.value}>{account.name}</option>
{/each}
</select>
</div>
</div>
<!-- 详细信息 -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-400">详细信息</h3>
<!-- 描述 -->
<div class="space-y-2">
<label
for="desc"
class="block text-sm font-medium text-gray-300">描述</label
>
<textarea
id="desc"
bind:value={profile.desc}
placeholder="输入视频描述"
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none resize-none h-24 hover:border-gray-700/50"
/>
</div>
<!-- 标签 -->
<div class="space-y-2">
<label for="tag" class="block text-sm font-medium text-gray-300"
>标签</label
>
<input
id="tag"
type="text"
bind:value={profile.tag}
placeholder="输入视频标签,用逗号分隔"
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none hover:border-gray-700/50"
/>
</div>
<!-- 动态 -->
<div class="space-y-2">
<label
for="dynamic"
class="block text-sm font-medium text-gray-300">动态</label
>
<textarea
id="dynamic"
bind:value={profile.dynamic}
placeholder="输入动态内容"
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none resize-none h-24 hover:border-gray-700/50"
/>
</div>
</div>
<!-- 投稿按钮 -->
{#if video}
<div class="pt-4">
<div class="flex gap-2">
<button
on:click={do_post}
disabled={current_post_event_id != null || !uid_selected}
class="flex-1 px-3 py-2 bg-[#0A84FF] text-white rounded-lg transition-all duration-200 hover:bg-[#0A84FF]/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 text-sm"
>
{#if current_post_event_id != null}
<div
class="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin"
/>
{/if}
<span id="post-prompt">投稿</span>
</button>
{#if current_post_event_id != null}
<button
on:click={() => cancel_post()}
class="px-3 py-2 bg-red-500 text-white rounded-lg transition-all duration-200 hover:bg-red-500/90 flex items-center justify-center text-sm"
>
取消
</button>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
@@ -1103,3 +1423,14 @@
{roomId}
onClose={() => (showStyleEditor = false)}
/>
<CoverEditor
bind:show={show_cover_editor}
{video}
on:coverUpdate={(event) => {
video = {
...video,
cover: event.detail.cover,
};
}}
/>

View File

@@ -48,3 +48,12 @@ export interface RecordItem {
export interface AccountInfo {
accounts: AccountItem[];
}
export interface TaskRow {
id: string;
task_type: string;
status: string;
message: string;
metadata: string;
created_at: string;
}

View File

@@ -59,6 +59,7 @@ export interface VideoItem {
tags: string;
area: number;
created_at: string;
platform?: string;
}
export interface Profile {
@@ -96,10 +97,14 @@ export interface Config {
post_notify: boolean;
auto_cleanup: boolean;
auto_subtitle: boolean;
subtitle_generator_type: string;
whisper_model: string;
whisper_prompt: string;
openai_api_endpoint: string;
openai_api_key: string;
clip_name_format: string;
auto_generate: AutoGenerateConfig;
status_check_interval: number;
}
export interface AutoGenerateConfig {
@@ -208,6 +213,7 @@ export interface ClipRangeParams {
y: number;
danmu: boolean;
offset: number;
local_offset: number;
}
export function generateEventId() {
@@ -215,8 +221,10 @@ export function generateEventId() {
}
export async function clipRange(eventId: string, params: ClipRangeParams) {
return (await invoke("clip_range", {
eventId: eventId,
params,
})) as VideoItem;
return await invoke("clip_range", { eventId, params });
}
export interface DanmuEntry {
ts: number;
content: string;
}

View File

@@ -14,6 +14,29 @@ declare global {
const ENDPOINT = localStorage.getItem("endpoint") || "";
const TAURI_ENV = typeof window.__TAURI_INTERNALS__ !== "undefined";
const log = {
error: (...args: any[]) => {
const message = args.map((arg) => JSON.stringify(arg)).join(" ");
invoke("console_log", { level: "error", message });
console.error(message);
},
warn: (...args: any[]) => {
const message = args.map((arg) => JSON.stringify(arg)).join(" ");
invoke("console_log", { level: "warn", message });
console.warn(message);
},
info: (...args: any[]) => {
const message = args.map((arg) => JSON.stringify(arg)).join(" ");
invoke("console_log", { level: "info", message });
console.info(message);
},
debug: (...args: any[]) => {
const message = args.map((arg) => JSON.stringify(arg)).join(" ");
invoke("console_log", { level: "debug", message });
console.debug(message);
},
};
async function invoke<T>(
command: string,
args?: Record<string, any>
@@ -28,12 +51,17 @@ async function invoke<T>(
console.log(args);
// open new page to live_index.html
window.open(
`live_index.html?platform=${args.platform}&room_id=${args.roomId}&live_id=${args.liveId}`,
`index_live.html?platform=${args.platform}&room_id=${args.roomId}&live_id=${args.liveId}`,
"_blank"
);
return;
}
if (command === "open_clip") {
window.open(`index_clip.html?id=${args.videoId}`, "_blank");
return;
}
const response = await fetch(`${ENDPOINT}/api/${command}`, {
method: "POST",
headers: {
@@ -41,7 +69,12 @@ async function invoke<T>(
},
body: JSON.stringify(args || {}),
});
// if status is 405, it means the command is not allowed
if (response.status === 405) {
throw new Error(
`Command ${command} is not allowed, maybe bili-shadowreplay is running in readonly mode`
);
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP error: ${response.status}`);
@@ -129,6 +162,13 @@ async function open(url: string) {
window.open(url, "_blank");
}
async function close_window() {
if (TAURI_ENV) {
return await getCurrentWebviewWindow().close();
}
window.close();
}
export {
invoke,
get,
@@ -138,4 +178,6 @@ export {
ENDPOINT,
listen,
open,
log,
close_window,
};

8
src/main_clip.ts Normal file
View File

@@ -0,0 +1,8 @@
import "./styles.css";
import App from "./AppClip.svelte";
const app = new App({
target: document.getElementById("app"),
});
export default app;

View File

@@ -10,13 +10,15 @@
fetch("https://api.github.com/repos/Xinrea/bili-shadowreplay/releases")
.then((response) => response.json())
.then((data) => {
const latest = data[0].tag_name;
// Filter out prerelease versions
const stableReleases = data.filter((release) => !release.prerelease);
const latest = stableReleases[0]?.tag_name;
latestVersion.set(latest);
// Compare versions and set hasNewVersion
if (version && latest !== version) {
hasNewVersion.set(true);
}
releases = data.slice(0, 3).map((release) => ({
releases = stableReleases.slice(0, 3).map((release) => ({
version: release.tag_name,
date: new Date(release.published_at).toLocaleDateString(),
description: release.body,
@@ -29,7 +31,7 @@
return notes
.split("\n")
.filter(
(line) => line.trim().startsWith("*") || line.trim().startsWith("-"),
(line) => line.trim().startsWith("*") || line.trim().startsWith("-")
)
.map((line) => {
line = line.trim().replace(/^[*-]\s*/, "");
@@ -54,7 +56,7 @@
}
</script>
<div class="flex-1 p-6 overflow-auto">
<div class="flex-1 p-6 overflow-auto custom-scrollbar-light bg-gray-50">
<div class="max-w-2xl mx-auto space-y-8">
<!-- App Info -->
<div class="text-center space-y-4">

View File

@@ -114,7 +114,7 @@
on:mousedown={handleModalClickOutside}
/>
<div class="flex-1 p-6 overflow-auto">
<div class="flex-1 p-6 overflow-auto custom-scrollbar-light bg-gray-50">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">

710
src/page/Clip.svelte Normal file
View File

@@ -0,0 +1,710 @@
<script lang="ts">
import { invoke } from "../lib/invoker";
import type { VideoItem } from "../lib/interface";
import { onMount } from "svelte";
import {
Play,
Trash2,
Calendar,
Clock,
HardDrive,
RefreshCw,
ChevronDown,
ChevronUp,
Video,
Globe,
Upload,
Home,
} from "lucide-svelte";
let videos: VideoItem[] = [];
let filteredVideos: VideoItem[] = [];
let loading = false;
let sortBy = "created_at";
let sortOrder = "desc";
let selectedRoomId: number | null = null;
let roomIds: number[] = [];
let selectedPlatform: string | null = null;
let platforms: string[] = [];
let selectedVideos: Set<number> = new Set();
let showDeleteConfirm = false;
let videoToDelete: VideoItem | null = null;
onMount(async () => {
await loadVideos();
});
let cover_cache: Map<number, string> = new Map();
async function loadVideos() {
loading = true;
try {
// Get all videos from all rooms
const allVideos: VideoItem[] = [];
// First, get all room IDs and platforms that have videos
const roomIdsSet = new Set<number>();
const platformsSet = new Set<string>();
const tempVideos = await invoke<VideoItem[]>("get_all_videos");
for (const video of tempVideos) {
if (cover_cache.has(video.id)) {
video.cover = cover_cache.get(video.id) || "";
} else {
video.cover = await invoke<string>("get_video_cover", {
id: video.id,
});
cover_cache.set(video.id, video.cover);
}
}
for (const video of tempVideos) {
roomIdsSet.add(video.room_id);
if (video.platform) {
platformsSet.add(video.platform);
}
allVideos.push(video);
}
videos = allVideos;
roomIds = Array.from(roomIdsSet).sort((a, b) => a - b);
platforms = Array.from(platformsSet).sort();
applyFilters();
} catch (error) {
console.error("Failed to load videos:", error);
} finally {
loading = false;
}
}
function applyFilters() {
console.log("applyFilters", selectedRoomId, selectedPlatform);
let filtered = [...videos];
// Apply room filter
if (selectedRoomId !== null) {
filtered = filtered.filter((video) => video.room_id === selectedRoomId);
}
// Apply platform filter
if (selectedPlatform !== null) {
filtered = filtered.filter(
(video) => video.platform === selectedPlatform
);
}
// Apply sorting
filtered.sort((a, b) => {
let aValue: any, bValue: any;
switch (sortBy) {
case "title":
aValue = a.title.toLowerCase();
bValue = b.title.toLowerCase();
break;
case "length":
aValue = a.length;
bValue = b.length;
break;
case "size":
aValue = a.size;
bValue = b.size;
break;
case "created_at":
aValue = new Date(a.created_at);
bValue = new Date(b.created_at);
break;
case "room_id":
aValue = a.room_id;
bValue = b.room_id;
break;
case "platform":
aValue = (a.platform || "").toLowerCase();
bValue = (b.platform || "").toLowerCase();
break;
default:
aValue = a.created_at;
bValue = b.created_at;
}
if (sortOrder === "asc") {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
filteredVideos = filtered;
}
function formatSize(size: number) {
if (size < 1024) {
return `${size} B`;
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KiB`;
} else if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
} else {
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}
}
function formatDuration(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
} else {
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleString();
}
function formatPlatform(platform: string | undefined) {
if (!platform) return "未知";
switch (platform.toLowerCase()) {
case "bilibili":
return "B站";
case "douyin":
return "抖音";
case "huya":
return "虎牙";
case "youtube":
return "YouTube";
default:
return platform;
}
}
function getRoomUrl(platform: string | undefined, roomId: number) {
if (!platform) return null;
switch (platform.toLowerCase()) {
case "bilibili":
return `https://live.bilibili.com/${roomId}`;
case "douyin":
return `https://live.douyin.com/${roomId}`;
case "huya":
return `https://www.huya.com/${roomId}`;
case "youtube":
return `https://www.youtube.com/channel/${roomId}`;
default:
return null;
}
}
function toggleSort(field: string) {
if (sortBy === field) {
sortOrder = sortOrder === "asc" ? "desc" : "asc";
} else {
sortBy = field;
sortOrder = "asc";
}
applyFilters();
}
function toggleVideoSelection(id: number) {
if (selectedVideos.has(id)) {
selectedVideos.delete(id);
} else {
selectedVideos.add(id);
}
selectedVideos = selectedVideos; // Trigger reactivity
}
function selectAllVideos() {
const currentVideos = filteredVideos;
if (selectedVideos.size === currentVideos.length) {
selectedVideos.clear();
} else {
currentVideos.forEach((video) => selectedVideos.add(video.id));
}
selectedVideos = selectedVideos; // Trigger reactivity
}
async function deleteVideo(video: VideoItem) {
try {
await invoke("delete_video", { id: video.id });
await loadVideos();
showDeleteConfirm = false;
videoToDelete = null;
} catch (error) {
console.error("Failed to delete video:", error);
}
}
async function deleteSelectedVideos() {
try {
for (const id of selectedVideos) {
await invoke("delete_video", { id });
}
selectedVideos.clear();
await loadVideos();
} catch (error) {
console.error("Failed to delete selected videos:", error);
}
}
async function playVideo(video: VideoItem) {
try {
await invoke("open_clip", { videoId: video.id });
} catch (error) {
console.error("Failed to play video:", error);
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex-1 p-6 overflow-auto custom-scrollbar-light bg-gray-50">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div class="space-y-1">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
切片
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
管理所有录播产生的切片;如需生成切片,请从直播间列表进入录播预览页面操作。
</p>
</div>
<div class="flex items-center space-x-3">
<button
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
on:click={loadVideos}
disabled={loading}
>
<RefreshCw
class="w-4 h-4 text-white {loading ? 'animate-spin' : ''}"
/>
<span>刷新</span>
</button>
</div>
</div>
<!-- Filters -->
<div
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 space-y-4"
>
<div class="flex justify-between items-center flex-wrap gap-4">
<select
bind:value={selectedRoomId}
on:change={applyFilters}
class="px-3 py-2 bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-pointer"
>
<option value={null}>所有直播间</option>
{#each roomIds as roomId}
<option value={roomId}>{roomId}</option>
{/each}
</select>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600 dark:text-gray-400">排序:</span>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {sortBy ===
'room_id'
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => toggleSort("room_id")}
>
直播间号
{#if sortBy === "room_id"}
{#if sortOrder === "asc"}
<ChevronUp class="w-3 h-3 inline ml-1" />
{:else}
<ChevronDown class="w-3 h-3 inline ml-1" />
{/if}
{/if}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {sortBy ===
'title'
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => toggleSort("title")}
>
文件名
{#if sortBy === "title"}
{#if sortOrder === "asc"}
<ChevronUp class="w-3 h-3 inline ml-1" />
{:else}
<ChevronDown class="w-3 h-3 inline ml-1" />
{/if}
{/if}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {sortBy ===
'platform'
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => toggleSort("platform")}
>
平台
{#if sortBy === "platform"}
{#if sortOrder === "asc"}
<ChevronUp class="w-3 h-3 inline ml-1" />
{:else}
<ChevronDown class="w-3 h-3 inline ml-1" />
{/if}
{/if}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {sortBy ===
'length'
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => toggleSort("length")}
>
时长
{#if sortBy === "length"}
{#if sortOrder === "asc"}
<ChevronUp class="w-3 h-3 inline ml-1" />
{:else}
<ChevronDown class="w-3 h-3 inline ml-1" />
{/if}
{/if}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {sortBy ===
'size'
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => toggleSort("size")}
>
大小
{#if sortBy === "size"}
{#if sortOrder === "asc"}
<ChevronUp class="w-3 h-3 inline ml-1" />
{:else}
<ChevronDown class="w-3 h-3 inline ml-1" />
{/if}
{/if}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {sortBy ===
'created_at'
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => toggleSort("created_at")}
>
创建时间
{#if sortBy === "created_at"}
{#if sortOrder === "asc"}
<ChevronUp class="w-3 h-3 inline ml-1" />
{:else}
<ChevronDown class="w-3 h-3 inline ml-1" />
{/if}
{/if}
</button>
</div>
</div>
</div>
<!-- Bulk Actions -->
{#if selectedVideos.size > 0}
<div
class="flex justify-between items-center p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700"
>
<span class="text-sm text-gray-600 dark:text-gray-400">
已选择 {selectedVideos.size} 个视频
</span>
<button
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center space-x-2"
on:click={() => {
showDeleteConfirm = true;
videoToDelete = null;
}}
>
<Trash2 class="w-4 h-4 icon-white" />
<span>删除选中</span>
</button>
</div>
{/if}
<!-- Video List -->
<div
class="bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden"
>
{#if loading}
<div
class="flex flex-col items-center justify-center p-12 space-y-4 text-gray-500 dark:text-gray-400"
>
<RefreshCw class="w-8 h-8 animate-spin" />
<span>加载中...</span>
</div>
{:else if filteredVideos.length === 0}
<div
class="flex flex-col items-center justify-center p-12 space-y-4 text-gray-500 dark:text-gray-400"
>
<Video class="w-12 h-12" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
暂无视频
</h3>
<p class="text-sm">
{selectedRoomId !== null
? "没有找到匹配的视频"
: "还没有录制任何视频切片"}
</p>
</div>
{:else}
<div class="overflow-x-auto custom-scrollbar-light">
<table class="w-full table-fixed">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700/50">
<th class="px-4 py-3 text-left w-12">
<input
type="checkbox"
checked={selectedVideos.size === filteredVideos.length &&
filteredVideos.length > 0}
on:change={selectAllVideos}
class="rounded border-gray-300 dark:border-gray-600"
/>
</th>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-20"
>平台</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-24"
>直播间</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-64"
>视频</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-20"
>时长</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-24"
>大小</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-28"
>创建时间</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-28"
>投稿状态</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-28"
>操作</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700/50">
{#each filteredVideos as video (video.id)}
<tr
class="group hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] transition-colors"
>
<td class="px-4 py-3 w-12">
<input
type="checkbox"
checked={selectedVideos.has(video.id)}
on:change={() => toggleVideoSelection(video.id)}
class="rounded border-gray-300 dark:border-gray-600"
/>
</td>
<td class="px-4 py-3 w-20">
<div class="flex items-center space-x-2">
<Globe class="w-4 h-4 text-gray-400" />
<span
class="text-sm text-gray-900 dark:text-white truncate"
>{formatPlatform(video.platform)}</span
>
</div>
</td>
<td class="px-4 py-3 w-24">
<div class="flex items-center space-x-2">
<Home class="w-4 h-4 text-gray-400" />
{#if getRoomUrl(video.platform, video.room_id)}
<a
href={getRoomUrl(video.platform, video.room_id)}
target="_blank"
rel="noopener noreferrer"
class="text-blue-500 hover:text-blue-700 text-sm"
title={`打开 ${formatPlatform(video.platform)} 直播间`}
>
{video.room_id}
</a>
{:else}
<span class="text-sm text-gray-900 dark:text-white"
>{video.room_id}</span
>
{/if}
</div>
</td>
<td class="px-4 py-3 w-64">
<div class="flex items-center space-x-3 w-full">
<div
class="w-12 h-8 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0"
>
<img
src={video.cover}
alt="封面"
class="w-full h-full object-cover"
/>
</div>
<div class="min-w-0 flex-1 w-64">
<p
class="text-sm font-medium text-gray-900 dark:text-white truncate w-full"
title={video.title || video.file}
>
{video.title || video.file}
</p>
<p
class="text-xs text-gray-500 dark:text-gray-400 truncate w-full"
title={video.file}
>
{video.file}
</p>
</div>
</div>
</td>
<td class="px-4 py-3 w-20">
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 text-gray-400" />
<span class="text-sm text-gray-900 dark:text-white"
>{formatDuration(video.length)}</span
>
</div>
</td>
<td class="px-4 py-3 w-24">
<div class="flex items-center space-x-2">
<HardDrive class="w-4 h-4 text-gray-400" />
<span class="text-sm text-gray-900 dark:text-white"
>{formatSize(video.size)}</span
>
</div>
</td>
<td class="px-4 py-3 w-28">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 text-gray-400" />
<span
class="text-sm text-gray-900 dark:text-white truncate"
>{formatDate(video.created_at)}</span
>
</div>
</td>
<td class="px-4 py-3 w-28">
<div class="flex items-center space-x-2">
<Upload class="w-4 h-4 text-gray-400" />
{#if video.bvid}
<a
href={`https://www.bilibili.com/video/${video.bvid}`}
target="_blank"
rel="noopener noreferrer"
class="text-blue-500 hover:text-blue-700 text-sm truncate"
title={video.bvid}
>
{video.bvid}
</a>
{:else}
<span class="text-gray-500 dark:text-gray-400 text-sm"
>未投稿</span
>
{/if}
</div>
</td>
<td class="px-4 py-3 w-28">
<div class="flex items-center space-x-1">
<button
class="p-1.5 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="播放"
on:click={() => playVideo(video)}
>
<Play class="w-4 h-4" />
</button>
<button
class="p-1.5 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="删除"
on:click={() => {
videoToDelete = video;
showDeleteConfirm = true;
}}
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
{#if showDeleteConfirm}
<div
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
>
<div
class="mac-modal w-[400px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden"
>
<div class="p-6 space-y-4">
<div class="text-center space-y-2">
<h3 class="text-base font-medium text-gray-900 dark:text-white">
确认删除
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{#if videoToDelete}
确定要删除视频 "{videoToDelete.title || videoToDelete.file}" 吗?
{:else}
确定要删除选中的 {selectedVideos.size} 个视频吗?
{/if}
</p>
<p class="text-xs text-red-600 dark:text-red-500">此操作无法撤销。</p>
</div>
<div class="flex justify-center space-x-3">
<button
class="w-24 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] rounded-lg transition-colors"
on:click={() => {
showDeleteConfirm = false;
videoToDelete = null;
}}
>
取消
</button>
<button
class="w-24 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors"
on:click={() => {
if (videoToDelete) {
deleteVideo(videoToDelete);
} else {
deleteSelectedVideos();
}
}}
>
删除
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
/* macOS style modal */
.mac-modal {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
</style>

View File

@@ -60,7 +60,7 @@
summary = new_summary;
}
update_summary();
setInterval(update_summary, 1000);
setInterval(update_summary, 5000);
function format_time(time: number) {
let hours = Math.floor(time / 3600);
@@ -139,11 +139,11 @@
let autoRecordStates = new Map<string, boolean>();
// Function to toggle auto-record state
function toggleAutoRecord(room: RecorderInfo) {
invoke("set_auto_start", {
function toggleEnabled(room: RecorderInfo) {
invoke("set_enable", {
roomId: room.room_id,
platform: room.platform,
autoStart: !room.auto_start,
enabled: !room.auto_start,
});
}
@@ -167,7 +167,7 @@
}
</script>
<div class="flex-1 p-6 overflow-auto">
<div class="flex-1 p-6 overflow-auto custom-scrollbar-light bg-gray-50">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
@@ -223,28 +223,34 @@
iclass={"w-full h-40 object-cover rounded-lg " +
(room.live_status ? "" : "brightness-75")}
/>
<!-- Room ID watermark -->
<div
class={"absolute top-2 left-2 p-1.5 px-2 rounded-full text-white text-xs flex items-center justify-center " +
(room.is_recording ? "bg-red-500" : "bg-gray-700/90")}
class="absolute bottom-2 left-2 px-2 py-1 rounded-md bg-black/30 backdrop-blur-sm flex items-center"
>
{#if room.auto_start}
<AutoRecordIcon class="w-4 h-4 text-white" />
{:else}
<Activity class="w-4 h-4 text-white" />
{/if}
{#if room.is_recording}
<span class="text-white ml-1">录制中</span>
{/if}
<span class="text-xs text-white/80 font-mono"
>{room.platform.toUpperCase()}#{room.room_id}</span
>
</div>
{#if room.auto_start}
<div
class={"absolute top-2 left-2 p-1.5 px-2 rounded-md text-white text-xs flex items-center justify-center " +
(room.is_recording ? "bg-red-500" : "bg-gray-700/90")}
>
<AutoRecordIcon class="w-4 h-4 text-white" />
{#if room.is_recording}
<span class="text-white ml-1">录制中</span>
{/if}
</div>
{/if}
{#if !room.live_status}
<div
class={"absolute bottom-2 right-2 p-1.5 px-2 rounded-full text-white text-xs flex items-center justify-center bg-gray-700"}
class={"absolute bottom-2 right-2 p-1.5 px-2 rounded-md text-white text-xs flex items-center justify-center bg-gray-700"}
>
<span>直播未开始</span>
</div>
{:else}
<div
class={"absolute bottom-2 right-2 p-1.5 px-2 rounded-full text-white text-xs flex items-center justify-center bg-green-500"}
class={"absolute bottom-2 right-2 p-1.5 px-2 rounded-md text-white text-xs flex items-center justify-center bg-green-500"}
>
<span>直播进行中</span>
</div>
@@ -255,33 +261,13 @@
<Ellipsis class="w-5 h-5 icon-white" />
</button>
<Dropdown class="whitespace-nowrap">
{#if room.is_recording}
<DropdownItem
on:click={() => {
invoke("force_stop", {
platform: room.platform,
roomId: room.room_id,
});
}}>暂停本次录制</DropdownItem
>
{/if}
{#if !room.is_recording && room.live_status}
<DropdownItem
on:click={() => {
invoke("force_start", {
platform: room.platform,
roomId: room.room_id,
});
}}>开始录制</DropdownItem
>
{/if}
<button
class="px-4 py-2 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
on:click={() => toggleAutoRecord(room)}
on:click={() => toggleEnabled(room)}
>
<span
class="text-sm text-gray-700 dark:text-gray-200 font-medium"
>自动开始录制</span
>启用直播间</span
>
<label class="toggle-switch ml-1">
<input type="checkbox" checked={room.auto_start} />
@@ -580,9 +566,9 @@
</button>
</div>
<div class="flex-1 overflow-auto">
<div class="flex-1 overflow-auto custom-scrollbar-light">
<div class="p-6">
<div class="overflow-x-auto">
<div class="overflow-x-auto custom-scrollbar-light">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700/50">

View File

@@ -4,7 +4,16 @@
import { TAURI_ENV } from "../lib/invoker";
import type { Config } from "../lib/interface";
import { Bell, HardDrive, AlertTriangle, FileText } from "lucide-svelte";
import {
Bell,
HardDrive,
AlertTriangle,
FileText,
Captions,
DiscAlbum,
SquareBottomDashedScissors,
} from "lucide-svelte";
import { onMount } from "svelte";
let setting_model: Config = {
cache: "",
@@ -16,6 +25,9 @@
post_notify: true,
auto_cleanup: true,
auto_subtitle: false,
subtitle_generator_type: "whisper",
openai_api_endpoint: "",
openai_api_key: "",
whisper_model: "",
whisper_prompt: "",
clip_name_format: "",
@@ -23,6 +35,7 @@
enabled: false,
encode_danmu: false,
},
status_check_interval: 30, // 默认30秒
};
let showModal = false;
@@ -110,10 +123,21 @@
});
}
get_config();
async function update_status_check_interval() {
if (setting_model.status_check_interval < 10) {
setting_model.status_check_interval = 10; // 最小值为10秒
}
await invoke("update_status_check_interval", {
interval: setting_model.status_check_interval,
});
}
onMount(async () => {
await get_config();
});
</script>
<div class="flex-1 overflow-auto">
<div class="flex-1 overflow-auto custom-scrollbar-light bg-gray-50">
<div class="h-screen">
<div class="p-6 space-y-6">
<!-- Header -->
@@ -127,6 +151,38 @@
<!-- Settings Sections -->
<div class="space-y-6 pb-6">
<div class="space-y-4">
<h2
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
>
<FileText class="w-5 h-5 dark:icon-white" />
<span>基础设置</span>
</h2>
<div
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"
>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
直播间状态检查间隔
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
设置直播间状态检查的时间间隔,单位为秒,过于频繁可能会触发风控
</p>
</div>
<div class="flex items-center space-x-2">
<input
type="number"
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white w-24"
bind:value={setting_model.status_check_interval}
on:blur={update_status_check_interval}
/>
</div>
</div>
</div>
</div>
</div>
<!-- API Server Settings -->
{#if !TAURI_ENV}
<div class="space-y-4">
@@ -364,7 +420,7 @@
<h2
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
>
<FileText class="w-5 h-5 dark:icon-white" />
<Captions class="w-5 h-5 dark:icon-white" />
<span>字幕生成</span>
</h2>
<div
@@ -396,8 +452,42 @@
</label>
</div>
</div>
<!-- Subtitle Generator Type -->
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
字幕生成器类型
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
选择字幕生成的方式本地Whisper模型或在线API服务
</p>
</div>
<div class="flex items-center space-x-2">
<select
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white"
bind:value={setting_model.subtitle_generator_type}
on:change={async () => {
try {
await invoke("update_subtitle_generator_type", {
subtitleGeneratorType:
setting_model.subtitle_generator_type,
});
} catch (error) {
console.error(error);
}
}}
>
<option value="whisper">本地 Whisper</option>
<option value="whisper_online">在线 Whisper API</option>
</select>
</div>
</div>
</div>
<!-- Whisper Model Path -->
{#if TAURI_ENV}
{#if TAURI_ENV && setting_model.subtitle_generator_type === "whisper"}
<div class="p-4">
<div class="flex items-center justify-between">
<div>
@@ -427,6 +517,64 @@
</div>
</div>
{/if}
<!-- OpenAI API Settings -->
{#if setting_model.subtitle_generator_type === "whisper_online"}
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
OpenAI API 端点
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
设置 OpenAI API 的端点地址,默认为官方地址
</p>
</div>
<div class="flex items-center space-x-2">
<input
type="text"
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white w-96"
bind:value={setting_model.openai_api_endpoint}
on:change={async () => {
await invoke("update_openai_api_endpoint", {
openaiApiEndpoint:
setting_model.openai_api_endpoint,
});
}}
placeholder="https://api.openai.com/v1"
/>
</div>
</div>
</div>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
OpenAI API 密钥
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
设置 OpenAI API 的访问密钥
</p>
</div>
<div class="flex items-center space-x-2">
<input
type="password"
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white w-96"
bind:value={setting_model.openai_api_key}
on:change={async () => {
await invoke("update_openai_api_key", {
openaiApiKey: setting_model.openai_api_key,
});
}}
placeholder="sk-..."
/>
</div>
</div>
</div>
{/if}
<div class="p-4">
<div class="flex items-center justify-between">
<div>
@@ -461,7 +609,7 @@
<h2
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
>
<FileText class="w-5 h-5 dark:icon-white" />
<DiscAlbum class="w-5 h-5 dark:icon-white" />
<span>切片文件名格式</span>
</h2>
<div
@@ -508,7 +656,7 @@
<h2
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
>
<FileText class="w-5 h-5 dark:icon-white" />
<SquareBottomDashedScissors class="w-5 h-5 dark:icon-white" />
<span>自动切片</span>
</h2>
<div

View File

@@ -232,7 +232,10 @@
<svelte:window on:click={handleClickOutside} />
<div class="flex-1 p-6 overflow-y-auto" on:scroll={handleScroll}>
<div
class="flex-1 p-6 overflow-y-auto custom-scrollbar-light bg-gray-50"
on:scroll={handleScroll}
>
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">

342
src/page/Task.svelte Normal file
View File

@@ -0,0 +1,342 @@
<script lang="ts">
import { invoke } from "../lib/invoker";
import { scale, fade } from "svelte/transition";
import {
Trash2,
Clock,
CheckCircle,
XCircle,
AlertCircle,
Loader2,
RefreshCw,
ChevronDown,
} from "lucide-svelte";
import type { TaskRow } from "../lib/db";
import { onMount, onDestroy } from "svelte";
let tasks: TaskRow[] = [];
let loading = true;
let deletingTaskId: string | null = null;
let refreshInterval = null;
let expandedTasks = new Set<string>();
async function update_tasks() {
try {
loading = true;
tasks = await invoke("get_tasks");
// 按创建时间倒序排列
tasks.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} catch (error) {
console.error("获取任务列表失败:", error);
} finally {
loading = false;
}
}
async function delete_task(id: string) {
try {
deletingTaskId = id;
await invoke("delete_task", { id });
await update_tasks();
} catch (error) {
console.error("删除任务失败:", error);
alert("删除任务失败:" + error);
} finally {
deletingTaskId = null;
}
}
function get_status_icon(status: string) {
switch (status.toLowerCase()) {
case "completed":
case "success":
return CheckCircle;
case "failed":
case "error":
return XCircle;
case "running":
case "processing":
return Loader2;
case "pending":
case "waiting":
return Clock;
default:
return AlertCircle;
}
}
function get_status_color(status: string) {
switch (status.toLowerCase()) {
case "completed":
case "success":
return "text-green-600 dark:text-green-400";
case "failed":
case "error":
return "text-red-600 dark:text-red-400";
case "running":
case "processing":
return "text-blue-600 dark:text-blue-400";
case "pending":
case "waiting":
return "text-yellow-600 dark:text-yellow-400";
default:
return "text-gray-600 dark:text-gray-400";
}
}
function get_status_bg_color(status: string) {
switch (status.toLowerCase()) {
case "completed":
case "success":
return "bg-green-100 dark:bg-green-900/20";
case "failed":
case "error":
return "bg-red-100 dark:bg-red-900/20";
case "running":
case "processing":
return "bg-blue-100 dark:bg-blue-900/20";
case "pending":
case "waiting":
return "bg-yellow-100 dark:bg-yellow-900/20";
default:
return "bg-gray-100 dark:bg-gray-900/20";
}
}
function format_date(date_str: string) {
const date = new Date(date_str);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function parse_metadata(metadata: string) {
try {
return JSON.parse(metadata);
} catch {
return null;
}
}
function get_task_type_name(task_type: string) {
switch (task_type.toLowerCase()) {
case "clip_range":
return "切片生成";
case "upload_procedure":
return "切片投稿";
case "generate_video_subtitle":
return "生成字幕";
case "encode_video_subtitle":
return "压制字幕";
default:
return task_type;
}
}
function get_task_type_color(task_type: string) {
switch (task_type.toLowerCase()) {
case "clip_range":
return "bg-purple-500";
case "upload_procedure":
return "bg-green-500";
case "generate_video_subtitle":
return "bg-blue-500";
case "encode_video_subtitle":
return "bg-orange-500";
default:
return "bg-gray-500";
}
}
function toggleMetadata(taskId: string) {
if (expandedTasks.has(taskId)) {
expandedTasks.delete(taskId);
} else {
expandedTasks.add(taskId);
}
expandedTasks = expandedTasks; // 触发响应式更新
}
// 设置自动刷新
onMount(async () => {
// 初始化时加载任务列表
update_tasks();
// 设置每5秒自动刷新
refreshInterval = setInterval(() => {
update_tasks();
}, 5000);
});
// 清理定时器
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<div class="flex-1 p-6 overflow-auto custom-scrollbar-light bg-gray-50">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
任务列表
</h1>
<div
class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"
>
<span>{tasks.length} 个任务</span>
</div>
</div>
<button
on:click={update_tasks}
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center space-x-2"
disabled={loading}
>
{#if loading}
<Loader2 class="w-5 h-5 icon-white animate-spin" />
{:else}
<RefreshCw class="w-5 h-5 icon-white" />
{/if}
<span>刷新</span>
</button>
</div>
<!-- Task List -->
<div class="space-y-4">
{#if loading && tasks.length === 0}
<div class="flex items-center justify-center py-12">
<Loader2 class="w-8 h-8 text-gray-400 animate-spin" />
<span class="ml-2 text-gray-500">加载中...</span>
</div>
{:else if tasks.length === 0}
<div
class="flex flex-col items-center justify-center py-12 text-center"
>
<div
class="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4"
>
<Clock class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
暂无任务
</h3>
<p class="text-gray-500 dark:text-gray-400">当前没有任务记录</p>
</div>
{:else}
{#each tasks as task (task.id)}
<div
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
in:scale={{ duration: 150, start: 0.95 }}
out:scale={{ duration: 150, start: 0.95 }}
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-3 mb-2">
<div class="flex items-center space-x-2">
<div
class="w-2 h-2 {get_task_type_color(
task.task_type
)} rounded-full"
></div>
<span
class="text-sm font-medium text-gray-900 dark:text-white"
>
{get_task_type_name(task.task_type)}
</span>
</div>
<div class="flex items-center space-x-2">
<div
class="flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium {get_status_bg_color(
task.status
)} {get_status_color(task.status)}"
>
{#if task.status.toLowerCase() === "running" || task.status.toLowerCase() === "processing"}
<Loader2 class="w-3 h-3 animate-spin" />
{:else}
<svelte:component
this={get_status_icon(task.status)}
class="w-3 h-3"
/>
{/if}
<span>{task.status}</span>
</div>
</div>
</div>
{#if task.message}
<div class="text-xs text-gray-400 dark:text-gray-500 my-2">
{task.message}
</div>
{/if}
{#if task.metadata}
{@const metadata = parse_metadata(task.metadata)}
{#if metadata}
<div class="mb-2">
<button
class="flex items-center space-x-1 text-xs text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
on:click={() => toggleMetadata(task.id)}
>
<ChevronDown
class="w-3 h-3 transition-transform {expandedTasks.has(
task.id
)
? 'rotate-180'
: ''}"
/>
<span>详细信息</span>
</button>
{#if expandedTasks.has(task.id)}
<div
class="mt-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-xs text-gray-600 dark:text-gray-400 space-y-1"
in:scale={{ duration: 150, start: 0.95 }}
out:scale={{ duration: 150, start: 0.95 }}
>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</div>
{/if}
</div>
{/if}
{/if}
<div class="text-xs text-gray-400 dark:text-gray-500 mt-2">
创建时间: {format_date(task.created_at)}
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
class="p-2 rounded-lg transition-colors {task.status.toLowerCase() ===
'pending'
? 'text-gray-400 cursor-not-allowed'
: 'hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 hover:text-red-700'}"
on:click={() => delete_task(task.id)}
disabled={deletingTaskId === task.id ||
task.status.toLowerCase() === "pending"}
>
{#if deletingTaskId === task.id}
<Loader2 class="w-4 h-4 animate-spin" />
{:else}
<Trash2 class="w-4 h-4" />
{/if}
</button>
</div>
</div>
</div>
{/each}
{/if}
</div>
</div>
</div>

View File

@@ -11,13 +11,130 @@ body {
}
.icon-white {
color: white;
color: white;
}
.icon-primary {
color: #007bff;
color: #007bff;
}
.icon-danger {
color: #dc3545;
color: #dc3545;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1c1c1e;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #3c3c3e;
border-radius: 4px;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: #4c4c4e;
}
::-webkit-scrollbar-corner {
background: #1c1c1e;
}
/* 针对侧边栏的特殊滚动条样式 */
.sidebar-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.sidebar-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-scrollbar::-webkit-scrollbar-thumb {
background: #4c4c4e;
border-radius: 3px;
transition: background-color 0.2s ease;
}
.sidebar-scrollbar::-webkit-scrollbar-thumb:hover {
background: #5c5c5e;
}
.sidebar-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
/* 针对表格的水平滚动条样式 */
.sidebar-scrollbar::-webkit-scrollbar:horizontal {
height: 6px;
}
.sidebar-scrollbar::-webkit-scrollbar-thumb:horizontal {
background: #4c4c4e;
border-radius: 3px;
}
.sidebar-scrollbar::-webkit-scrollbar-thumb:horizontal:hover {
background: #5c5c5e;
}
/* 针对深色主题的滚动条样式 */
.dark .sidebar-scrollbar::-webkit-scrollbar-thumb {
background: #4c4c4e;
}
.dark .sidebar-scrollbar::-webkit-scrollbar-thumb:hover {
background: #5c5c5e;
}
.dark .sidebar-scrollbar::-webkit-scrollbar-thumb:horizontal {
background: #4c4c4e;
}
.dark .sidebar-scrollbar::-webkit-scrollbar-thumb:horizontal:hover {
background: #5c5c5e;
}
/* 针对 Summary 页面的浅色滚动条样式 */
.custom-scrollbar-light::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar-light::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar-light::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
transition: background-color 0.2s ease;
}
.custom-scrollbar-light::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
.custom-scrollbar-light::-webkit-scrollbar-corner {
background: transparent;
}
.custom-scrollbar-light::-webkit-scrollbar:horizontal {
height: 6px;
}
.custom-scrollbar-light::-webkit-scrollbar-thumb:horizontal {
background: #d1d5db;
border-radius: 3px;
}
.custom-scrollbar-light::-webkit-scrollbar-thumb:horizontal:hover {
background: #9ca3af;
}

View File

@@ -49,7 +49,8 @@ export default defineConfig(async ({ mode }) => {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
live: resolve(__dirname, "live_index.html"),
live: resolve(__dirname, "index_live.html"),
clip: resolve(__dirname, "index_clip.html"),
},
},
// Tauri supports es2021

806
yarn.lock

File diff suppressed because it is too large Load Diff