Compare commits

...

5 Commits

Author SHA1 Message Date
Xinrea
8bea9336ae fix: multiple danmu task running (#215) 2025-11-01 23:43:40 +08:00
Xinrea
617a6a0b8e fix: panic generating whole live without danmu (#214) 2025-11-01 22:07:52 +08:00
Xinrea
140ab772d0 feat: auto using hwaccel x264 encoder (#213)
* feat: auto using hwaccel x264 encoder

* fix: ffmpeg args error
2025-11-01 21:50:03 +08:00
Xinrea
e7d8c8814d fix: shortcut key conflicts with input (#212)
* fix: shortcut key conflicts with input

* Update src/lib/components/VideoPreview.svelte

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-11-01 20:44:27 +08:00
Xinrea
588559c645 refactor: migrate ids to str for compatibility (#211)
* refactor: migrate ids to str for compatibility

* feat: handle get item from cookies
2025-11-01 19:27:39 +08:00
47 changed files with 977 additions and 785 deletions

View File

@@ -18,7 +18,7 @@
"id": "a96a5e9f-9857-4c13-b889-91da2ace208a",
"event": "recorder.added",
"payload": {
"room_id": 26966466,
"room_id": "26966466",
"created_at": "2025-09-07T03:33:14.258796+00:00",
"platform": "bilibili",
"auto_start": true,
@@ -35,7 +35,7 @@
"id": "e33623d4-e040-4390-88f5-d351ceeeace7",
"event": "recorder.removed",
"payload": {
"room_id": 27183290,
"room_id": "27183290",
"created_at": "2025-08-30T10:54:18.569198+00:00",
"platform": "bilibili",
"auto_start": true,
@@ -57,9 +57,9 @@
"id": "f12f3424-f7d8-4b2f-a8b7-55477411482e",
"event": "live.started",
"payload": {
"room_id": 843610,
"room_id": "843610",
"room_info": {
"room_id": 843610,
"room_id": "843610",
"room_title": "登顶!",
"room_cover": "https://i0.hdslb.com/bfs/live/new_room_cover/73aea43f4b4624c314d62fea4b424822fb506dfb.jpg"
},
@@ -86,9 +86,9 @@
"id": "e8b0756a-02f9-4655-b5ae-a170bf9547bd",
"event": "live.ended",
"payload": {
"room_id": 843610,
"room_id": "843610",
"room_info": {
"room_id": 843610,
"room_id": "843610",
"room_title": "登顶!",
"room_cover": "https://i0.hdslb.com/bfs/live/new_room_cover/73aea43f4b4624c314d62fea4b424822fb506dfb.jpg"
},
@@ -117,9 +117,9 @@
"id": "5ec1ea10-2b31-48fd-8deb-f2d7d2ea5985",
"event": "record.started",
"payload": {
"room_id": 26966466,
"room_id": "26966466",
"room_info": {
"room_id": 26966466,
"room_id": "26966466",
"room_title": "早安獭獭栞下播前抽fufu",
"room_cover": "https://i0.hdslb.com/bfs/live/user_cover/b810c36855168034557e905e5916b1dba1761fa4.jpg"
},
@@ -146,9 +146,9 @@
"id": "56fd03e5-3965-4c2e-a6a9-bb6932347eb3",
"event": "record.ended",
"payload": {
"room_id": 26966466,
"room_id": "26966466",
"room_info": {
"room_id": 26966466,
"room_id": "26966466",
"room_title": "早安獭獭栞下播前抽fufu",
"room_cover": "https://i0.hdslb.com/bfs/live/user_cover/b810c36855168034557e905e5916b1dba1761fa4.jpg"
},
@@ -177,7 +177,7 @@
"payload": {
"platform": "bilibili",
"live_id": "1756607084705",
"room_id": 1967212929,
"room_id": "1967212929",
"title": "灶台O.o",
"length": 9,
"size": 1927112,
@@ -198,7 +198,7 @@
"event": "clip.generated",
"payload": {
"id": 316,
"room_id": 27183290,
"room_id": "27183290",
"cover": "[27183290][1757172501727][一起看凡人修仙传][2025-09-07_00-16-11].jpg",
"file": "[27183290][1757172501727][一起看凡人修仙传][2025-09-07_00-16-11].mp4",
"note": "",
@@ -225,7 +225,7 @@
"event": "clip.deleted",
"payload": {
"id": 313,
"room_id": 27183290,
"room_id": "27183290",
"cover": "[27183290][1756903953470][不出非洲之心不下播][2025-09-03_21-10-54].jpg",
"file": "[27183290][1756903953470][不出非洲之心不下播][2025-09-03_21-10-54].mp4",
"note": "",

View File

@@ -8,7 +8,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
env_logger::init();
// Replace these with actual values
let room_id = 768756;
let room_id = "768756";
let cookie = "";
let stream = Arc::new(DanmuStream::new(ProviderType::BiliBili, cookie, room_id).await?);

View File

@@ -8,7 +8,7 @@ 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 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?);

View File

@@ -11,7 +11,7 @@ use crate::{
pub struct DanmuStream {
pub provider_type: ProviderType,
pub identifier: String,
pub room_id: i64,
pub room_id: String,
pub provider: Arc<RwLock<Box<dyn DanmuProvider>>>,
tx: mpsc::UnboundedSender<DanmuMessageType>,
rx: Arc<RwLock<mpsc::UnboundedReceiver<DanmuMessageType>>>,
@@ -21,14 +21,14 @@ impl DanmuStream {
pub async fn new(
provider_type: ProviderType,
identifier: &str,
room_id: i64,
room_id: &str,
) -> 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,
room_id: room_id.to_string(),
provider: Arc::new(RwLock::new(provider)),
tx,
rx: Arc::new(RwLock::new(rx)),

View File

@@ -29,7 +29,7 @@ pub enum DanmuMessageType {
#[derive(Debug, Clone)]
pub struct DanmuMessage {
pub room_id: i64,
pub room_id: String,
pub user_id: u64,
pub user_name: String,
pub message: String,

View File

@@ -36,7 +36,7 @@ type WsWriteType = futures_util::stream::SplitSink<
pub struct BiliDanmu {
client: ApiClient,
room_id: i64,
room_id: String,
user_id: i64,
stop: Arc<RwLock<bool>>,
write: Arc<RwLock<Option<WsWriteType>>>,
@@ -44,7 +44,7 @@ pub struct BiliDanmu {
#[async_trait]
impl DanmuProvider for BiliDanmu {
async fn new(cookie: &str, room_id: i64) -> Result<Self, DanmuStreamError> {
async fn new(cookie: &str, room_id: &str) -> Result<Self, DanmuStreamError> {
// find DedeUserID=<user_id> in cookie str
let user_id = BiliDanmu::parse_user_id(cookie)?;
// add buvid3 to cookie
@@ -54,7 +54,7 @@ impl DanmuProvider for BiliDanmu {
Ok(Self {
client,
user_id,
room_id,
room_id: room_id.to_string(),
stop: Arc::new(RwLock::new(false)),
write: Arc::new(RwLock::new(None)),
})
@@ -126,8 +126,10 @@ impl BiliDanmu {
tx: mpsc::UnboundedSender<DanmuMessageType>,
) -> Result<(), DanmuStreamError> {
let wbi_key = self.get_wbi_key().await?;
let real_room = self.get_real_room(&wbi_key, self.room_id).await?;
let danmu_info = self.get_danmu_info(&wbi_key, real_room).await?;
let real_room = self.get_real_room(&wbi_key, &self.room_id).await?;
let danmu_info = self
.get_danmu_info(&wbi_key, real_room.to_string().as_str())
.await?;
let ws_hosts = danmu_info.data.host_list.clone();
let mut conn = None;
log::debug!("ws_hosts: {:?}", ws_hosts);
@@ -241,7 +243,7 @@ impl BiliDanmu {
async fn get_danmu_info(
&self,
wbi_key: &str,
room_id: i64,
room_id: &str,
) -> Result<DanmuInfo, DanmuStreamError> {
let params = self
.get_sign(
@@ -268,7 +270,7 @@ impl BiliDanmu {
Ok(resp)
}
async fn get_real_room(&self, wbi_key: &str, room_id: i64) -> Result<i64, DanmuStreamError> {
async fn get_real_room(&self, wbi_key: &str, room_id: &str) -> Result<i64, DanmuStreamError> {
let params = self
.get_sign(
wbi_key,

View File

@@ -65,7 +65,7 @@ impl WsStreamCtx {
if let Some(danmu_msg) = danmu_msg {
Ok(DanmuMessageType::DanmuMessage(DanmuMessage {
room_id: 0,
room_id: "".to_string(),
user_id: danmu_msg.uid,
user_name: danmu_msg.username,
message: danmu_msg.msg,

View File

@@ -33,7 +33,7 @@ type WsWriteType =
futures_util::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, WsMessage>;
pub struct DouyinDanmu {
room_id: i64,
room_id: String,
cookie: String,
stop: Arc<RwLock<bool>>,
write: Arc<RwLock<Option<WsWriteType>>>,
@@ -192,7 +192,7 @@ impl DouyinDanmu {
});
// Main message handling loop
let room_id = self.room_id;
let room_id = self.room_id.clone();
let stop = Arc::clone(&self.stop);
let write = Arc::clone(&self.write);
let message_handle = tokio::spawn(async move {
@@ -210,7 +210,7 @@ impl DouyinDanmu {
match msg {
WsMessage::Binary(data) => {
if let Ok(Some(ack)) = handle_binary_message(&data, &tx, room_id).await {
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
@@ -268,7 +268,7 @@ impl DouyinDanmu {
async fn handle_binary_message(
data: &[u8],
tx: &mpsc::UnboundedSender<DanmuMessageType>,
room_id: i64,
room_id: &str,
) -> Result<Option<PushFrame>, DanmuStreamError> {
// First decode the PushFrame
let push_frame = PushFrame::decode(Bytes::from(data.to_vec())).map_err(|e| {
@@ -328,7 +328,7 @@ async fn handle_binary_message(
})?;
if let Some(user) = chat_msg.user {
let danmu_msg = DanmuMessage {
room_id,
room_id: room_id.to_string(),
user_id: user.id,
user_name: user.nick_name,
message: chat_msg.content,
@@ -394,9 +394,9 @@ async fn handle_binary_message(
#[async_trait]
impl DanmuProvider for DouyinDanmu {
async fn new(identifier: &str, room_id: i64) -> Result<Self, DanmuStreamError> {
async fn new(identifier: &str, room_id: &str) -> Result<Self, DanmuStreamError> {
Ok(Self {
room_id,
room_id: room_id.to_string(),
cookie: identifier.to_string(),
stop: Arc::new(RwLock::new(false)),
write: Arc::new(RwLock::new(None)),

View File

@@ -17,7 +17,7 @@ pub enum ProviderType {
#[async_trait]
pub trait DanmuProvider: Send + Sync {
async fn new(identifier: &str, room_id: i64) -> Result<Self, DanmuStreamError>
async fn new(identifier: &str, room_id: &str) -> Result<Self, DanmuStreamError>
where
Self: Sized;
@@ -57,7 +57,7 @@ pub trait DanmuProvider: Send + Sync {
pub async fn new(
provider_type: ProviderType,
identifier: &str,
room_id: i64,
room_id: &str,
) -> Result<Box<dyn DanmuProvider>, DanmuStreamError> {
match provider_type {
ProviderType::BiliBili => {

View File

@@ -15,7 +15,7 @@ use crate::errors::RecorderError;
use crate::ffmpeg::VideoMetadata;
use crate::{core::HlsStream, events::RecorderEvent};
const UPDATE_TIMEOUT: Duration = Duration::from_secs(10);
const UPDATE_TIMEOUT: Duration = Duration::from_secs(20);
const UPDATE_INTERVAL: Duration = Duration::from_secs(1);
const PLAYLIST_FILE_NAME: &str = "playlist.m3u8";
const DOWNLOAD_RETRY: u32 = 3;
@@ -193,13 +193,19 @@ impl HlsRecorder {
// we need to remove the query parameters: 1.ts
let filename = segment.uri.split('?').next().unwrap_or(&segment.uri);
let segment_path = self.work_dir.join(filename);
let size = download(
let Ok(size) = download(
&self.client,
&segment_full_url,
&segment_path,
DOWNLOAD_RETRY,
)
.await?;
.await
else {
log::error!("Download failed: {:#?}", segment);
return Err(RecorderError::IoError(std::io::Error::other(
"Download failed",
)));
};
// check if the stream is changed
let segment_metadata = crate::ffmpeg::extract_video_metadata(&segment_path)

View File

@@ -7,7 +7,7 @@ pub enum RecorderEvent {
recorder: RecorderInfo,
},
LiveEnd {
room_id: i64,
room_id: String,
platform: PlatformType,
recorder: RecorderInfo,
},
@@ -32,7 +32,7 @@ pub enum RecorderEvent {
message: String,
},
DanmuReceived {
room: i64,
room: String,
ts: i64,
content: String,
},

View File

@@ -58,7 +58,7 @@ where
T: Send + Sync,
{
platform: PlatformType,
room_id: i64,
room_id: String,
/// The account for the recorder
account: Account,
/// The client for the recorder
@@ -108,8 +108,8 @@ impl<T: Send + Sync> traits::RecorderBasicTrait<T> for Recorder<T> {
self.platform
}
fn room_id(&self) -> i64 {
self.room_id
fn room_id(&self) -> String {
self.room_id.clone()
}
fn account(&self) -> &Account {
@@ -194,17 +194,17 @@ impl<T: Send + Sync> traits::RecorderBasicTrait<T> for Recorder<T> {
pub struct CachePath {
pub cache_path: PathBuf,
pub platform: PlatformType,
pub room_id: i64,
pub room_id: String,
pub live_id: String,
pub file_name: Option<String>,
}
impl CachePath {
pub fn new(cache_path: PathBuf, platform: PlatformType, room_id: i64, live_id: &str) -> Self {
pub fn new(cache_path: PathBuf, platform: PlatformType, room_id: &str, live_id: &str) -> Self {
Self {
cache_path,
platform,
room_id,
room_id: room_id.to_string(),
live_id: live_id.to_string(),
file_name: None,
}

View File

@@ -38,7 +38,7 @@ pub type BiliRecorder = Recorder<BiliExtra>;
impl BiliRecorder {
pub async fn new(
room_id: i64,
room_id: &str,
account: &Account,
cache_dir: PathBuf,
event_channel: broadcast::Sender<RecorderEvent>,
@@ -53,7 +53,7 @@ impl BiliRecorder {
let recorder = Self {
platform: PlatformType::BiliBili,
room_id,
room_id: room_id.to_string(),
account: account.clone(),
client,
event_channel,
@@ -102,7 +102,7 @@ impl BiliRecorder {
async fn check_status(&self) -> bool {
let pre_live_status = self.room_info.read().await.status;
match api::get_room_info(&self.client, &self.account, self.room_id).await {
match api::get_room_info(&self.client, &self.account, &self.room_id).await {
Ok(room_info) => {
*self.room_info.write().await = RoomInfo {
platform: "bilibili".to_string(),
@@ -112,9 +112,9 @@ impl BiliRecorder {
status: room_info.live_status == 1,
};
// Only update user info once
if self.user_info.read().await.user_id != room_info.user_id.to_string() {
if self.user_info.read().await.user_id != room_info.user_id {
let user_id = room_info.user_id;
let user_info = api::get_user_info(&self.client, &self.account, user_id).await;
let user_info = api::get_user_info(&self.client, &self.account, &user_id).await;
if let Ok(user_info) = user_info {
*self.user_info.write().await = UserInfo {
user_id: user_id.to_string(),
@@ -141,7 +141,7 @@ impl BiliRecorder {
if live_status {
// Get cover image
let room_cover_path = Path::new(PlatformType::BiliBili.as_str())
.join(self.room_id.to_string())
.join(&self.room_id)
.join("cover.jpg");
let full_room_cover_path = self.cache_dir.join(&room_cover_path);
if (api::download_file(
@@ -161,7 +161,7 @@ impl BiliRecorder {
} else {
let _ = self.event_channel.send(RecorderEvent::LiveEnd {
platform: PlatformType::BiliBili,
room_id: self.room_id,
room_id: self.room_id.to_string(),
recorder: self.info().await,
});
*self.live_id.write().await = String::new();
@@ -187,7 +187,7 @@ impl BiliRecorder {
let new_stream = api::get_stream_info(
&self.client,
&self.account,
self.room_id,
&self.room_id,
Protocol::HttpHls,
Format::TS,
&[Codec::Avc, Codec::Hevc],
@@ -204,7 +204,7 @@ impl BiliRecorder {
log::info!(
"[{}]Update to a new stream: {:#?} => {:#?}",
self.room_id,
&self.room_id,
pre_live_stream,
stream
);
@@ -213,11 +213,11 @@ impl BiliRecorder {
}
Err(e) => {
if let crate::errors::RecorderError::FormatNotFound { format } = e {
log::error!("[{}]Format {} not found", self.room_id, format);
log::error!("[{}]Format {} not found", &self.room_id, format);
true
} else {
log::error!("[{}]Fetch stream failed: {}", self.room_id, e);
log::error!("[{}]Fetch stream failed: {}", &self.room_id, e);
true
}
@@ -225,7 +225,7 @@ impl BiliRecorder {
}
}
Err(e) => {
log::error!("[{}]Update room status failed: {}", self.room_id, e);
log::error!("[{}]Update room status failed: {}", &self.room_id, e);
// may encounter internet issues, not sure whether the stream is closed or started, just remain
pre_live_status
}
@@ -234,43 +234,58 @@ impl BiliRecorder {
async fn danmu(&self) -> Result<(), crate::errors::RecorderError> {
let cookies = self.account.cookies.clone();
let room_id = self.room_id;
let danmu_stream = DanmuStream::new(ProviderType::BiliBili, &cookies, room_id).await;
let room_id = self.room_id.clone();
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: {}", self.room_id, err);
log::error!("[{}]Failed to create danmu stream: {}", &self.room_id, err);
return Err(crate::errors::RecorderError::DanmuStreamError(err));
}
let danmu_stream = danmu_stream.unwrap();
// create a task to receive danmu message
let danmu_stream_clone = danmu_stream.clone();
tokio::spawn(async move {
let _ = danmu_stream_clone.start().await;
});
let mut start_fut = Box::pin(danmu_stream.start());
loop {
if let Ok(Some(msg)) = danmu_stream.recv().await {
match msg {
DanmuMessageType::DanmuMessage(danmu) => {
let ts = Utc::now().timestamp_millis();
let _ = self.event_channel.send(RecorderEvent::DanmuReceived {
room: self.room_id,
ts,
content: danmu.message.clone(),
});
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
storage.add_line(ts, &danmu.message).await;
tokio::select! {
start_res = &mut start_fut => {
match start_res {
Ok(_) => {
log::info!("[{}]Danmu stream finished", &self.room_id);
return Ok(());
}
Err(err) => {
log::error!("[{}]Danmu stream start error: {}", &self.room_id, err);
return Err(crate::errors::RecorderError::DanmuStreamError(err));
}
}
}
recv_res = danmu_stream.recv() => {
match recv_res {
Ok(Some(msg)) => {
match msg {
DanmuMessageType::DanmuMessage(danmu) => {
let ts = Utc::now().timestamp_millis();
let _ = self.event_channel.send(RecorderEvent::DanmuReceived {
room: self.room_id.clone(),
ts,
content: danmu.message.clone(),
});
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
storage.add_line(ts, &danmu.message).await;
}
}
}
}
Ok(None) => {
log::info!("[{}]Danmu stream closed", &self.room_id);
return Ok(());
}
Err(err) => {
log::error!("[{}]Failed to receive danmu message: {}", &self.room_id, err);
return Err(crate::errors::RecorderError::DanmuStreamError(err));
}
}
}
} else {
log::error!("[{}]Failed to receive danmu message", self.room_id);
return Err(crate::errors::RecorderError::DanmuStreamError(
danmu_stream::DanmuStreamError::WebsocketError {
err: "Failed to receive danmu message".to_string(),
},
));
}
}
}
@@ -294,7 +309,7 @@ impl BiliRecorder {
let room_cover_path = self
.cache_dir
.join(PlatformType::BiliBili.as_str())
.join(self.room_id.to_string())
.join(&self.room_id)
.join("cover.jpg");
tokio::fs::copy(room_cover_path, &cover_path.full_path())

View File

@@ -40,16 +40,16 @@ struct UploadParams<'a> {
pub struct RoomInfo {
pub live_status: u8,
pub room_cover_url: String,
pub room_id: i64,
pub room_id: String,
pub room_keyframe_url: String,
pub room_title: String,
pub user_id: i64,
pub user_id: String,
pub live_start_time: i64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserInfo {
pub user_id: i64,
pub user_id: String,
pub user_name: String,
pub user_sign: String,
pub user_avatar_url: String,
@@ -242,7 +242,7 @@ pub async fn logout(client: &Client, account: &Account) -> Result<(), RecorderEr
pub async fn get_user_info(
client: &Client,
account: &Account,
user_id: i64,
user_id: &str,
) -> Result<UserInfo, RecorderError> {
let params: Value = json!({
"mid": user_id.to_string(),
@@ -284,7 +284,7 @@ pub async fn get_user_info(
return Err(RecorderError::InvalidResponseJson { resp: res.clone() });
}
Ok(UserInfo {
user_id,
user_id: user_id.to_string(),
user_name: res["data"]["name"].as_str().unwrap_or("").to_string(),
user_sign: res["data"]["sign"].as_str().unwrap_or("").to_string(),
user_avatar_url: res["data"]["face"].as_str().unwrap_or("").to_string(),
@@ -294,7 +294,7 @@ pub async fn get_user_info(
pub async fn get_room_info(
client: &Client,
account: &Account,
room_id: i64,
room_id: &str,
) -> Result<RoomInfo, RecorderError> {
let mut headers = generate_user_agent_header();
if let Ok(cookies) = account.cookies.parse() {
@@ -329,7 +329,8 @@ pub async fn get_room_info(
let room_id = res["data"]["room_id"]
.as_i64()
.ok_or(RecorderError::InvalidValue)?;
.ok_or(RecorderError::InvalidValue)?
.to_string();
let room_title = res["data"]["title"]
.as_str()
.ok_or(RecorderError::InvalidValue)?
@@ -344,7 +345,8 @@ pub async fn get_room_info(
.to_string();
let user_id = res["data"]["uid"]
.as_i64()
.ok_or(RecorderError::InvalidValue)?;
.ok_or(RecorderError::InvalidValue)?
.to_string();
let live_status = res["data"]["live_status"]
.as_u64()
.ok_or(RecorderError::InvalidValue)? as u8;
@@ -384,7 +386,7 @@ pub async fn get_room_info(
pub async fn get_stream_info(
client: &Client,
account: &Account,
room_id: i64,
room_id: &str,
protocol: Protocol,
format: Format,
codec: &[Codec],
@@ -900,7 +902,7 @@ pub async fn upload_cover(
pub async fn send_danmaku(
client: &Client,
account: &Account,
room_id: i64,
room_id: &str,
message: &str,
) -> Result<(), RecorderError> {
let url = "https://api.live.bilibili.com/msg/send".to_string();
@@ -918,7 +920,7 @@ pub async fn send_danmaku(
("fontsize", "25"),
("room_type", "0"),
("rnd", &format!("{}", chrono::Local::now().timestamp())),
("roomid", &format!("{room_id}")),
("roomid", room_id),
("csrf", &account.csrf),
("csrf_token", &account.csrf),
];

View File

@@ -43,7 +43,7 @@ fn get_best_stream_url(stream: &DouyinStream) -> Option<String> {
impl DouyinRecorder {
pub async fn new(
room_id: i64,
room_id: &str,
sec_user_id: &str,
account: &Account,
cache_dir: PathBuf,
@@ -53,7 +53,7 @@ impl DouyinRecorder {
) -> Result<Self, crate::errors::RecorderError> {
Ok(Self {
platform: PlatformType::Douyin,
room_id,
room_id: room_id.to_string(),
account: account.clone(),
client: reqwest::Client::new(),
event_channel: channel,
@@ -85,7 +85,7 @@ impl DouyinRecorder {
match api::get_room_info(
&self.client,
&self.account,
self.room_id,
&self.room_id,
&self.extra.sec_user_id,
)
.await
@@ -123,7 +123,7 @@ impl DouyinRecorder {
} else {
let _ = self.event_channel.send(RecorderEvent::LiveEnd {
platform: PlatformType::Douyin,
room_id: self.room_id,
room_id: self.room_id.clone(),
recorder: self.info().await,
});
}
@@ -166,7 +166,7 @@ impl DouyinRecorder {
true
}
Err(e) => {
log::warn!("[{}]Update room status failed: {}", self.room_id, e);
log::warn!("[{}]Update room status failed: {}", &self.room_id, e);
pre_live_status
}
}
@@ -181,7 +181,8 @@ impl DouyinRecorder {
.clone()
.parse::<i64>()
.unwrap_or(0);
let danmu_stream = DanmuStream::new(ProviderType::Douyin, &cookies, danmu_room_id).await;
let danmu_stream =
DanmuStream::new(ProviderType::Douyin, &cookies, &danmu_room_id.to_string()).await;
if danmu_stream.is_err() {
let err = danmu_stream.err().unwrap();
log::error!("Failed to create danmu stream: {err}");
@@ -189,34 +190,50 @@ impl DouyinRecorder {
}
let danmu_stream = danmu_stream.unwrap();
let danmu_stream_clone = danmu_stream.clone();
*self.danmu_task.lock().await = Some(tokio::spawn(async move {
let _ = danmu_stream_clone.start().await;
}));
let mut start_fut = Box::pin(danmu_stream.start());
loop {
if let Ok(Some(msg)) = danmu_stream.recv().await {
match msg {
DanmuMessageType::DanmuMessage(danmu) => {
let ts = Utc::now().timestamp_millis();
let _ = self.event_channel.send(RecorderEvent::DanmuReceived {
room: self.room_id,
ts,
content: danmu.message.clone(),
});
if let Some(danmu_storage) = self.danmu_storage.read().await.as_ref() {
danmu_storage.add_line(ts, &danmu.message).await;
tokio::select! {
start_res = &mut start_fut => {
match start_res {
Ok(_) => {
log::info!("Danmu stream finished");
return Ok(());
}
Err(err) => {
log::error!("Danmu stream start error: {err}");
return Err(crate::errors::RecorderError::DanmuStreamError(err));
}
}
}
recv_res = danmu_stream.recv() => {
match recv_res {
Ok(Some(msg)) => {
match msg {
DanmuMessageType::DanmuMessage(danmu) => {
let ts = Utc::now().timestamp_millis();
let _ = self.event_channel.send(RecorderEvent::DanmuReceived {
room: self.room_id.clone(),
ts,
content: danmu.message.clone(),
});
if let Some(danmu_storage) = self.danmu_storage.read().await.as_ref() {
danmu_storage.add_line(ts, &danmu.message).await;
}
}
}
}
Ok(None) => {
log::info!("Danmu stream closed");
return Ok(());
}
Err(err) => {
log::error!("Failed to receive danmu message: {err}");
return Err(crate::errors::RecorderError::DanmuStreamError(err));
}
}
}
} else {
log::error!("Failed to receive danmu message");
return Err(crate::errors::RecorderError::DanmuStreamError(
danmu_stream::DanmuStreamError::WebsocketError {
err: "Failed to receive danmu message".to_string(),
},
));
}
}
}
@@ -229,6 +246,11 @@ impl DouyinRecorder {
self.total_duration.store(0, atomic::Ordering::Relaxed);
self.total_size.store(0, atomic::Ordering::Relaxed);
*self.extra.live_stream.write().await = None;
if let Some(danmu_task) = self.danmu_task.lock().await.as_mut() {
danmu_task.abort();
let _ = danmu_task.await;
log::info!("Danmu task aborted");
}
}
async fn update_entries(&self, live_id: &str) -> Result<(), RecorderError> {
@@ -255,13 +277,6 @@ impl DouyinRecorder {
*self.danmu_storage.write().await = danmu_storage;
// 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_task.lock().await.as_mut() {
danmu_stream_task.abort();
}
*self.live_id.write().await = live_id.to_string();
let self_clone = self.clone();
@@ -289,7 +304,7 @@ impl DouyinRecorder {
)
.await;
if let Err(e) = hls_recorder.start().await {
log::error!("[{}]Failed to start hls recorder: {}", self.room_id, e);
log::error!("[{}]Error from hls recorder: {}", self.room_id, e);
return Err(e);
}

View File

@@ -73,7 +73,7 @@ pub fn generate_user_agent_header() -> reqwest::header::HeaderMap {
pub async fn get_room_info(
client: &Client,
account: &Account,
room_id: i64,
room_id: &str,
sec_user_id: &str,
) -> Result<DouyinBasicRoomInfo, RecorderError> {
let mut headers = generate_user_agent_header();
@@ -140,7 +140,7 @@ pub async fn get_room_info(
pub async fn get_room_info_h5(
client: &Client,
account: &Account,
room_id: i64,
room_id: &str,
sec_user_id: &str,
) -> Result<DouyinBasicRoomInfo, RecorderError> {
// 参考biliup实现构建完整的URL参数
@@ -335,7 +335,7 @@ pub async fn get_user_info(
pub async fn get_room_owner_sec_uid(
client: &Client,
room_id: i64,
room_id: &str,
) -> Result<String, RecorderError> {
let url = format!("https://live.douyin.com/{room_id}");
let mut headers = generate_user_agent_header();
@@ -381,7 +381,9 @@ mod tests {
#[tokio::test]
async fn test_get_room_owner_sec_uid() {
let client = Client::new();
let sec_uid = get_room_owner_sec_uid(&client, 200525029536).await.unwrap();
let sec_uid = get_room_owner_sec_uid(&client, "200525029536")
.await
.unwrap();
assert_eq!(
sec_uid,
"MS4wLjABAAAAdFmmud36bynPjXOvoMjatb42856_zryHsGmlkpIECDA"

View File

@@ -70,7 +70,7 @@ pub async fn get_user_info(
pub async fn get_room_info(
client: &Client,
account: &Account,
room_id: i64,
room_id: &str,
) -> Result<(UserInfo, RoomInfo, StreamInfo), HuyaClientError> {
let mut headers = generate_user_agent_header();
if let Ok(cookies) = account.cookies.parse() {
@@ -142,7 +142,7 @@ mod tests {
let client = Client::new();
let account = Account::default();
let (user_info, room_info, stream_info) =
get_room_info(&client, &account, 599934).await.unwrap();
get_room_info(&client, &account, "599934").await.unwrap();
println!("{:?}", user_info);
println!("{:?}", room_info);
println!("{:?}", stream_info);

View File

@@ -30,7 +30,7 @@ pub struct HuyaExtra {
impl HuyaRecorder {
pub async fn new(
room_id: i64,
room_id: &str,
account: &Account,
cache_dir: PathBuf,
channel: broadcast::Sender<RecorderEvent>,
@@ -39,7 +39,7 @@ impl HuyaRecorder {
) -> Result<Self, crate::errors::RecorderError> {
Ok(Self {
platform: PlatformType::Huya,
room_id,
room_id: room_id.to_string(),
account: account.clone(),
client: reqwest::Client::new(),
event_channel: channel,
@@ -67,7 +67,7 @@ impl HuyaRecorder {
async fn check_status(&self) -> bool {
let pre_live_status = self.room_info.read().await.status;
match api::get_room_info(&self.client, &self.account, self.room_id).await {
match api::get_room_info(&self.client, &self.account, &self.room_id).await {
Ok((user_info, room_info, stream_info)) => {
let live_status = room_info.status;
@@ -79,7 +79,7 @@ impl HuyaRecorder {
// live status changed, reset current record flag
log::info!(
"[{}]Live status changed to {}, auto_start: {}",
self.room_id,
&self.room_id,
live_status,
self.enabled.load(atomic::Ordering::Relaxed)
);
@@ -91,7 +91,7 @@ impl HuyaRecorder {
} else {
let _ = self.event_channel.send(RecorderEvent::LiveEnd {
platform: PlatformType::Douyin,
room_id: self.room_id,
room_id: self.room_id.clone(),
recorder: self.info().await,
});
}
@@ -118,7 +118,7 @@ impl HuyaRecorder {
true
}
Err(e) => {
log::warn!("[{}]Update room status failed: {}", self.room_id, e);
log::warn!("[{}]Update room status failed: {}", &self.room_id, e);
pre_live_status
}
}
@@ -168,14 +168,14 @@ impl HuyaRecorder {
recorder: self.info().await,
});
log::debug!("[{}]Stream URL: {}", self.room_id, stream.hls_url);
log::debug!("[{}]Stream URL: {}", &self.room_id, stream.hls_url);
let hls_stream =
construct_stream_from_variant(live_id, &stream.hls_url, Format::TS, Codec::Avc)
.await
.map_err(|_| RecorderError::NoStreamAvailable)?;
let hls_recorder = HlsRecorder::new(
self.room_id.to_string(),
self.room_id.clone(),
Arc::new(hls_stream),
self.client.clone(),
Some(self.account.cookies.clone()),
@@ -186,7 +186,7 @@ impl HuyaRecorder {
.await;
if let Err(e) = hls_recorder.start().await {
log::error!("[{}]Failed to start hls recorder: {}", self.room_id, e);
log::error!("[{}]Failed to start hls recorder: {}", &self.room_id, e);
return Err(e);
}
@@ -208,7 +208,7 @@ impl crate::traits::RecorderTrait<HuyaExtra> for HuyaRecorder {
.store(true, atomic::Ordering::Relaxed);
let live_id = Utc::now().timestamp_millis().to_string();
if let Err(e) = self_clone.update_entries(&live_id).await {
log::error!("[{}]Update entries error: {}", self_clone.room_id, e);
log::error!("[{}]Update entries error: {}", &self_clone.room_id, e);
}
}
if self_clone.is_recording.load(atomic::Ordering::Relaxed) {
@@ -231,7 +231,7 @@ impl crate::traits::RecorderTrait<HuyaExtra> for HuyaRecorder {
))
.await;
}
log::info!("[{}]Recording thread quit.", self_clone.room_id);
log::info!("[{}]Recording thread quit.", &self_clone.room_id);
}));
}
}

View File

@@ -17,7 +17,7 @@ use tokio::{
#[allow(dead_code)]
pub trait RecorderBasicTrait<T> {
fn platform(&self) -> PlatformType;
fn room_id(&self) -> i64;
fn room_id(&self) -> String;
fn account(&self) -> &Account;
fn client(&self) -> &reqwest::Client;
fn event_channel(&self) -> &broadcast::Sender<RecorderEvent>;
@@ -62,7 +62,7 @@ pub trait RecorderTrait<T>: RecorderBasicTrait<T> {
}
async fn work_dir(&self, live_id: &str) -> CachePath {
CachePath::new(self.cache_dir(), self.platform(), self.room_id(), live_id)
CachePath::new(self.cache_dir(), self.platform(), &self.room_id(), live_id)
}
async fn info(&self) -> RecorderInfo {
let room_info = self.room_info().read().await.clone();

View File

@@ -1,19 +1,13 @@
use std::str::FromStr;
use recorder::account::Account;
use recorder::platforms::PlatformType;
use super::Database;
use super::DatabaseError;
use chrono::Utc;
use rand::seq::SliceRandom;
use rand::Rng;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct AccountRow {
pub platform: String,
pub uid: i64, // Keep for Bilibili compatibility
pub id_str: Option<String>, // New field for string IDs like Douyin sec_uid
pub uid: String,
pub name: String,
pub avatar: String,
pub csrf: String,
@@ -25,11 +19,7 @@ impl AccountRow {
pub fn to_account(&self) -> Account {
Account {
platform: self.platform.clone(),
id: if let Some(id) = self.id_str.as_ref() {
id.clone()
} else {
self.uid.to_string()
},
id: self.uid.clone(),
name: self.name.clone(),
avatar: self.avatar.clone(),
csrf: self.csrf.clone(),
@@ -41,99 +31,14 @@ impl 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, account: &AccountRow) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let platform = PlatformType::from_str(platform).unwrap();
sqlx::query("INSERT INTO accounts (uid, platform, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)").bind(&account.uid).bind(&account.platform).bind(&account.name).bind(&account.avatar).bind(&account.csrf).bind(&account.cookies).bind(&account.created_at).execute(&lock).await?;
let csrf = match platform {
PlatformType::BiliBili => {
cookies
.split(';')
.map(str::trim)
.find_map(|cookie| -> Option<String> {
if cookie.starts_with("bili_jct=") {
let var_name = &"bili_jct=";
Some(cookie[var_name.len()..].to_string())
} else {
None
}
})
}
_ => Some(String::new()),
};
if csrf.is_none() {
return Err(DatabaseError::InvalidCookies);
}
// parse uid and id_str based on platform
let (uid, id_str) = match platform {
PlatformType::BiliBili => {
// For Bilibili, extract numeric uid from cookies
let uid = (*cookies
.split("DedeUserID=")
.collect::<Vec<&str>>()
.get(1)
.unwrap()
.split(';')
.collect::<Vec<&str>>()
.first()
.unwrap())
.to_string()
.parse::<u64>()
.map_err(|_| DatabaseError::InvalidCookies)?;
(uid, None)
}
PlatformType::Douyin => {
// For Douyin, use temporary uid and will set id_str later with real sec_uid
// Fix: Generate a u32 within the desired range and then cast to u64 to avoid `clippy::cast-sign-loss`.
let temp_uid = rand::thread_rng().gen_range(10000u64..=i32::MAX as u64);
(temp_uid, Some(format!("temp_{temp_uid}")))
}
PlatformType::Huya => {
let uid = (*cookies
.split("yyuid=")
.collect::<Vec<&str>>()
.get(1)
.unwrap()
.split(';')
.collect::<Vec<&str>>()
.first()
.unwrap())
.to_string()
.parse::<u64>()
.map_err(|_| DatabaseError::InvalidCookies)?;
(uid, None)
}
PlatformType::Youtube => {
// unsupported
return Err(DatabaseError::InvalidCookies);
}
};
let uid = i64::try_from(uid).map_err(|_| DatabaseError::InvalidCookies)?;
let account = AccountRow {
platform: platform.as_str().to_string(),
uid,
id_str,
name: String::new(),
avatar: String::new(),
csrf: csrf.unwrap(),
cookies: cookies.into(),
created_at: Utc::now().to_rfc3339(),
};
sqlx::query("INSERT INTO accounts (uid, platform, id_str, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)").bind(uid).bind(&account.platform).bind(&account.id_str).bind(&account.name).bind(&account.avatar).bind(&account.csrf).bind(&account.cookies).bind(&account.created_at).execute(&lock).await?;
Ok(account)
Ok(())
}
pub async fn remove_account(&self, platform: &str, uid: i64) -> Result<(), DatabaseError> {
pub async fn remove_account(&self, platform: &str, uid: &str) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("DELETE FROM accounts WHERE uid = $1 and platform = $2")
.bind(uid)
@@ -146,75 +51,6 @@ impl Database {
Ok(())
}
pub async fn update_account(
&self,
platform: &str,
uid: i64,
name: &str,
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)
.bind(platform)
.execute(&lock)
.await?;
if sql.rows_affected() != 1 {
return Err(DatabaseError::NotFound);
}
Ok(())
}
pub async fn update_account_with_id_str(
&self,
old_account: &AccountRow,
new_id_str: &str,
name: &str,
avatar: &str,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
// If the id_str changed, we need to delete the old record and create a new one
if old_account.id_str.as_deref() == Some(new_id_str) {
// id_str is the same, just update name and avatar
sqlx::query(
"UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3 and platform = $4",
)
.bind(name)
.bind(avatar)
.bind(old_account.uid)
.bind(&old_account.platform)
.execute(&lock)
.await?;
} else {
// Delete the old record (for Douyin accounts, we use uid to identify)
sqlx::query("DELETE FROM accounts WHERE uid = $1 and platform = $2")
.bind(old_account.uid)
.bind(&old_account.platform)
.execute(&lock)
.await?;
// Insert the new record with updated id_str
sqlx::query("INSERT INTO accounts (uid, platform, id_str, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)")
.bind(old_account.uid)
.bind(&old_account.platform)
.bind(new_id_str)
.bind(name)
.bind(avatar)
.bind(&old_account.csrf)
.bind(&old_account.cookies)
.bind(&old_account.created_at)
.execute(&lock)
.await?;
}
Ok(())
}
pub async fn get_accounts(&self) -> Result<Vec<AccountRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts")
@@ -222,7 +58,11 @@ impl Database {
.await?)
}
pub async fn get_account(&self, platform: &str, uid: i64) -> Result<AccountRow, DatabaseError> {
pub async fn get_account(
&self,
platform: &str,
uid: &str,
) -> 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",

View File

@@ -9,7 +9,7 @@ pub struct RecordRow {
pub platform: String,
pub parent_id: String,
pub live_id: String,
pub room_id: i64,
pub room_id: String,
pub title: String,
pub length: i64,
pub size: i64,
@@ -17,11 +17,11 @@ pub struct RecordRow {
pub cover: Option<String>,
}
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, title TEXT, length INTEGER, size INTEGER, created_at TEXT);
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id TEXT, title TEXT, length INTEGER, size INTEGER, created_at TEXT);
impl Database {
pub async fn get_records(
&self,
room_id: i64,
room_id: &str,
offset: i64,
limit: i64,
) -> Result<Vec<RecordRow>, DatabaseError> {
@@ -38,7 +38,7 @@ impl Database {
pub async fn get_record(
&self,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<RecordRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
@@ -53,7 +53,7 @@ impl Database {
pub async fn get_archives_by_parent_id(
&self,
room_id: i64,
room_id: &str,
parent_id: &str,
) -> Result<Vec<RecordRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
@@ -71,7 +71,7 @@ impl Database {
platform: PlatformType,
parent_id: &str,
live_id: &str,
room_id: i64,
room_id: &str,
title: &str,
cover: Option<String>,
) -> Result<RecordRow, DatabaseError> {
@@ -80,7 +80,7 @@ impl Database {
platform: platform.as_str().to_string(),
parent_id: parent_id.to_string(),
live_id: live_id.to_string(),
room_id,
room_id: room_id.to_string(),
title: title.into(),
length: 0,
size: 0,
@@ -88,7 +88,7 @@ impl Database {
cover,
};
if let Err(e) = sqlx::query("INSERT INTO records (live_id, room_id, title, length, size, cover, created_at, platform, parent_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)").bind(record.live_id.clone())
.bind(record.room_id).bind(&record.title).bind(0).bind(0).bind(&record.cover).bind(&record.created_at).bind(platform.as_str().to_string()).bind(parent_id).execute(&lock).await {
.bind(&record.room_id).bind(&record.title).bind(0).bind(0).bind(&record.cover).bind(&record.created_at).bind(platform.as_str().to_string()).bind(parent_id).execute(&lock).await {
// if the record already exists, return the existing record
if e.to_string().contains("UNIQUE constraint failed") {
return self.get_record(room_id, live_id).await;
@@ -180,12 +180,12 @@ impl Database {
pub async fn get_recent_record(
&self,
room_id: i64,
room_id: &str,
offset: i64,
limit: i64,
) -> Result<Vec<RecordRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
if room_id == 0 {
if room_id.is_empty() {
Ok(sqlx::query_as::<_, RecordRow>(
"SELECT * FROM records ORDER BY created_at DESC LIMIT $1 OFFSET $2",
)

View File

@@ -6,7 +6,7 @@ use recorder::platforms::PlatformType;
/// because many room infos are collected in realtime
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct RecorderRow {
pub room_id: i64,
pub room_id: String,
pub created_at: String,
pub platform: String,
pub auto_start: bool,
@@ -18,12 +18,12 @@ impl Database {
pub async fn add_recorder(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
extra: &str,
) -> Result<RecorderRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let recorder = RecorderRow {
room_id,
room_id: room_id.to_string(),
created_at: Utc::now().to_rfc3339(),
platform: platform.as_str().to_string(),
auto_start: true,
@@ -42,7 +42,7 @@ impl Database {
Ok(recorder)
}
pub async fn remove_recorder(&self, room_id: i64) -> Result<RecorderRow, DatabaseError> {
pub async fn remove_recorder(&self, room_id: &str) -> Result<RecorderRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let recorder =
sqlx::query_as::<_, RecorderRow>("SELECT * FROM recorders WHERE room_id = $1")
@@ -71,7 +71,7 @@ impl Database {
.await?)
}
pub async fn remove_archive(&self, room_id: i64) -> Result<(), DatabaseError> {
pub async fn remove_archive(&self, room_id: &str) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let _ = sqlx::query("DELETE FROM records WHERE room_id = $1")
.bind(room_id)
@@ -83,7 +83,7 @@ impl Database {
pub async fn update_recorder(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
auto_start: bool,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();

View File

@@ -1,11 +1,11 @@
use super::Database;
use super::DatabaseError;
// CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id INTEGER, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT);
// CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id TEXT, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT);
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct VideoRow {
pub id: i64,
pub room_id: i64,
pub room_id: String,
pub cover: String,
pub file: String,
pub note: String,
@@ -22,7 +22,7 @@ pub struct VideoRow {
}
impl Database {
pub async fn get_videos(&self, room_id: i64) -> Result<Vec<VideoRow>, DatabaseError> {
pub async fn get_videos(&self, room_id: &str) -> Result<Vec<VideoRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let videos = sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
.bind(room_id)
@@ -69,7 +69,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, note, 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, $14)")
.bind(video.room_id)
.bind(&video.room_id)
.bind(&video.cover)
.bind(&video.file)
.bind(&video.note)

View File

@@ -6,7 +6,7 @@ use std::{
use async_ffmpeg_sidecar::{event::FfmpegEvent, log_parser::FfmpegLogParser};
use tokio::io::{AsyncWriteExt, BufReader};
use crate::progress::progress_reporter::ProgressReporterTrait;
use crate::{ffmpeg::hwaccel, progress::progress_reporter::ProgressReporterTrait};
use super::ffmpeg_path;
@@ -103,9 +103,10 @@ pub async fn concat_videos(
output_folder.join(&filelist_filename).to_str().unwrap(),
]);
if should_encode {
let video_encoder = hwaccel::get_x264_encoder().await;
ffmpeg_process.args(["-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2"]);
ffmpeg_process.args(["-r", "60"]);
ffmpeg_process.args(["-c", "libx264"]);
ffmpeg_process.args(["-c:v", video_encoder]);
ffmpeg_process.args(["-c:a", "aac"]);
ffmpeg_process.args(["-b:v", "6000k"]);
ffmpeg_process.args(["-b:a", "128k"]);

View File

@@ -0,0 +1,143 @@
use std::{collections::HashSet, process::Stdio};
use tokio::io::AsyncReadExt;
use super::ffmpeg_path;
const TARGET_ENCODERS: [&str; 7] = [
"h264_nvenc",
"h264_videotoolbox",
"h264_qsv",
"h264_amf",
"h264_mf",
"h264_vaapi",
"h264_v4l2m2m",
];
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
/// 检测当前环境下 FFmpeg 支持的 H.264 硬件编码器。
///
/// 返回值为可直接用于 `-c:v <value>` 的编码器名称列表。
pub async fn list_supported_hwaccels() -> Result<Vec<String>, String> {
let mut command = tokio::process::Command::new(ffmpeg_path());
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let mut child = command
.arg("-hide_banner")
.arg("-encoders")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("无法启动 ffmpeg 进程: {e}"))?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| "无法获取 ffmpeg 标准输出".to_string())?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| "无法获取 ffmpeg 标准错误输出".to_string())?;
let (mut stdout_buf, mut stderr_buf) = (String::new(), String::new());
let stdout_future = stdout.read_to_string(&mut stdout_buf);
let stderr_future = stderr.read_to_string(&mut stderr_buf);
let (stdout_res, stderr_res, status) = tokio::join!(stdout_future, stderr_future, child.wait());
stdout_res.map_err(|e| format!("读取 ffmpeg 标准输出失败: {e}"))?;
stderr_res.map_err(|e| format!("读取 ffmpeg 标准错误输出失败: {e}"))?;
let status = status.map_err(|e| format!("等待 ffmpeg 进程退出失败: {e}"))?;
if !status.success() {
let err = if stderr_buf.trim().is_empty() {
stdout_buf.trim().to_string()
} else {
stderr_buf.trim().to_string()
};
log::error!("ffmpeg -encoders 运行失败: {err}");
return Err(format!("ffmpeg -encoders 运行失败: {err}"));
}
let mut hwaccels = Vec::new();
for line in stdout_buf.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || !trimmed.starts_with('V') {
continue;
}
let mut parts = trimmed.split_whitespace();
let flags = parts.next().unwrap_or_default();
if !flags.starts_with('V') {
continue;
}
if let Some(name) = parts.next() {
if TARGET_ENCODERS
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(name))
{
hwaccels.push(name.to_string());
}
}
}
// 去重并保持原有顺序(即第一次出现时保留)
let mut seen = HashSet::new();
hwaccels.retain(|value| seen.insert(value.clone()));
Ok(hwaccels)
}
/// 依据优先级从支持列表中挑选推荐的硬件编码器。
///
/// 当前优先级顺序:`h264_nvenc` > `h264_videotoolbox` > `h264_qsv` > `h264_amf` > `h264_mf` > `h264_vaapi` > `h264_v4l2m2m`。
pub fn select_preferred_hwaccel(supported: &[String]) -> Option<&'static str> {
const PRIORITY: [&str; 7] = [
"h264_nvenc",
"h264_videotoolbox",
"h264_qsv",
"h264_amf",
"h264_mf",
"h264_vaapi",
"h264_v4l2m2m",
];
PRIORITY
.iter()
.find(|candidate| {
supported
.iter()
.any(|value| value.eq_ignore_ascii_case(candidate))
})
.copied()
}
/// Get the preferred hardware encoder for x264
///
/// Returns the preferred hardware encoder for x264, or "libx264" if no hardware acceleration is available.
pub async fn get_x264_encoder() -> &'static str {
let mut encoder = "libx264";
match list_supported_hwaccels().await {
Ok(hwaccels) => {
if let Some(arg) = select_preferred_hwaccel(&hwaccels) {
encoder = arg;
}
}
Err(err) => {
log::warn!("Failed to query hardware encoders: {err}");
}
}
log::info!("Selected x264 encoder: {encoder}");
encoder
}

View File

@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
pub mod general;
pub mod hwaccel;
pub mod playlist;
use crate::constants;
@@ -14,7 +15,7 @@ use crate::subtitle_generator::{
use async_ffmpeg_sidecar::event::{FfmpegEvent, LogLevel};
use async_ffmpeg_sidecar::log_parser::FfmpegLogParser;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncWriteExt, BufReader};
use tokio::io::BufReader;
// 视频元数据结构
#[derive(Debug, Clone, PartialEq)]
@@ -71,9 +72,10 @@ pub async fn transcode(
if copy_codecs {
ffmpeg_process.args(["-c:v", "copy"]).args(["-c:a", "copy"]);
} else {
let video_encoder = hwaccel::get_x264_encoder().await;
ffmpeg_process
.args(["-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2"])
.args(["-c:v", "libx264"])
.args(["-c:v", video_encoder])
.args(["-c:a", "aac"])
.args(["-b:v", "6000k"])
.args(["-b:a", "128k"])
@@ -396,10 +398,12 @@ pub async fn encode_video_subtitle(
#[cfg(target_os = "windows")]
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
let video_encoder = hwaccel::get_x264_encoder().await;
let child = ffmpeg_process
.args(["-i", file.to_str().unwrap()])
.args(["-vf", vf.as_str()])
.args(["-c:v", "libx264"])
.args(["-c:v", video_encoder])
.args(["-c:a", "copy"])
.args(["-b:v", "6000k"])
.args([output_path.to_str().unwrap()])
@@ -488,10 +492,12 @@ pub async fn encode_video_danmu(
#[cfg(target_os = "windows")]
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
let video_encoder = hwaccel::get_x264_encoder().await;
let child = ffmpeg_process
.args(["-i", file.to_str().unwrap()])
.args(["-vf", &format!("ass={subtitle}")])
.args(["-c:v", "libx264"])
.args(["-c:v", video_encoder])
.args(["-c:a", "copy"])
.args(["-b:v", "6000k"])
.args([output_file_path.to_str().unwrap()])
@@ -778,11 +784,13 @@ pub async fn clip_from_video_file(
#[cfg(target_os = "windows")]
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
let video_encoder = hwaccel::get_x264_encoder().await;
let child = ffmpeg_process
.args(["-i", &format!("{}", input_path.display())])
.args(["-ss", &start_time.to_string()])
.args(["-t", &duration.to_string()])
.args(["-c:v", "libx264"])
.args(["-c:v", video_encoder])
.args(["-c:a", "aac"])
.args(["-b:v", "6000k"])
.args(["-avoid_negative_ts", "make_zero"])
@@ -1150,124 +1158,6 @@ pub async fn check_videos(video_paths: &[&Path]) -> bool {
true
}
pub async fn convert_fmp4_to_ts_raw(
header_data: &[u8],
source_data: &[u8],
output_ts: &Path,
) -> Result<(), String> {
// Combine the data
let mut combined_data = header_data.to_vec();
combined_data.extend_from_slice(source_data);
// Build ffmpeg command to convert combined data to TS
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
#[cfg(target_os = "windows")]
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
let child = ffmpeg_process
.args(["-f", "mp4"])
.args(["-i", "-"]) // Read from stdin
.args(["-c:v", "libx264"])
.args(["-b:v", "6000k"])
.args(["-maxrate", "10000k"])
.args(["-bufsize", "16000k"])
.args(["-c:a", "copy"])
.args(["-threads", "0"])
.args(["-f", "mpegts"])
.args(["-y", output_ts.to_str().unwrap()]) // Overwrite output
.args(["-progress", "pipe:2"]) // Progress to stderr
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
if let Err(e) = child {
return Err(format!("Failed to spawn ffmpeg process: {e}"));
}
let mut child = child.unwrap();
// Write the combined data to stdin and close it
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(&combined_data)
.await
.map_err(|e| format!("Failed to write data to ffmpeg stdin: {e}"))?;
// stdin is automatically closed when dropped
}
// Parse ffmpeg output for progress and errors
let stderr = child.stderr.take().unwrap();
let reader = BufReader::new(stderr);
let mut parser = FfmpegLogParser::new(reader);
let mut conversion_error = None;
while let Ok(event) = parser.parse_next_event().await {
match event {
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(level, content) => {
if content.contains("error") || level == LogLevel::Error {
log::error!("fMP4 to TS conversion error: {content}");
}
}
FfmpegEvent::Error(e) => {
log::error!("fMP4 to TS conversion error: {e}");
conversion_error = Some(e.to_string());
}
_ => {}
}
}
// Wait for ffmpeg to complete
if let Err(e) = child.wait().await {
return Err(format!("ffmpeg process failed: {e}"));
}
// Check for conversion errors
if let Some(error) = conversion_error {
Err(error)
} else {
Ok(())
}
}
/// Convert fragmented MP4 (fMP4) files to MPEG-TS format
/// Combines an initialization segment (header) and a media segment (source) into a single TS file
///
/// # Arguments
/// * `header` - Path to the initialization segment (.mp4)
/// * `source` - Path to the media segment (.m4s)
///
/// # Returns
/// A `Result` indicating success or failure with error message
#[allow(unused)]
pub async fn convert_fmp4_to_ts(header: &Path, source: &Path) -> Result<(), String> {
log::info!(
"Converting fMP4 to TS: {} + {}",
header.display(),
source.display()
);
// Check if input files exist
if !header.exists() {
return Err(format!("Header file does not exist: {}", header.display()));
}
if !source.exists() {
return Err(format!("Source file does not exist: {}", source.display()));
}
let output_ts = source.with_extension("ts");
// Read the header and source files into memory
let header_data = tokio::fs::read(header)
.await
.map_err(|e| format!("Failed to read header file: {e}"))?;
let source_data = tokio::fs::read(source)
.await
.map_err(|e| format!("Failed to read source file: {e}"))?;
convert_fmp4_to_ts_raw(&header_data, &source_data, &output_ts).await
}
// tests
#[cfg(test)]
mod tests {
@@ -1399,6 +1289,42 @@ mod tests {
}
}
// 测试硬件加速能力探测
#[tokio::test]
async fn test_list_supported_hwaccels() {
match super::hwaccel::list_supported_hwaccels().await {
Ok(hwaccels) => {
println!("hwaccels: {:?}", hwaccels);
let mut sorted = hwaccels.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), hwaccels.len());
}
Err(_) => {
println!("FFmpeg hardware acceleration query not available for testing");
}
}
}
#[test]
fn test_select_preferred_hwaccel() {
let cases = vec![
(vec!["h264_nvenc", "h264_vaapi"], Some("h264_nvenc")),
(
vec!["h264_videotoolbox", "h264_qsv"],
Some("h264_videotoolbox"),
),
(vec!["h264_vaapi"], Some("h264_vaapi")),
(vec!["h264_v4l2m2m"], Some("h264_v4l2m2m")),
(vec!["libx264"], None),
];
for (inputs, expected) in cases {
let inputs = inputs.into_iter().map(String::from).collect::<Vec<_>>();
assert_eq!(super::hwaccel::select_preferred_hwaccel(&inputs), expected);
}
}
// 测试字幕生成错误处理
#[tokio::test]
async fn test_generate_video_subtitle_errors() {
@@ -1480,52 +1406,4 @@ mod tests {
assert!(chunk_dir.to_string_lossy().contains("_chunks"));
assert!(chunk_dir.to_string_lossy().contains("test"));
}
// 测试 fMP4 到 TS 转换
#[tokio::test]
async fn test_convert_fmp4_to_ts() {
let header_file = Path::new("tests/video/init.m4s");
let segment_file = Path::new("tests/video/segment.m4s");
let output_file = Path::new("tests/video/segment.ts");
// 如果测试文件存在,则进行转换测试
if header_file.exists() && segment_file.exists() {
let result = convert_fmp4_to_ts(header_file, segment_file).await;
// 检查转换是否成功
match result {
Ok(()) => {
// 检查输出文件是否创建
assert!(output_file.exists());
log::info!("fMP4 to TS conversion test passed");
// 清理测试文件
let _ = std::fs::remove_file(output_file);
}
Err(e) => {
log::error!("fMP4 to TS conversion test failed: {}", e);
// 对于测试文件不存在或其他错误,我们仍然认为测试通过
// 因为这不是功能性问题
}
}
} else {
log::info!("Test files not found, skipping fMP4 to TS conversion test");
}
}
// 测试 fMP4 到 TS 转换的错误处理
#[tokio::test]
async fn test_convert_fmp4_to_ts_error_handling() {
let non_existent_header = Path::new("tests/video/non_existent_init.mp4");
let non_existent_segment = Path::new("tests/video/non_existent_segment.m4s");
// 测试文件不存在的错误处理
let result = convert_fmp4_to_ts(non_existent_header, non_existent_segment).await;
assert!(result.is_err());
let error_msg = result.unwrap_err();
assert!(error_msg.contains("does not exist"));
log::info!("fMP4 to TS error handling test passed");
}
}

View File

@@ -1,8 +1,12 @@
use std::str::FromStr;
use crate::database::account::AccountRow;
use crate::state::State;
use crate::state_type;
use chrono::Utc;
use recorder::platforms::bilibili::api::{QrInfo, QrStatus};
use recorder::platforms::{bilibili, douyin};
use recorder::platforms::{bilibili, douyin, huya, PlatformType};
use recorder::UserInfo;
use hyper::header::HeaderValue;
#[cfg(feature = "gui")]
@@ -16,107 +20,163 @@ pub async fn get_accounts(state: state_type!()) -> Result<super::AccountInfo, St
Ok(account_info)
}
fn get_item_from_cookies(name: &str, cookies: &str) -> Result<String, String> {
Ok(cookies
.split(';')
.map(str::trim)
.find_map(|cookie| cookie.strip_prefix(format!("{name}=").as_str()))
.ok_or_else(|| format!("Invalid cookies: missing {name}").to_string())?
.to_string())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn add_account(
state: state_type!(),
platform: String,
cookies: &str,
) -> Result<AccountRow, String> {
) -> Result<(), String> {
// check if cookies is valid
if let Err(e) = cookies.parse::<HeaderValue>() {
return Err(format!("Invalid cookies: {e}"));
}
let account = state.db.add_account(&platform, cookies).await?;
let client = reqwest::Client::new();
if platform == "bilibili" {
let account_info =
match bilibili::api::get_user_info(&client, &account.to_account(), account.uid).await {
Ok(account_info) => account_info,
let platform = PlatformType::from_str(&platform).map_err(|_| "Invalid platform".to_string())?;
let csrf = match platform {
PlatformType::BiliBili => {
cookies
.split(';')
.map(str::trim)
.find_map(|cookie| -> Option<String> {
if cookie.starts_with("bili_jct=") {
let var_name = &"bili_jct=";
Some(cookie[var_name.len()..].to_string())
} else {
None
}
})
}
_ => Some(String::new()),
};
// fetch basic account user info
let client = reqwest::Client::new();
let user_info = match platform {
PlatformType::BiliBili => {
// For Bilibili, extract numeric uid from cookies
if csrf.is_none() {
return Err("Invalid bilibili cookies".to_string());
}
let uid = get_item_from_cookies("DedeUserID", cookies)?;
let tmp_account = AccountRow {
platform: platform.as_str().to_string(),
uid,
name: String::new(),
avatar: String::new(),
csrf: csrf.clone().unwrap(),
cookies: cookies.into(),
created_at: Utc::now().to_rfc3339(),
};
match bilibili::api::get_user_info(&client, &tmp_account.to_account(), &tmp_account.uid)
.await
{
Ok(user_info) => UserInfo {
user_id: user_info.user_id,
user_name: user_info.user_name,
user_avatar: user_info.user_avatar_url,
},
Err(e) => {
return Err(e.to_string());
}
};
state
.db
.update_account(
&platform,
account_info.user_id,
&account_info.user_name,
&account_info.user_avatar_url,
)
.await?;
return Ok(account);
}
if platform == "douyin" {
// Get user info from Douyin API
match douyin::api::get_user_info(&client, &account.to_account()).await {
Ok(user_info) => {
// For Douyin, use sec_uid as the primary identifier in id_str field
let avatar_url = user_info
.avatar_thumb
.url_list
.first()
.cloned()
.unwrap_or_default();
state
.db
.update_account_with_id_str(
&account,
&user_info.sec_uid,
&user_info.nickname,
&avatar_url,
)
.await?;
}
Err(e) => {
log::warn!("Failed to get Douyin user info: {e}");
// Keep the account but with default values
}
}
PlatformType::Douyin => {
let tmp_account = AccountRow {
platform: platform.as_str().to_string(),
uid: "".into(),
name: String::new(),
avatar: String::new(),
csrf: "".into(),
cookies: cookies.into(),
created_at: Utc::now().to_rfc3339(),
};
return Ok(account);
}
match douyin::api::get_user_info(&client, &tmp_account.to_account()).await {
Ok(user_info) => {
// For Douyin, use sec_uid as the primary identifier in id_str field
let avatar_url = user_info
.avatar_thumb
.url_list
.first()
.cloned()
.unwrap_or_default();
// if platform == "huya" {
// let huya_client = crate::recorder::huya::client::HuyaClient::new();
// match huya_client.get_user_info(&account).await {
// Ok(user_info) => {
// state
// .db
// .update_account(
// &platform,
// user_info.user_id,
// &user_info.user_name,
// &user_info.user_avatar_url,
// )
// .await?;
// }
// Err(e) => {
// log::warn!("Failed to get Huya user info: {e}");
// // Keep the account but with default values
// }
// }
// return Ok(account);
// }
UserInfo {
user_id: user_info.sec_uid,
user_name: user_info.nickname,
user_avatar: avatar_url,
}
}
Err(e) => {
return Err(format!("Failed to get Douyin user info: {e}"));
}
}
}
PlatformType::Huya => {
let user_id = get_item_from_cookies("yyuid", cookies)?;
todo!("unsupported platform: {platform}");
let tmp_account = AccountRow {
platform: platform.as_str().to_string(),
uid: user_id,
name: String::new(),
avatar: String::new(),
csrf: "".into(),
cookies: cookies.into(),
created_at: Utc::now().to_rfc3339(),
};
match huya::api::get_user_info(&client, &tmp_account.to_account()).await {
Ok(user_info) => UserInfo {
user_id: user_info.user_id,
user_name: user_info.user_name,
user_avatar: user_info.user_avatar,
},
Err(e) => {
return Err(format!("Failed to get Huya user info: {e}"));
}
}
}
PlatformType::Youtube => {
// unsupported
return Err("Unsupported platform".to_string());
}
};
let account = AccountRow {
platform: platform.as_str().to_string(),
uid: user_info.user_id,
name: user_info.user_name,
avatar: user_info.user_avatar,
csrf: csrf.unwrap(),
cookies: cookies.into(),
created_at: Utc::now().to_rfc3339(),
};
state.db.add_account(&account).await?;
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn remove_account(
state: state_type!(),
platform: String,
uid: i64,
uid: String,
) -> Result<(), String> {
if platform == "bilibili" {
let account = state.db.get_account(&platform, uid).await?;
let account = state.db.get_account(&platform, &uid).await?;
let client = reqwest::Client::new();
let _ = bilibili::api::logout(&client, &account.to_account()).await;
}
Ok(state.db.remove_account(&platform, uid).await?)
Ok(state.db.remove_account(&platform, &uid).await?)
}
#[cfg_attr(feature = "gui", tauri::command)]
@@ -141,3 +201,21 @@ pub async fn get_qr(_state: state_type!()) -> Result<QrInfo, ()> {
Err(_e) => Err(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_get_item_from_cookies() {
let cookies = "DedeUserID=1234567890; bili_jct=1234567890; yyuid=1234567890";
let uid = get_item_from_cookies("DedeUserID", cookies).unwrap();
assert_eq!(uid, "1234567890");
let uid = get_item_from_cookies("yyuid", cookies).unwrap();
assert_eq!(uid, "1234567890");
let uid = get_item_from_cookies("bili_jct", cookies).unwrap();
assert_eq!(uid, "1234567890");
let uid = get_item_from_cookies("unknown", cookies).unwrap_err();
assert_eq!(uid, "Invalid cookies: missing unknown");
}
}

View File

@@ -33,7 +33,7 @@ pub async fn get_recorder_list(state: state_type!()) -> Result<RecorderList, ()>
pub async fn add_recorder(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
mut extra: String,
) -> Result<RecorderRow, String> {
log::info!("Add recorder: {platform} {room_id}");
@@ -49,7 +49,7 @@ pub async fn add_recorder(
}
PlatformType::Douyin => {
let client = reqwest::Client::new();
let sec_uid = douyin::api::get_room_owner_sec_uid(&client, room_id)
let sec_uid = douyin::api::get_room_owner_sec_uid(&client, &room_id)
.await
.map_err(|e| e.to_string())?;
extra = sec_uid;
@@ -74,11 +74,11 @@ pub async fn add_recorder(
match account {
Ok(account) => match state
.recorder_manager
.add_recorder(&account, platform, room_id, &extra, true)
.add_recorder(&account, platform, &room_id, &extra, true)
.await
{
Ok(()) => {
let room = state.db.add_recorder(platform, room_id, &extra).await?;
let room = state.db.add_recorder(platform, &room_id, &extra).await?;
state
.db
.new_message("添加直播间", &format!("添加了新直播间 {room_id}"))
@@ -109,13 +109,13 @@ pub async fn add_recorder(
pub async fn remove_recorder(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
) -> Result<(), String> {
log::info!("Remove recorder: {platform} {room_id}");
let platform = PlatformType::from_str(&platform).unwrap();
match state
.recorder_manager
.remove_recorder(platform, room_id)
.remove_recorder(platform, &room_id)
.await
{
Ok(recorder) => {
@@ -145,12 +145,12 @@ pub async fn remove_recorder(
pub async fn get_room_info(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
) -> Result<RecorderInfo, String> {
let platform = PlatformType::from_str(&platform).unwrap();
if let Some(info) = state
.recorder_manager
.get_recorder_info(platform, room_id)
.get_recorder_info(platform, &room_id)
.await
{
Ok(info)
@@ -167,37 +167,37 @@ pub async fn get_archive_disk_usage(state: state_type!()) -> Result<i64, String>
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_archives(
state: state_type!(),
room_id: i64,
room_id: String,
offset: i64,
limit: i64,
) -> Result<Vec<RecordRow>, String> {
Ok(state
.recorder_manager
.get_archives(room_id, offset, limit)
.get_archives(&room_id, offset, limit)
.await?)
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_archive(
state: state_type!(),
room_id: i64,
room_id: String,
live_id: String,
) -> Result<RecordRow, String> {
Ok(state
.recorder_manager
.get_archive(room_id, &live_id)
.get_archive(&room_id, &live_id)
.await?)
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_archives_by_parent_id(
state: state_type!(),
room_id: i64,
room_id: String,
parent_id: String,
) -> Result<Vec<RecordRow>, String> {
Ok(state
.db
.get_archives_by_parent_id(room_id, &parent_id)
.get_archives_by_parent_id(&room_id, &parent_id)
.await?)
}
@@ -205,13 +205,13 @@ pub async fn get_archives_by_parent_id(
pub async fn get_archive_subtitle(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
live_id: String,
) -> Result<String, String> {
let platform = PlatformType::from_str(&platform)?;
Ok(state
.recorder_manager
.get_archive_subtitle(platform, room_id, &live_id)
.get_archive_subtitle(platform, &room_id, &live_id)
.await?)
}
@@ -219,13 +219,13 @@ pub async fn get_archive_subtitle(
pub async fn generate_archive_subtitle(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
live_id: String,
) -> Result<String, String> {
let platform = PlatformType::from_str(&platform)?;
Ok(state
.recorder_manager
.generate_archive_subtitle(platform, room_id, &live_id)
.generate_archive_subtitle(platform, &room_id, &live_id)
.await?)
}
@@ -233,13 +233,13 @@ pub async fn generate_archive_subtitle(
pub async fn delete_archive(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
live_id: String,
) -> Result<(), String> {
let platform = PlatformType::from_str(&platform)?;
let to_delete = state
.recorder_manager
.delete_archive(platform, room_id, &live_id)
.delete_archive(platform, &room_id, &live_id)
.await?;
state
.db
@@ -261,7 +261,7 @@ pub async fn delete_archive(
pub async fn delete_archives(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
live_ids: Vec<String>,
) -> Result<(), String> {
let platform = PlatformType::from_str(&platform)?;
@@ -269,7 +269,7 @@ pub async fn delete_archives(
.recorder_manager
.delete_archives(
platform,
room_id,
&room_id,
&live_ids
.iter()
.map(std::string::String::as_str)
@@ -298,13 +298,13 @@ pub async fn delete_archives(
pub async fn get_danmu_record(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
live_id: String,
) -> Result<Vec<DanmuEntry>, String> {
let platform = PlatformType::from_str(&platform)?;
Ok(state
.recorder_manager
.load_danmus(platform, room_id, &live_id)
.load_danmus(platform, &room_id, &live_id)
.await?)
}
@@ -312,7 +312,7 @@ pub async fn get_danmu_record(
#[serde(rename_all = "camelCase")]
pub struct ExportDanmuOptions {
platform: String,
room_id: i64,
room_id: String,
live_id: String,
x: i64,
y: i64,
@@ -327,7 +327,7 @@ pub async fn export_danmu(
let platform = PlatformType::from_str(&options.platform)?;
let mut danmus = state
.recorder_manager
.load_danmus(platform, options.room_id, &options.live_id)
.load_danmus(platform, &options.room_id, &options.live_id)
.await?;
log::debug!("First danmu entry: {:?}", danmus.first());
@@ -357,13 +357,13 @@ pub async fn export_danmu(
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn send_danmaku(
state: state_type!(),
uid: i64,
room_id: i64,
uid: String,
room_id: String,
message: String,
) -> Result<(), String> {
let account = state.db.get_account("bilibili", uid).await?;
let account = state.db.get_account("bilibili", &uid).await?;
let client = reqwest::Client::new();
match bilibili::api::send_danmaku(&client, &account.to_account(), room_id, &message).await {
match bilibili::api::send_danmaku(&client, &account.to_account(), &room_id, &message).await {
Ok(()) => Ok(()),
Err(e) => Err(e.to_string()),
}
@@ -388,11 +388,11 @@ pub async fn get_today_record_count(state: state_type!()) -> Result<i64, String>
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_recent_record(
state: state_type!(),
room_id: i64,
room_id: String,
offset: i64,
limit: i64,
) -> Result<Vec<RecordRow>, String> {
match state.db.get_recent_record(room_id, offset, limit).await {
match state.db.get_recent_record(&room_id, offset, limit).await {
Ok(records) => Ok(records),
Err(e) => Err(format!("Failed to get recent record: {e}")),
}
@@ -402,14 +402,14 @@ pub async fn get_recent_record(
pub async fn set_enable(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
enabled: bool,
) -> Result<(), String> {
log::info!("Set enable for recorder {platform} {room_id} {enabled}");
let platform = PlatformType::from_str(&platform)?;
state
.recorder_manager
.set_enable(platform, room_id, enabled)
.set_enable(platform, &room_id, enabled)
.await;
Ok(())
}
@@ -434,7 +434,7 @@ pub async fn generate_whole_clip(
state: state_type!(),
encode_danmu: bool,
platform: String,
room_id: i64,
room_id: String,
parent_id: String,
) -> Result<TaskRow, String> {
log::info!("Generate whole clip for {platform} {room_id} {parent_id}");
@@ -470,7 +470,7 @@ pub async fn generate_whole_clip(
tokio::spawn(async move {
match state_clone
.recorder_manager
.generate_whole_clip(Some(&reporter), encode_danmu, platform, room_id, parent_id)
.generate_whole_clip(Some(&reporter), encode_danmu, platform, &room_id, parent_id)
.await
{
Ok(()) => {

View File

@@ -216,7 +216,7 @@ pub async fn open_log_folder(state: state_type!()) -> Result<(), String> {
pub async fn open_live(
state: state_type!(),
platform: String,
room_id: i64,
room_id: String,
live_id: String,
) -> Result<(), String> {
log::info!("Open player window: {room_id} {live_id}");
@@ -303,6 +303,7 @@ pub async fn list_folder(_state: state_type!(), path: String) -> Result<Vec<Stri
}
#[cfg_attr(feature = "gui", tauri::command)]
#[allow(dead_code)]
pub async fn file_exists(_state: state_type!(), path: String) -> Result<bool, String> {
let path = PathBuf::from(path);
match std::fs::metadata(&path) {

View File

@@ -435,7 +435,7 @@ async fn clip_range_inner(
.add_video(&VideoRow {
id: 0,
status: 0,
room_id: params.room_id,
room_id: params.room_id.clone(),
created_at: Local::now().to_rfc3339(),
cover: cover_file
.file_name()
@@ -464,7 +464,7 @@ async fn clip_range_inner(
"生成新切片",
&format!(
"生成了房间 {} 的切片,长度 {}s{}",
params.room_id,
&params.room_id,
params
.range
.as_ref()
@@ -482,7 +482,7 @@ async fn clip_range_inner(
.title("BiliShadowReplay - 切片完成")
.body(format!(
"生成了房间 {} 的切片: {}",
params.room_id, filename
&params.room_id, filename
))
.show()
.unwrap();
@@ -497,8 +497,8 @@ async fn clip_range_inner(
pub async fn upload_procedure(
state: state_type!(),
event_id: String,
uid: i64,
room_id: i64,
uid: String,
room_id: String,
video_id: i64,
cover: String,
profile: Profile,
@@ -547,13 +547,13 @@ pub async fn upload_procedure(
async fn upload_procedure_inner(
state: &state_type!(),
reporter: &ProgressReporter,
uid: i64,
room_id: i64,
uid: String,
room_id: String,
video_id: i64,
cover: String,
mut profile: Profile,
) -> Result<String, String> {
let account = state.db.get_account("bilibili", uid).await?;
let account = state.db.get_account("bilibili", &uid).await?;
// get video info from dbs
let mut video_row = state.db.get_video(video_id).await?;
// construct file path
@@ -625,10 +625,10 @@ 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: i64) -> Result<Vec<VideoRow>, String> {
pub async fn get_videos(state: state_type!(), room_id: String) -> Result<Vec<VideoRow>, String> {
state
.db
.get_videos(room_id)
.get_videos(&room_id)
.await
.map_err(|e| e.to_string())
}
@@ -949,7 +949,7 @@ pub async fn import_external_video(
event_id: String,
file_path: String,
title: String,
room_id: i64,
room_id: String,
) -> Result<VideoRow, String> {
#[cfg(feature = "gui")]
let emitter = EventEmitter::new(state.app_handle.clone());
@@ -1293,7 +1293,7 @@ pub async fn batch_import_external_videos(
state: state_type!(),
event_id: String,
file_paths: Vec<String>,
room_id: i64,
room_id: String,
) -> Result<BatchImportResult, String> {
if file_paths.is_empty() {
return Ok(BatchImportResult {
@@ -1343,7 +1343,7 @@ pub async fn batch_import_external_videos(
file_event_id,
file_path.clone(),
title,
room_id,
room_id.clone(),
)
.await
{

View File

@@ -6,8 +6,8 @@ use std::{
use crate::{
config::Config,
database::{
account::AccountRow, message::MessageRow, record::RecordRow, recorder::RecorderRow,
task::TaskRow, video::VideoRow,
message::MessageRow, record::RecordRow, recorder::RecorderRow, task::TaskRow,
video::VideoRow,
},
handlers::{
account::{
@@ -137,17 +137,16 @@ struct AddAccountRequest {
async fn handler_add_account(
state: axum::extract::State<State>,
Json(param): Json<AddAccountRequest>,
) -> Result<Json<ApiResponse<AccountRow>>, ApiError> {
let mut account = add_account(state.0, param.platform, &param.cookies).await?;
account.cookies = "".to_string();
Ok(Json(ApiResponse::success(account)))
) -> Result<Json<ApiResponse<()>>, ApiError> {
add_account(state.0, param.platform, &param.cookies).await?;
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemoveAccountRequest {
platform: String,
uid: i64,
uid: String,
}
async fn handler_remove_account(
@@ -449,7 +448,7 @@ async fn handler_get_recorder_list(
#[serde(rename_all = "camelCase")]
struct AddRecorderRequest {
platform: String,
room_id: i64,
room_id: String,
extra: String,
}
@@ -467,7 +466,7 @@ async fn handler_add_recorder(
#[serde(rename_all = "camelCase")]
struct RemoveRecorderRequest {
platform: String,
room_id: i64,
room_id: String,
}
async fn handler_remove_recorder(
@@ -484,7 +483,7 @@ async fn handler_remove_recorder(
#[serde(rename_all = "camelCase")]
struct GetRoomInfoRequest {
platform: String,
room_id: i64,
room_id: String,
}
async fn handler_get_room_info(
@@ -505,7 +504,7 @@ async fn handler_get_archive_disk_usage(
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GetArchivesRequest {
room_id: i64,
room_id: String,
offset: i64,
limit: i64,
}
@@ -521,7 +520,7 @@ async fn handler_get_archives(
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GetArchiveRequest {
room_id: i64,
room_id: String,
live_id: String,
}
@@ -537,7 +536,7 @@ async fn handler_get_archive(
#[serde(rename_all = "camelCase")]
struct GetArchiveSubtitleRequest {
platform: String,
room_id: i64,
room_id: String,
live_id: String,
}
@@ -554,7 +553,7 @@ async fn handler_get_archive_subtitle(
#[serde(rename_all = "camelCase")]
struct GenerateArchiveSubtitleRequest {
platform: String,
room_id: i64,
room_id: String,
live_id: String,
}
@@ -571,7 +570,7 @@ async fn handler_generate_archive_subtitle(
#[serde(rename_all = "camelCase")]
struct DeleteArchiveRequest {
platform: String,
room_id: i64,
room_id: String,
live_id: String,
}
@@ -587,7 +586,7 @@ async fn handler_delete_archive(
#[serde(rename_all = "camelCase")]
struct DeleteArchivesRequest {
platform: String,
room_id: i64,
room_id: String,
live_ids: Vec<String>,
}
@@ -603,7 +602,7 @@ async fn handler_delete_archives(
#[serde(rename_all = "camelCase")]
struct GetDanmuRecordRequest {
platform: String,
room_id: i64,
room_id: String,
live_id: String,
}
@@ -619,8 +618,8 @@ async fn handler_get_danmu_record(
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendDanmakuRequest {
uid: i64,
room_id: i64,
uid: String,
room_id: String,
message: String,
}
@@ -649,7 +648,7 @@ async fn handler_get_today_record_count(
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GetRecentRecordRequest {
room_id: i64,
room_id: String,
offset: i64,
limit: i64,
}
@@ -667,7 +666,7 @@ async fn handler_get_recent_record(
#[serde(rename_all = "camelCase")]
struct SetEnableRequest {
platform: String,
room_id: i64,
room_id: String,
enabled: bool,
}
@@ -698,8 +697,8 @@ async fn handler_clip_range(
#[serde(rename_all = "camelCase")]
struct UploadProcedureRequest {
event_id: String,
uid: i64,
room_id: i64,
uid: String,
room_id: String,
video_id: i64,
cover: String,
profile: Profile,
@@ -753,7 +752,7 @@ async fn handler_get_video(
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GetVideosRequest {
room_id: i64,
room_id: String,
}
async fn handler_get_videos(
@@ -962,7 +961,7 @@ struct ImportExternalVideoRequest {
event_id: String,
file_path: String,
title: String,
room_id: i64,
room_id: String,
}
async fn handler_import_external_video(
@@ -1017,7 +1016,7 @@ struct GetFileSizeRequest {
struct GenerateWholeClipRequest {
encode_danmu: bool,
platform: String,
room_id: i64,
room_id: String,
parent_id: String,
}
@@ -1039,7 +1038,7 @@ async fn handler_generate_whole_clip(
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GetArchivesByParentIdRequest {
room_id: i64,
room_id: String,
parent_id: String,
}
@@ -1071,7 +1070,7 @@ async fn handler_update_danmu_ass_options(
struct BatchImportExternalVideosRequest {
event_id: String,
file_paths: Vec<String>,
room_id: i64,
room_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -1238,7 +1237,7 @@ async fn handler_upload_and_import_files(
mut multipart: Multipart,
) -> Result<Json<ApiResponse<UploadAndImportResponse>>, ApiError> {
let mut uploaded_files = Vec::new();
let mut room_id = 0i64;
let mut room_id = "".to_string();
let upload_dir = std::env::temp_dir().join("bsr_uploads");
// 确保上传目录存在
@@ -1252,8 +1251,7 @@ async fn handler_upload_and_import_files(
match name {
"room_id" => {
// 读取房间ID
let text = field.text().await.map_err(|e| e.to_string())?;
room_id = text.parse().unwrap_or(0);
room_id = field.text().await.map_err(|e| e.to_string())?;
}
"files" => {
// 处理文件上传

View File

@@ -197,6 +197,198 @@ fn get_migrations() -> Vec<Migration> {
sql: r"ALTER TABLE records ADD COLUMN parent_id TEXT;",
kind: MigrationKind::Up,
},
// change records table primary key to (parent_id, live_id)
Migration {
version: 10,
description: "change_records_primary_key",
sql: r"
CREATE TABLE records_new (
parent_id TEXT NOT NULL DEFAULT '',
live_id TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'bilibili',
room_id TEXT,
title TEXT,
length INTEGER,
size INTEGER,
cover BLOB,
created_at TEXT,
PRIMARY KEY (parent_id, live_id)
);
INSERT INTO records_new (
parent_id,
live_id,
platform,
room_id,
title,
length,
size,
cover,
created_at
)
SELECT
COALESCE(parent_id, live_id) AS parent_id,
live_id,
platform,
CAST(room_id AS TEXT),
title,
length,
size,
cover,
created_at
FROM records;
DROP TABLE records;
ALTER TABLE records_new RENAME TO records;
CREATE INDEX IF NOT EXISTS idx_records_live_id ON records (room_id, live_id);
CREATE INDEX IF NOT EXISTS idx_records_created_at ON records (room_id, created_at);
",
kind: MigrationKind::Up,
},
// convert room_id columns to TEXT across tables
Migration {
version: 11,
description: "convert_room_id_to_text",
sql: r"
CREATE TABLE recorders_new (
room_id TEXT PRIMARY KEY,
platform TEXT NOT NULL DEFAULT 'bilibili',
created_at TEXT,
auto_start INTEGER NOT NULL DEFAULT 1,
extra TEXT
);
INSERT INTO recorders_new (
room_id,
platform,
created_at,
auto_start,
extra
)
SELECT
CAST(room_id AS TEXT),
platform,
created_at,
COALESCE(auto_start, 1),
extra
FROM recorders;
DROP TABLE recorders;
ALTER TABLE recorders_new RENAME TO recorders;
CREATE TABLE danmu_statistics_new (
live_id TEXT PRIMARY KEY,
room_id TEXT,
value INTEGER,
time_point TEXT
);
INSERT INTO danmu_statistics_new (
live_id,
room_id,
value,
time_point
)
SELECT
live_id,
CAST(room_id AS TEXT),
value,
time_point
FROM danmu_statistics;
DROP TABLE danmu_statistics;
ALTER TABLE danmu_statistics_new RENAME TO danmu_statistics;
CREATE TABLE videos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT,
cover TEXT,
file TEXT,
length INTEGER,
size INTEGER,
status INTEGER,
bvid TEXT,
title TEXT,
desc TEXT,
tags TEXT,
area INTEGER,
created_at TEXT,
platform TEXT,
note TEXT
);
INSERT INTO videos_new (
id,
room_id,
cover,
file,
length,
size,
status,
bvid,
title,
desc,
tags,
area,
created_at,
platform,
note
)
SELECT
id,
CAST(room_id AS TEXT),
cover,
file,
length,
size,
status,
bvid,
title,
desc,
tags,
area,
created_at,
platform,
note
FROM videos;
DROP TABLE videos;
ALTER TABLE videos_new RENAME TO videos;
CREATE INDEX IF NOT EXISTS idx_videos_room_id ON videos (room_id);
CREATE INDEX IF NOT EXISTS idx_videos_created_at ON videos (created_at);
",
kind: MigrationKind::Up,
},
Migration {
version: 12,
description: "convert_account_uid_to_text",
sql: r"
CREATE TABLE accounts_new (
uid TEXT,
platform TEXT NOT NULL DEFAULT 'bilibili',
name TEXT,
avatar TEXT,
csrf TEXT,
cookies TEXT,
created_at TEXT,
PRIMARY KEY (uid, platform)
);
INSERT INTO accounts_new (
uid,
platform,
name,
avatar,
csrf,
cookies,
created_at
)
SELECT
COALESCE(NULLIF(id_str, ''), CAST(uid AS TEXT)),
platform,
name,
avatar,
csrf,
cookies,
created_at
FROM accounts;
DROP TABLE accounts;
ALTER TABLE accounts_new RENAME TO accounts;
",
kind: MigrationKind::Up,
},
]
}

View File

@@ -24,7 +24,7 @@ pub async fn try_rebuild_archives(
let live_id = file.file_name();
let live_id = live_id.to_str().unwrap();
// check if live_id is in db
let record = db.get_record(room_id, live_id).await;
let record = db.get_record(&room_id, live_id).await;
if record.is_ok() {
continue;
}
@@ -39,7 +39,7 @@ pub async fn try_rebuild_archives(
})?,
live_id,
live_id,
room_id,
&room_id,
&format!("UnknownLive {live_id}"),
None,
)
@@ -60,7 +60,7 @@ pub async fn try_convert_live_covers(
for room in rooms {
let room_id = room.room_id;
let room_cache_path = cache_path.join(format!("{}/{}", room.platform, room_id));
let records = db.get_records(room_id, 0, 999_999_999).await?;
let records = db.get_records(&room_id, 0, 999_999_999).await?;
for record in &records {
let record_path = room_cache_path.join(record.live_id.clone());
let cover = record.cover.clone();
@@ -129,7 +129,7 @@ pub async fn try_add_parent_id_to_records(
) -> Result<(), Box<dyn std::error::Error>> {
let rooms = db.get_recorders().await?;
for room in &rooms {
let records = db.get_records(room.room_id, 0, 999_999_999).await?;
let records = db.get_records(&room.room_id, 0, 999_999_999).await?;
for record in &records {
if record.parent_id.is_empty() {
db.update_record_parent_id(record.live_id.as_str(), record.live_id.as_str())
@@ -146,7 +146,7 @@ pub async fn try_convert_entry_to_m3u8(
) -> Result<(), Box<dyn std::error::Error>> {
let rooms = db.get_recorders().await?;
for room in &rooms {
let records = db.get_records(room.room_id, 0, 999_999_999).await?;
let records = db.get_records(&room.room_id, 0, 999_999_999).await?;
for record in &records {
let record_path = cache_path.join(format!(
"{}/{}/{}",

View File

@@ -52,7 +52,7 @@ pub struct ClipRangeParams {
pub note: String,
pub cover: String,
pub platform: String,
pub room_id: i64,
pub room_id: String,
pub live_id: String,
pub range: Option<Range>,
/// Encode danmu after clip
@@ -133,9 +133,9 @@ pub struct RecorderManager {
#[derive(Error, Debug)]
pub enum RecorderManagerError {
#[error("Recorder already exists: {room_id}")]
AlreadyExisted { room_id: i64 },
AlreadyExisted { room_id: String },
#[error("Recorder not found: {room_id}")]
NotFound { room_id: i64 },
NotFound { room_id: String },
#[error("Invalid platform type: {platform}")]
InvalidPlatformType { platform: String },
#[error("Recorder error: {0}")]
@@ -244,7 +244,7 @@ impl RecorderManager {
Payload::Room(recorder.clone()),
);
let _ = self.webhook_poster.post_event(&event).await;
self.handle_live_end(platform, room_id, &recorder).await;
self.handle_live_end(platform, &room_id, &recorder).await;
if self.config.read().await.live_end_notify {
#[cfg(feature = "gui")]
self.app_handle
@@ -262,7 +262,7 @@ impl RecorderManager {
RecorderEvent::RecordStart { recorder } => {
// add record entry into db
let platform = PlatformType::from_str(&recorder.room_info.platform).unwrap();
let room_id = recorder.room_info.room_id.parse::<i64>().unwrap();
let room_id = recorder.room_info.room_id.clone();
log::info!("Record start: {recorder:?}");
if let Err(e) = self
.db
@@ -270,7 +270,7 @@ impl RecorderManager {
platform,
&recorder.platform_live_id,
&recorder.live_id,
room_id,
&room_id,
&recorder.room_info.room_title,
None,
)
@@ -321,7 +321,12 @@ impl RecorderManager {
}
}
async fn handle_live_end(&self, platform: PlatformType, room_id: i64, recorder: &RecorderInfo) {
async fn handle_live_end(
&self,
platform: PlatformType,
room_id: &str,
recorder: &RecorderInfo,
) {
if !self.config.read().await.auto_generate.enabled {
return;
}
@@ -442,14 +447,14 @@ impl RecorderManager {
if !self.recorders.read().await.contains_key(&recorder_id)
&& !self.to_remove.read().await.contains(&recorder_id)
{
recorders_to_add.push((*platform, *room_id));
recorders_to_add.push((*platform, room_id.clone()));
}
}
for (platform, room_id) in recorders_to_add {
if self.is_migrating.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
let (auto_start, extra) = recorder_map.get(&(platform, room_id)).unwrap();
let (auto_start, extra) = recorder_map.get(&(platform, room_id.clone())).unwrap();
let account = self
.db
.get_account_by_platform(platform.clone().as_str())
@@ -465,7 +470,7 @@ impl RecorderManager {
};
if let Err(e) = self
.add_recorder(&account, platform, room_id, extra, *auto_start)
.add_recorder(&account, platform, &room_id, extra, *auto_start)
.await
{
log::error!(
@@ -484,13 +489,15 @@ impl RecorderManager {
&self,
account: &Account,
platform: PlatformType,
room_id: i64,
room_id: &str,
extra: &str,
enabled: bool,
) -> Result<(), RecorderManagerError> {
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if self.recorders.read().await.contains_key(&recorder_id) {
return Err(RecorderManagerError::AlreadyExisted { room_id });
return Err(RecorderManagerError::AlreadyExisted {
room_id: room_id.to_string(),
});
}
let cache_dir = self.config.read().await.cache.clone();
@@ -565,12 +572,14 @@ impl RecorderManager {
pub async fn remove_recorder(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
) -> Result<RecorderRow, RecorderManagerError> {
// check recorder exists
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if !self.recorders.read().await.contains_key(&recorder_id) {
return Err(RecorderManagerError::NotFound { room_id });
return Err(RecorderManagerError::NotFound {
room_id: room_id.to_string(),
});
}
// remove from db
@@ -611,14 +620,14 @@ impl RecorderManager {
async fn load_playlist_bytes(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<Vec<u8>, RecorderManagerError> {
let cache_path = self.config.read().await.cache.clone();
let cache_path = Path::new(&cache_path);
let playlist_path = cache_path
.join(platform.as_str())
.join(room_id.to_string())
.join(room_id)
.join(live_id)
.join("playlist.m3u8");
if !playlist_path.exists() {
@@ -646,7 +655,7 @@ impl RecorderManager {
async fn is_outdated_playlist(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> bool {
// check current recorder live id is the same as the live id
@@ -665,7 +674,7 @@ impl RecorderManager {
async fn load_playlist(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<MediaPlaylist, RecorderManagerError> {
let bytes = self.load_playlist_bytes(platform, room_id, live_id).await?;
@@ -707,7 +716,7 @@ impl RecorderManager {
async fn first_segment_timestamp(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<i64, RecorderManagerError> {
let playlist = self.load_playlist(platform, room_id, live_id).await?;
@@ -745,14 +754,14 @@ impl RecorderManager {
pub async fn load_danmus(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<Vec<DanmuEntry>, RecorderManagerError> {
let cache_path = self.config.read().await.cache.clone();
let cache_path = Path::new(&cache_path);
let danmus_path = cache_path
.join(platform.as_str())
.join(room_id.to_string())
.join(room_id)
.join(live_id)
.join("danmu.txt");
if !danmus_path.exists() {
@@ -772,7 +781,7 @@ impl RecorderManager {
async fn get_related_playlists(
&self,
platform: &PlatformType,
room_id: i64,
room_id: &str,
parent_id: &str,
) -> Vec<RelatedPlaylist> {
let cache_path = self.config.read().await.cache.clone();
@@ -823,7 +832,7 @@ impl RecorderManager {
let cache_path = Path::new(&cache_path);
let playlist_path = cache_path
.join(params.platform.clone())
.join(params.room_id.to_string())
.join(params.room_id.clone())
.join(params.live_id.clone())
.join("playlist.m3u8");
@@ -869,11 +878,11 @@ impl RecorderManager {
});
};
let stream_start_timestamp_milis = self
.first_segment_timestamp(platform, params.room_id, &params.live_id)
.first_segment_timestamp(platform, &params.room_id, &params.live_id)
.await?;
let danmus = self
.load_danmus(platform, params.room_id, &params.live_id)
.load_danmus(platform, &params.room_id, &params.live_id)
.await;
if danmus.is_err() {
log::error!(
@@ -941,7 +950,7 @@ impl RecorderManager {
async fn generate_archive_danmu_ass(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<PathBuf, RecorderManagerError> {
log::info!(
@@ -1038,7 +1047,7 @@ impl RecorderManager {
pub async fn get_recorder_info(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
) -> Option<RecorderInfo> {
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
@@ -1055,7 +1064,7 @@ impl RecorderManager {
pub async fn get_archives(
&self,
room_id: i64,
room_id: &str,
offset: i64,
limit: i64,
) -> Result<Vec<RecordRow>, RecorderManagerError> {
@@ -1064,7 +1073,7 @@ impl RecorderManager {
pub async fn get_archive(
&self,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<RecordRow, RecorderManagerError> {
Ok(self.db.get_record(room_id, live_id).await?)
@@ -1073,7 +1082,7 @@ impl RecorderManager {
pub async fn get_archive_subtitle(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<String, RecorderManagerError> {
// read subtitle file under work_dir
@@ -1100,7 +1109,7 @@ impl RecorderManager {
pub async fn generate_archive_subtitle(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<String, RecorderManagerError> {
// generate subtitle file under work_dir
@@ -1181,14 +1190,14 @@ impl RecorderManager {
pub async fn delete_archive(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_id: &str,
) -> Result<RecordRow, RecorderManagerError> {
log::info!("Deleting archive {room_id}:{live_id}");
let to_delete = self.db.remove_record(live_id).await?;
let cache_folder = Path::new(self.config.read().await.cache.as_str())
.join(platform.as_str())
.join(room_id.to_string())
.join(room_id)
.join(live_id);
let _ = tokio::fs::remove_dir_all(cache_folder).await;
Ok(to_delete)
@@ -1197,7 +1206,7 @@ impl RecorderManager {
pub async fn delete_archives(
&self,
platform: PlatformType,
room_id: i64,
room_id: &str,
live_ids: &[&str],
) -> Result<Vec<RecordRow>, RecorderManagerError> {
log::info!("Deleting archives in batch: {live_ids:?}");
@@ -1224,7 +1233,7 @@ impl RecorderManager {
// parse recorder type
let platform = path_segs[0];
// parse room id
let room_id = path_segs[1].parse::<i64>().unwrap();
let room_id = path_segs[1];
// parse live id
let live_id = path_segs[2];
@@ -1299,7 +1308,7 @@ impl RecorderManager {
}
}
pub async fn set_enable(&self, platform: PlatformType, room_id: i64, enabled: bool) {
pub async fn set_enable(&self, platform: PlatformType, room_id: &str, enabled: bool) {
// update RecordRow auto_start field
if let Err(e) = self.db.update_recorder(platform, room_id, enabled).await {
log::error!("Failed to update recorder auto_start: {e}");
@@ -1320,7 +1329,7 @@ impl RecorderManager {
reporter: Option<&ProgressReporter>,
encode_danmu: bool,
platform: String,
room_id: i64,
room_id: &str,
parent_id: String,
) -> Result<(), RecorderManagerError> {
let platform = PlatformType::from_str(&platform).map_err(|_| {
@@ -1353,7 +1362,7 @@ impl RecorderManager {
futures::future::join_all(danmu_ass_files).await
} else {
Vec::new()
vec![None; playlists.len()]
};
let timestamp = chrono::Local::now().format("%Y%m%d%H%M%S").to_string();
@@ -1412,7 +1421,7 @@ impl RecorderManager {
.add_video(&VideoRow {
id: 0,
status: 0,
room_id,
room_id: room_id.to_string(),
created_at: chrono::Local::now().to_rfc3339(),
cover: cover_filename.to_string_lossy().to_string(),
file: output_filename.to_string_lossy().to_string(),

View File

@@ -8,7 +8,7 @@
let video: VideoItem | null = null;
let videos: any[] = [];
let showVideoPreview = false;
let roomId: number | null = null;
let roomId: string = "";
let config: Config = null;
@@ -26,7 +26,7 @@
// update window title to file name
set_title((videoData as VideoItem).file);
// 获取房间下的所有视频列表
if (roomId !== null && roomId !== undefined) {
if (roomId.length > 0) {
const videoList = (await invoke("get_videos", {
roomId: roomId,
})) as VideoItem[];
@@ -68,7 +68,7 @@
}
async function handleVideoListUpdate() {
if (roomId !== null && roomId !== undefined) {
if (roomId.length > 0) {
const videosData = await invoke("get_videos", { roomId });
videos = await Promise.all(
(videosData as VideoItem[]).map(async (v) => {
@@ -85,7 +85,7 @@
}
</script>
{#if showVideoPreview && video && roomId !== null && roomId !== undefined}
{#if showVideoPreview && video && roomId.length > 0}
<VideoPreview
bind:show={showVideoPreview}
{video}

View File

@@ -25,7 +25,7 @@
import { onDestroy, onMount } from "svelte";
const urlParams = new URLSearchParams(window.location.search);
const room_id = parseInt(urlParams.get("room_id"));
const room_id = urlParams.get("room_id");
const platform = urlParams.get("platform");
const live_id = urlParams.get("live_id");
const focus_start = parseInt(urlParams.get("start") || "0");

View File

@@ -62,7 +62,7 @@ const add_recorder = tool(
extra,
}: {
platform: string;
room_id: number;
room_id: string;
extra: string;
}) => {
const result = await invoke("add_recorder", {
@@ -81,7 +81,7 @@ const add_recorder = tool(
.describe(
`The platform of the recorder. Can be ${platform_list.join(", ")}`
),
room_id: z.number().describe("The room id of the recorder"),
room_id: z.string().describe("The room id of the recorder"),
extra: z
.string()
.describe(
@@ -93,7 +93,7 @@ const add_recorder = tool(
// @ts-ignore
const remove_recorder = tool(
async ({ platform, room_id }: { platform: string; room_id: number }) => {
async ({ platform, room_id }: { platform: string; room_id: string }) => {
const result = await invoke("remove_recorder", {
platform,
roomId: room_id,
@@ -109,7 +109,7 @@ const remove_recorder = tool(
.describe(
`The platform of the recorder. Can be ${platform_list.join(", ")}`
),
room_id: z.number().describe("The room id of the recorder"),
room_id: z.string().describe("The room id of the recorder"),
}),
}
);
@@ -129,7 +129,7 @@ const get_recorder_list = tool(
// @ts-ignore
const get_recorder_info = tool(
async ({ platform, room_id }: { platform: string; room_id: number }) => {
async ({ platform, room_id }: { platform: string; room_id: string }) => {
const result = await invoke("get_room_info", { platform, roomId: room_id });
return result;
},
@@ -138,7 +138,7 @@ const get_recorder_info = tool(
description: "Get the info of a recorder",
schema: z.object({
platform: z.string().describe("The platform of the room"),
room_id: z.number().describe("The room id of the room"),
room_id: z.string().describe("The room id of the room"),
}),
}
);
@@ -150,7 +150,7 @@ const get_archives = tool(
offset,
limit,
}: {
room_id: number;
room_id: string;
offset: number;
limit: number;
}) => {
@@ -174,7 +174,7 @@ const get_archives = tool(
name: "get_archives",
description: "Get the list of all archives of a recorder",
schema: z.object({
room_id: z.number().describe("The room id of the recorder"),
room_id: z.string().describe("The room id of the recorder"),
offset: z.number().describe("The offset of the archives"),
limit: z.number().describe("The limit of the archives"),
}),
@@ -183,7 +183,7 @@ const get_archives = tool(
// @ts-ignore
const get_archive = tool(
async ({ room_id, live_id }: { room_id: number; live_id: string }) => {
async ({ room_id, live_id }: { room_id: string; live_id: string }) => {
const result = (await invoke("get_archive", {
roomId: room_id,
liveId: live_id,
@@ -199,7 +199,7 @@ const get_archive = tool(
name: "get_archive",
description: "Get the info of a archive",
schema: z.object({
room_id: z.number().describe("The room id of the recorder"),
room_id: z.string().describe("The room id of the recorder"),
live_id: z.string().describe("The live id of the archive"),
}),
}
@@ -213,7 +213,7 @@ const delete_archive = tool(
live_id,
}: {
platform: string;
room_id: number;
room_id: string;
live_id: string;
}) => {
const result = await invoke("delete_archive", {
@@ -232,7 +232,7 @@ const delete_archive = tool(
.describe(
`The platform of the recorder. Can be ${platform_list.join(", ")}`
),
room_id: z.number().describe("The room id of the recorder"),
room_id: z.string().describe("The room id of the recorder"),
live_id: z.string().describe("The live id of the archive"),
}),
}
@@ -246,7 +246,7 @@ const delete_archives = tool(
live_ids,
}: {
platform: string;
room_id: number;
room_id: string;
live_ids: string[];
}) => {
const result = await invoke("delete_archives", {
@@ -265,7 +265,7 @@ const delete_archives = tool(
.describe(
`The platform of the recorder. Can be ${platform_list.join(", ")}`
),
room_id: z.number().describe("The room id of the recorder"),
room_id: z.string().describe("The room id of the recorder"),
live_ids: z.array(z.string()).describe("The live ids of the archives"),
}),
}
@@ -308,7 +308,7 @@ const delete_background_task = tool(
// @ts-ignore
const get_videos = tool(
async ({ room_id }: { room_id: number }) => {
async ({ room_id }: { room_id: string }) => {
const result = (await invoke("get_videos", { roomId: room_id })) as any[];
return {
videos: result.map((v: any) => {
@@ -323,7 +323,7 @@ const get_videos = tool(
name: "get_videos",
description: "Get the list of all videos of a room",
schema: z.object({
room_id: z.number().describe("The room id of the room"),
room_id: z.string().describe("The room id of the room"),
}),
}
);
@@ -480,7 +480,7 @@ const post_video_to_bilibili = tool(
tid,
}: {
uid: number;
room_id: number;
room_id: string;
video_id: number;
title: string;
desc: string;
@@ -521,7 +521,7 @@ const post_video_to_bilibili = tool(
.describe(
"The uid of the user, it should be one of the uid in the bilibili accounts"
),
room_id: z.number().describe("The room id of the room"),
room_id: z.string().describe("The room id of the room"),
video_id: z.number().describe("The id of the video"),
title: z.string().describe("The title of the video"),
desc: z.string().describe("The description of the video"),
@@ -547,7 +547,7 @@ const get_danmu_record = tool(
live_id,
}: {
platform: string;
room_id: number;
room_id: string;
live_id: string;
}) => {
const result = (await invoke("get_danmu_record", {
@@ -571,7 +571,7 @@ const get_danmu_record = tool(
"Get the danmu record of a live, entry ts is relative to the live start time in seconds",
schema: z.object({
platform: z.string().describe("The platform of the room"),
room_id: z.number().describe("The room id of the room"),
room_id: z.string().describe("The room id of the room"),
live_id: z.string().describe("The live id of the live"),
}),
}
@@ -604,7 +604,7 @@ const clip_range = tool(
"The reason for the clip range, it will be shown to the user. You must offer a summary of the clip range content and why you choose this clip range."
),
clip_range_params: z.object({
room_id: z.number().describe("The room id of the room"),
room_id: z.string().describe("The room id of the room"),
live_id: z.string().describe("The live id of the live"),
range: z.object({
start: z.number().describe("The start time in SECONDS of the clip"),
@@ -641,7 +641,7 @@ const get_recent_record = tool(
offset,
limit,
}: {
room_id: number;
room_id: string;
offset: number;
limit: number;
}) => {
@@ -664,7 +664,7 @@ const get_recent_record = tool(
name: "get_recent_record",
description: "Get the list of recent records that bsr has recorded",
schema: z.object({
room_id: z.number().describe("The room id of the room"),
room_id: z.string().describe("The room id of the room"),
offset: z.number().describe("The offset of the records"),
limit: z.number().describe("The limit of the records"),
}),
@@ -752,7 +752,7 @@ const get_archive_subtitle = tool(
live_id,
}: {
platform: string;
room_id: number;
room_id: string;
live_id: string;
}) => {
const result = await invoke("get_archive_subtitle", {
@@ -768,7 +768,7 @@ const get_archive_subtitle = tool(
"Get the subtitle of a archive, it may not be generated yet, you can use generate_archive_subtitle to generate the subtitle",
schema: z.object({
platform: z.string().describe("The platform of the archive"),
room_id: z.number().describe("The room id of the archive"),
room_id: z.string().describe("The room id of the archive"),
live_id: z.string().describe("The live id of the archive"),
}),
}
@@ -782,7 +782,7 @@ const generate_archive_subtitle = tool(
live_id,
}: {
platform: string;
room_id: number;
room_id: string;
live_id: string;
}) => {
const result = await invoke("generate_archive_subtitle", {
@@ -798,7 +798,7 @@ const generate_archive_subtitle = tool(
"Generate the subtitle of a archive, it may take a long time, you should not call this tool unless user ask you to generate the subtitle. It can be used to overwrite the subtitle of a archive",
schema: z.object({
platform: z.string().describe("The platform of the archive"),
room_id: z.number().describe("The room id of the archive"),
room_id: z.string().describe("The room id of the archive"),
live_id: z.string().describe("The live id of the archive"),
}),
}

View File

@@ -7,7 +7,7 @@
export let showModal = false;
export let archive: RecordItem | null = null;
export let roomId: number;
export let roomId: string;
export const platform: string = "";
const dispatch = createEventDispatcher();
@@ -21,7 +21,7 @@
loadWholeClipArchives(roomId, archive.parent_id);
}
async function loadWholeClipArchives(roomId: number, parentId: string) {
async function loadWholeClipArchives(roomId: string, parentId: string) {
if (isLoading) return;
isLoading = true;

View File

@@ -23,7 +23,7 @@
}
export let platform: string;
export let room_id: number;
export let room_id: string;
export let live_id: string;
export let start = 0;
export let end = 0;
@@ -184,11 +184,11 @@ ${mediaPlaylistUrl}`;
recorders = (
(await invoke("get_recorder_list")) as RecorderList
).recorders.filter(
(r) => r.room_info.status && Number(r.room_info.room_id) != room_id
(r) => r.room_info.status && r.room_info.room_id != room_id
);
}
function go_to(platform: string, room_id: number, live_id: string) {
function go_to(platform: string, room_id: string, live_id: string) {
const url = `${window.location.origin}${window.location.pathname}?platform=${platform}&room_id=${room_id}&live_id=${live_id}`;
window.location.href = url;
}
@@ -1030,7 +1030,7 @@ ${mediaPlaylistUrl}`;
on:click={() => {
go_to(
recorder.room_info.platform,
Number(recorder.room_info.room_id),
recorder.room_info.room_id,
recorder.live_id
);
}}

View File

@@ -4,7 +4,7 @@
export let show = false;
export let onClose: () => void;
export let roomId: number;
export let roomId: string;
// 默认样式
const defaultStyle: SubtitleStyle = {
@@ -48,7 +48,7 @@
font-family: ${style.fontName};
font-size: ${style.fontSize}px;
color: ${fontColor};
text-shadow:
text-shadow:
${style.outlineWidth}px ${style.outlineWidth}px 0 ${outlineColor},
-${style.outlineWidth}px ${style.outlineWidth}px 0 ${outlineColor},
${style.outlineWidth}px -${style.outlineWidth}px 0 ${outlineColor},

View File

@@ -43,7 +43,7 @@
export let show = false;
export let video: VideoItem;
export let roomId: number;
export let roomId: string;
export let videos: any[] = [];
export let onVideoChange: ((video: VideoItem) => void) | undefined =
undefined;
@@ -809,8 +809,16 @@
function handleKeydown(event: KeyboardEvent) {
if (!show || !isVideoLoaded) return;
// 如果在输入框中,不处理某些快捷键
const isInInput = (event.target as HTMLElement)?.tagName === "INPUT";
const target = event.target as HTMLElement | null;
const tagName = target?.tagName;
// 如果当前焦点位于可编辑元素内,则跳过快捷键处理
const isInInput =
(!!tagName && ["INPUT", "TEXTAREA", "SELECT"].includes(tagName)) ||
!!target?.isContentEditable ||
!!target?.closest(
"input, textarea, select, [contenteditable='true'], [data-hotkey-block]"
);
switch (event.key) {
case "【":

View File

@@ -1,13 +1,12 @@
export interface RecorderItem {
platform: string;
room_id: number;
room_id: string;
created_at: string;
}
export interface AccountItem {
platform: string;
uid: number;
id_str?: string; // For platforms like Douyin that use string IDs
uid: string;
name: string;
avatar: string;
csrf: string;
@@ -29,7 +28,7 @@ export interface RecordItem {
title: string;
parent_id: string;
live_id: string;
room_id: number;
room_id: string;
length: number;
size: number;
created_at: string;

View File

@@ -59,7 +59,7 @@ export interface Video {
export interface VideoItem {
id: number;
room_id: number;
room_id: string;
cover: string;
file: string;
length: number;
@@ -259,7 +259,7 @@ export interface ClipRangeParams {
note: string;
cover: string;
platform: string;
room_id: number;
room_id: string;
live_id: string;
range: {
start: number;

View File

@@ -212,7 +212,7 @@
</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
UID: {account.id_str || account.uid}
UID: {account.uid}
</p>
</div>
</div>

View File

@@ -88,7 +88,7 @@
for (const room of allRooms) {
try {
const roomArchives = await invoke<RecordItem[]>("get_archives", {
roomId: parseInt(room.room_info.room_id),
roomId: room.room_info.room_id,
offset: 0,
limit: 100, // 每个直播间获取更多数据
});
@@ -184,7 +184,7 @@
// Apply room filter
if (selectedRoomId !== null) {
filtered = filtered.filter(
(archive) => String(archive.room_id) === selectedRoomId
(archive) => archive.room_id === selectedRoomId
);
}
@@ -291,7 +291,7 @@
}
}
function getRoomUrl(platform: string, roomId: number) {
function getRoomUrl(platform: string, roomId: string) {
switch (platform.toLowerCase()) {
case "bilibili":
return `https://live.bilibili.com/${roomId}`;
@@ -310,6 +310,10 @@
return ((size * 8) / duration / 1024).toFixed(0);
}
function getArchiveKey(archive: RecordItem) {
return `${archive.platform}-${archive.room_id}-${archive.parent_id}-${archive.live_id}`;
}
function toggleSort(field: string) {
if (sortBy === field) {
sortOrder = sortOrder === "asc" ? "desc" : "asc";
@@ -723,7 +727,7 @@
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700/50">
{#each filteredArchives as archive (archive.live_id)}
{#each filteredArchives as archive (getArchiveKey(archive))}
<tr
class="group hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] transition-colors"
>
@@ -906,7 +910,7 @@
<GenerateWholeClipModal
bind:showModal={showWholeClipModal}
archive={wholeClipArchive}
roomId={wholeClipArchive?.room_id || 0}
roomId={wholeClipArchive?.room_id || ""}
platform={wholeClipArchive?.platform || ""}
on:generated={handleWholeClipGenerated}
/>

View File

@@ -119,7 +119,7 @@
let isLoading = false;
let loadError = "";
async function showArchives(room_id: number) {
async function showArchives(room_id: string) {
// 重置分页状态
currentPage = 0;
archives = [];
@@ -139,7 +139,7 @@
try {
let new_archives = (await invoke("get_archives", {
roomId: Number(archiveRoom.room_info.room_id),
roomId: archiveRoom.room_info.room_id,
offset: currentPage * pageSize,
limit: pageSize,
})) as RecordItem[];
@@ -297,7 +297,7 @@
// Function to toggle auto-record state
function toggleEnabled(room: RecorderInfo) {
invoke("set_enable", {
roomId: Number(room.room_info.room_id),
roomId: room.room_info.room_id,
platform: room.room_info.platform,
enabled: !room.enabled,
});
@@ -322,7 +322,7 @@
}
}
function addNewRecorder(room_id: number, platform: string, extra: string) {
function addNewRecorder(room_id: string, platform: string, extra: string) {
// if extra contains ?, remove it
if (extra.includes("?")) {
extra = extra.split("?")[0];
@@ -560,7 +560,7 @@
on:click={() => {
invoke("open_live", {
platform: room.room_info.platform,
roomId: Number(room.room_info.room_id),
roomId: room.room_info.room_id,
liveId: room.live_id,
});
}}
@@ -572,7 +572,7 @@
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
on:click={() => {
archiveRoom = room;
showArchives(Number(room.room_info.room_id));
showArchives(room.room_info.room_id);
}}
>
<History class="w-5 h-5 dark:icon-white" />
@@ -636,7 +636,7 @@
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={async () => {
await invoke("remove_recorder", {
roomId: Number(deleteRoom.room_info.room_id),
roomId: deleteRoom.room_info.room_id,
platform: deleteRoom.room_info.platform,
});
deleteModal = false;
@@ -725,8 +725,7 @@
addValid = false;
return;
}
const room_id = Number(addRoom);
if (Number.isInteger(room_id) && room_id > 0) {
if (addRoom.length > 0) {
addErrorMsg = "";
addValid = true;
} else {
@@ -755,7 +754,7 @@
class="px-4 py-2 bg-[#0A84FF] hover:bg-[#0A84FF]/90 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!addValid}
on:click={() => {
addNewRecorder(Number(addRoom), selectedPlatform, "");
addNewRecorder(addRoom, selectedPlatform, "");
addModal = false;
addRoom = "";
}}
@@ -879,7 +878,7 @@
on:click={() => {
invoke("open_live", {
platform: archiveRoom.room_info.platform,
roomId: Number(archiveRoom.room_info.room_id),
roomId: archiveRoom.room_info.room_id,
liveId: archive.live_id,
});
}}
@@ -901,7 +900,7 @@
on:click={() => {
invoke("delete_archive", {
platform: archiveRoom.room_info.platform,
roomId: Number(archiveRoom.room_info.room_id),
roomId: archiveRoom.room_info.room_id,
liveId: archive.live_id,
})
.then(async () => {
@@ -978,7 +977,7 @@
<GenerateWholeClipModal
bind:showModal={generateWholeClipModal}
archive={generateWholeClipArchive}
roomId={generateWholeClipArchive?.room_id || 0}
roomId={generateWholeClipArchive?.room_id || ""}
platform={generateWholeClipArchive?.platform || ""}
on:generated={handleWholeClipGenerated}
/>

View File

@@ -61,7 +61,7 @@
// check for new records
if (recent_records.length > 0) {
const latestRecords = (await invoke("get_recent_record", {
roomId: 0,
roomId: "",
offset: 0,
limit: 1,
})) as RecordItem[];
@@ -84,7 +84,7 @@
loading = true;
const newRecords = (await invoke("get_recent_record", {
roomId: 0,
roomId: "",
offset: hasNewRecords ? 0 : offset,
limit: RECORDS_PER_PAGE,
})) as RecordItem[];