mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-25 04:22:24 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3813528f50 | ||
|
|
e3bb014644 | ||
|
|
76a7afde76 | ||
|
|
1184f9f3f5 | ||
|
|
b754f8938f | ||
|
|
6b30ff04b7 | ||
|
|
1c40acca63 | ||
|
|
a5a7a8afaf | ||
|
|
583ac13a37 | ||
|
|
3e58972072 | ||
|
|
f15aa27727 | ||
|
|
2581014dbd | ||
|
|
baaaa1b57e | ||
|
|
160fbb3590 | ||
|
|
6f3253678c | ||
|
|
563ad66243 | ||
|
|
a8d002cc53 | ||
|
|
0615410fa4 | ||
|
|
fc98e065f8 | ||
|
|
66f671ffa0 | ||
|
|
69a35af456 | ||
|
|
e462bd0b4c | ||
|
|
ae6483427f | ||
|
|
ad97677104 | ||
|
|
996d15ef25 | ||
|
|
06de32ffe7 | ||
|
|
dd43074e46 |
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[[language]]
|
||||
name = "rust"
|
||||
auto-format = true
|
||||
rulers = []
|
||||
|
||||
[[language]]
|
||||
name = "svelte"
|
||||
|
||||
@@ -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
13
index_clip.html
Normal 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
63
index_live.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
1819
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
43
src-tauri/crates/danmu_stream/Cargo.toml
Normal file
43
src-tauri/crates/danmu_stream/Cargo.toml
Normal 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"
|
||||
0
src-tauri/crates/danmu_stream/README.md
Normal file
0
src-tauri/crates/danmu_stream/README.md
Normal file
40
src-tauri/crates/danmu_stream/examples/douyin.rs
Normal file
40
src-tauri/crates/danmu_stream/examples/douyin.rs
Normal 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(())
|
||||
}
|
||||
51
src-tauri/crates/danmu_stream/src/danmu_stream.rs
Normal file
51
src-tauri/crates/danmu_stream/src/danmu_stream.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
51
src-tauri/crates/danmu_stream/src/http_client.rs
Normal file
51
src-tauri/crates/danmu_stream/src/http_client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
30
src-tauri/crates/danmu_stream/src/lib.rs
Normal file
30
src-tauri/crates/danmu_stream/src/lib.rs
Normal 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,
|
||||
}
|
||||
72
src-tauri/crates/danmu_stream/src/provider.rs
Normal file
72
src-tauri/crates/danmu_stream/src/provider.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
436
src-tauri/crates/danmu_stream/src/provider/bilibili.rs
Normal file
436
src-tauri/crates/danmu_stream/src/provider/bilibili.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
161
src-tauri/crates/danmu_stream/src/provider/bilibili/pack.rs
Normal file
161
src-tauri/crates/danmu_stream/src/provider/bilibili/pack.rs
Normal 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))
|
||||
}
|
||||
115
src-tauri/crates/danmu_stream/src/provider/bilibili/send_gift.rs
Normal file
115
src-tauri/crates/danmu_stream/src/provider/bilibili/send_gift.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
462
src-tauri/crates/danmu_stream/src/provider/douyin.rs
Normal file
462
src-tauri/crates/danmu_stream/src/provider/douyin.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
1
src-tauri/crates/danmu_stream/src/provider/douyin/crypto-js.min.js
vendored
Normal file
1
src-tauri/crates/danmu_stream/src/provider/douyin/crypto-js.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
861
src-tauri/crates/danmu_stream/src/provider/douyin/messages.rs
Normal file
861
src-tauri/crates/danmu_stream/src/provider/douyin/messages.rs
Normal 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,
|
||||
}
|
||||
13167
src-tauri/crates/danmu_stream/src/provider/douyin/webmssdk.js
Normal file
13167
src-tauri/crates/danmu_stream/src/provider/douyin/webmssdk.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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"]}}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
86
src-tauri/src/database/task.rs
Normal file
86
src-tauri/src/database/task.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
15
src-tauri/src/handlers/task.rs
Normal file
15
src-tauri/src/handlers/task.rs
Normal 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?)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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, ¶m.level, ¶m.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, ¶ms.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();
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
232
src-tauri/src/subtitle_generator/whisper_online.rs
Normal file
232
src-tauri/src/subtitle_generator/whisper_online.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
97
src/AppClip.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
8
src/main_clip.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import "./styles.css";
|
||||
import App from "./AppClip.svelte";
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById("app"),
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -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">
|
||||
|
||||
@@ -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
710
src/page/Clip.svelte
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
342
src/page/Task.svelte
Normal 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>
|
||||
123
src/styles.css
123
src/styles.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user