mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-24 20:15:34 +08:00
feat: deep-link support bsr://
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@langchain/langgraph": "^0.3.10",
|
||||
"@langchain/ollama": "^0.2.3",
|
||||
"@tauri-apps/api": "^2.6.2",
|
||||
"@tauri-apps/plugin-deep-link": "~2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
|
||||
86
src-tauri/Cargo.lock
generated
86
src-tauri/Cargo.lock
generated
@@ -571,6 +571,7 @@ dependencies = [
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
@@ -975,6 +976,26 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -1151,6 +1172,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -1529,6 +1556,15 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.11"
|
||||
@@ -3927,6 +3963,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -4987,6 +5033,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7295b7ce3bf4806b419dc3420745998b447178b7005e2011947b38fc5aa6791"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.25"
|
||||
@@ -6320,6 +6376,26 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fec67f32d7a06d80bd3dc009fdb678c35a66116d9cb8cd2bb32e406c2b5bbd2"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.3.0"
|
||||
@@ -6451,6 +6527,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -6692,6 +6769,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.1"
|
||||
|
||||
@@ -52,6 +52,7 @@ tokio-util = { version = "0.7", features = ["io"] }
|
||||
clap = { version = "4.5.37", features = ["derive"] }
|
||||
url = "2.5.4"
|
||||
srtparse = "0.2.0"
|
||||
tauri-plugin-deep-link = "2"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
@@ -83,6 +84,7 @@ optional = true
|
||||
[dependencies.tauri-plugin-single-instance]
|
||||
version = "2"
|
||||
optional = true
|
||||
features = ["deep-link"]
|
||||
|
||||
[dependencies.tauri-plugin-dialog]
|
||||
version = "2"
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
"identifier": "migrated",
|
||||
"description": "permissions that were migrated from v1",
|
||||
"local": true,
|
||||
"windows": ["main", "Live*", "Clip*"],
|
||||
"windows": [
|
||||
"main",
|
||||
"Live*",
|
||||
"Clip*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"fs:allow-read-file",
|
||||
@@ -16,7 +20,9 @@
|
||||
"fs:allow-exists",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": ["**"]
|
||||
"allow": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
@@ -65,6 +71,7 @@
|
||||
"shell:default",
|
||||
"sql:default",
|
||||
"os:default",
|
||||
"dialog:default"
|
||||
"dialog:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*","Clip*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default","dialog:default"]}}
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*","Clip*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default","dialog:default","deep-link:default"]}}
|
||||
@@ -4220,6 +4220,60 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`",
|
||||
"type": "string",
|
||||
"const": "deep-link:default",
|
||||
"markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-get-current",
|
||||
"markdownDescription": "Enables the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-get-current",
|
||||
"markdownDescription": "Denies the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -4220,6 +4220,60 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`",
|
||||
"type": "string",
|
||||
"const": "deep-link:default",
|
||||
"markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-get-current",
|
||||
"markdownDescription": "Enables the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-get-current",
|
||||
"markdownDescription": "Denies the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -9,7 +9,7 @@ use rand::Rng;
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct AccountRow {
|
||||
pub platform: String,
|
||||
pub uid: u64, // Keep for Bilibili compatibility
|
||||
pub uid: u64, // Keep for Bilibili compatibility
|
||||
pub id_str: Option<String>, // New field for string IDs like Douyin sec_uid
|
||||
pub name: String,
|
||||
pub avatar: String,
|
||||
@@ -133,7 +133,7 @@ impl Database {
|
||||
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) {
|
||||
// Delete the old record (for Douyin accounts, we use uid to identify)
|
||||
@@ -142,7 +142,7 @@ impl Database {
|
||||
.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 as i64)
|
||||
@@ -157,15 +157,17 @@ impl Database {
|
||||
.await?;
|
||||
} else {
|
||||
// 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 as i64)
|
||||
.bind(&old_account.platform)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3 and platform = $4",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(avatar)
|
||||
.bind(old_account.uid as i64)
|
||||
.bind(&old_account.platform)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ impl Database {
|
||||
"SELECT * FROM records ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
} else {
|
||||
Ok(sqlx::query_as::<_, RecordRow>(
|
||||
"SELECT * FROM records WHERE room_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
|
||||
@@ -2,8 +2,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
use crate::progress_reporter::{ProgressReporter, ProgressReporterTrait};
|
||||
use crate::subtitle_generator::{whisper_cpp, GenerateResult, SubtitleGenerator, SubtitleGeneratorType};
|
||||
use crate::subtitle_generator::whisper_online;
|
||||
use crate::subtitle_generator::{
|
||||
whisper_cpp, GenerateResult, SubtitleGenerator, SubtitleGeneratorType,
|
||||
};
|
||||
use async_ffmpeg_sidecar::event::{FfmpegEvent, LogLevel};
|
||||
use async_ffmpeg_sidecar::log_parser::FfmpegLogParser;
|
||||
use tokio::io::BufReader;
|
||||
@@ -262,7 +264,10 @@ pub async fn get_segment_duration(file: &Path) -> Result<f64, String> {
|
||||
.spawn();
|
||||
|
||||
if let Err(e) = child {
|
||||
return Err(format!("Failed to spawn ffprobe process for segment: {}", e));
|
||||
return Err(format!(
|
||||
"Failed to spawn ffprobe process for segment: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
|
||||
let mut child = child.unwrap();
|
||||
@@ -293,8 +298,6 @@ pub async fn get_segment_duration(file: &Path) -> Result<f64, String> {
|
||||
duration.ok_or_else(|| "Failed to parse segment duration".to_string())
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub async fn encode_video_subtitle(
|
||||
reporter: &impl ProgressReporterTrait,
|
||||
file: &Path,
|
||||
@@ -467,10 +470,7 @@ pub async fn encode_video_danmu(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn generic_ffmpeg_command(
|
||||
args: &[&str],
|
||||
) -> Result<String, String> {
|
||||
pub async fn generic_ffmpeg_command(args: &[&str]) -> Result<String, String> {
|
||||
let child = tokio::process::Command::new(ffmpeg_path())
|
||||
.args(args)
|
||||
.stderr(Stdio::piped())
|
||||
@@ -520,8 +520,7 @@ pub async fn generate_video_subtitle(
|
||||
if whisper_model.is_empty() {
|
||||
return Err("Whisper model not configured".to_string());
|
||||
}
|
||||
if let Ok(generator) =
|
||||
whisper_cpp::new(Path::new(&whisper_model), whisper_prompt).await
|
||||
if let Ok(generator) = whisper_cpp::new(Path::new(&whisper_model), whisper_prompt).await
|
||||
{
|
||||
let chunk_dir = extract_audio_chunks(file, "wav").await?;
|
||||
|
||||
@@ -630,7 +629,6 @@ pub async fn generate_video_subtitle(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Trying to run ffmpeg for version
|
||||
pub async fn check_ffmpeg() -> Result<String, String> {
|
||||
let child = tokio::process::Command::new(ffmpeg_path())
|
||||
|
||||
@@ -43,8 +43,13 @@ pub async fn add_account(
|
||||
match douyin_client.get_user_info().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();
|
||||
|
||||
let avatar_url = user_info
|
||||
.avatar_thumb
|
||||
.url_list
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
state
|
||||
.db
|
||||
.update_account_with_id_str(
|
||||
|
||||
@@ -147,7 +147,10 @@ pub async fn get_archive_subtitle(
|
||||
if platform.is_none() {
|
||||
return Err("Unsupported platform".to_string());
|
||||
}
|
||||
Ok(state.recorder_manager.get_archive_subtitle(platform.unwrap(), room_id, &live_id).await?)
|
||||
Ok(state
|
||||
.recorder_manager
|
||||
.get_archive_subtitle(platform.unwrap(), room_id, &live_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
@@ -161,7 +164,10 @@ pub async fn generate_archive_subtitle(
|
||||
if platform.is_none() {
|
||||
return Err("Unsupported platform".to_string());
|
||||
}
|
||||
Ok(state.recorder_manager.generate_archive_subtitle(platform.unwrap(), room_id, &live_id).await?)
|
||||
Ok(state
|
||||
.recorder_manager
|
||||
.generate_archive_subtitle(platform.unwrap(), room_id, &live_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
|
||||
@@ -301,4 +301,4 @@ pub async fn list_folder(_state: state_type!(), path: String) -> Result<Vec<Stri
|
||||
files.push(entry.path().to_str().unwrap().to_string());
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +411,18 @@ pub async fn generate_video_subtitle(
|
||||
let filepath = Path::new(state.config.read().await.output.as_str()).join(&video.file);
|
||||
let file = Path::new(&filepath);
|
||||
|
||||
match ffmpeg::generate_video_subtitle(Some(&reporter), file, generator_type, &whisper_model, &whisper_prompt, &openai_api_key, &openai_api_endpoint, language_hint).await {
|
||||
match ffmpeg::generate_video_subtitle(
|
||||
Some(&reporter),
|
||||
file,
|
||||
generator_type,
|
||||
&whisper_model,
|
||||
&whisper_prompt,
|
||||
&openai_api_key,
|
||||
&openai_api_endpoint,
|
||||
language_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
reporter.finish(true, "字幕生成完成").await;
|
||||
// for local whisper, we need to update the task status to success
|
||||
@@ -552,7 +563,6 @@ async fn encode_video_subtitle_inner(
|
||||
Ok(new_video)
|
||||
}
|
||||
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn generic_ffmpeg_command(
|
||||
_state: state_type!(),
|
||||
@@ -560,4 +570,4 @@ pub async fn generic_ffmpeg_command(
|
||||
) -> Result<String, String> {
|
||||
let args_str: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
ffmpeg::generic_ffmpeg_command(&args_str).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,17 +22,18 @@ use crate::{
|
||||
},
|
||||
message::{delete_message, get_messages, read_message},
|
||||
recorder::{
|
||||
add_recorder, delete_archive, export_danmu, fetch_hls, get_archive, get_archive_subtitle, get_archives,
|
||||
get_danmu_record, get_recent_record, get_recorder_list, get_room_info,
|
||||
get_today_record_count, get_total_length, remove_recorder, send_danmaku, set_enable,
|
||||
ExportDanmuOptions, generate_archive_subtitle,
|
||||
add_recorder, delete_archive, export_danmu, fetch_hls, generate_archive_subtitle,
|
||||
get_archive, get_archive_subtitle, get_archives, get_danmu_record, get_recent_record,
|
||||
get_recorder_list, get_room_info, get_today_record_count, get_total_length,
|
||||
remove_recorder, send_danmaku, set_enable, ExportDanmuOptions,
|
||||
},
|
||||
task::{delete_task, get_tasks},
|
||||
utils::{console_log, get_disk_info, list_folder, DiskInfo},
|
||||
video::{
|
||||
cancel, clip_range, delete_video, encode_video_subtitle, generate_video_subtitle,
|
||||
get_all_videos, get_video, get_video_cover, get_video_subtitle, get_video_typelist,
|
||||
get_videos, update_video_cover, update_video_subtitle, upload_procedure, generic_ffmpeg_command,
|
||||
generic_ffmpeg_command, get_all_videos, get_video, get_video_cover, get_video_subtitle,
|
||||
get_video_typelist, get_videos, update_video_cover, update_video_subtitle,
|
||||
upload_procedure,
|
||||
},
|
||||
AccountInfo,
|
||||
},
|
||||
@@ -518,7 +519,8 @@ async fn handler_get_archive_subtitle(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<GetArchiveSubtitleRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
let subtitle = get_archive_subtitle(state.0, param.platform, param.room_id, param.live_id).await?;
|
||||
let subtitle =
|
||||
get_archive_subtitle(state.0, param.platform, param.room_id, param.live_id).await?;
|
||||
Ok(Json(ApiResponse::success(subtitle)))
|
||||
}
|
||||
|
||||
@@ -534,7 +536,8 @@ async fn handler_generate_archive_subtitle(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<GenerateArchiveSubtitleRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
let subtitle = generate_archive_subtitle(state.0, param.platform, param.room_id, param.live_id).await?;
|
||||
let subtitle =
|
||||
generate_archive_subtitle(state.0, param.platform, param.room_id, param.live_id).await?;
|
||||
Ok(Json(ApiResponse::success(subtitle)))
|
||||
}
|
||||
|
||||
@@ -613,7 +616,8 @@ async fn handler_get_recent_record(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<GetRecentRecordRequest>,
|
||||
) -> Result<Json<ApiResponse<Vec<RecordRow>>>, ApiError> {
|
||||
let recent_record = get_recent_record(state.0, param.room_id, param.offset, param.limit).await?;
|
||||
let recent_record =
|
||||
get_recent_record(state.0, param.room_id, param.offset, param.limit).await?;
|
||||
Ok(Json(ApiResponse::success(recent_record)))
|
||||
}
|
||||
|
||||
@@ -1333,7 +1337,10 @@ pub async fn start_api_server(state: State) {
|
||||
.route("/api/get_room_info", post(handler_get_room_info))
|
||||
.route("/api/get_archives", post(handler_get_archives))
|
||||
.route("/api/get_archive", post(handler_get_archive))
|
||||
.route("/api/get_archive_subtitle", post(handler_get_archive_subtitle))
|
||||
.route(
|
||||
"/api/get_archive_subtitle",
|
||||
post(handler_get_archive_subtitle),
|
||||
)
|
||||
.route("/api/get_danmu_record", post(handler_get_danmu_record))
|
||||
.route("/api/get_total_length", post(handler_get_total_length))
|
||||
.route(
|
||||
|
||||
@@ -556,7 +556,7 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = fix_path_env::fix();
|
||||
|
||||
let builder = tauri::Builder::default();
|
||||
let builder = tauri::Builder::default().plugin(tauri_plugin_deep_link::init());
|
||||
let builder = setup_plugins(builder);
|
||||
let builder = setup_event_handlers(builder);
|
||||
let builder = setup_invoke_handlers(builder);
|
||||
|
||||
@@ -82,7 +82,10 @@ pub trait Recorder: Send + Sync + 'static {
|
||||
async fn comments(&self, live_id: &str) -> Result<Vec<DanmuEntry>, errors::RecorderError>;
|
||||
async fn is_recording(&self, live_id: &str) -> bool;
|
||||
async fn get_archive_subtitle(&self, live_id: &str) -> Result<String, errors::RecorderError>;
|
||||
async fn generate_archive_subtitle(&self, live_id: &str) -> Result<String, errors::RecorderError>;
|
||||
async fn generate_archive_subtitle(
|
||||
&self,
|
||||
live_id: &str,
|
||||
) -> Result<String, errors::RecorderError>;
|
||||
async fn enable(&self);
|
||||
async fn disable(&self);
|
||||
}
|
||||
|
||||
@@ -67,12 +67,16 @@ impl DanmuStorage {
|
||||
|
||||
// get entries with ts relative to live start time
|
||||
pub async fn get_entries(&self, live_start_ts: i64) -> Vec<DanmuEntry> {
|
||||
let mut danmus: Vec<DanmuEntry> = self.cache.read().await.iter().map(|entry| {
|
||||
DanmuEntry {
|
||||
let mut danmus: Vec<DanmuEntry> = self
|
||||
.cache
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|entry| DanmuEntry {
|
||||
ts: entry.ts - live_start_ts,
|
||||
content: entry.content.clone(),
|
||||
}
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
// filter out danmus with ts < 0
|
||||
danmus.retain(|entry| entry.ts >= 0);
|
||||
danmus
|
||||
|
||||
@@ -19,11 +19,11 @@ use danmu_stream::danmu_stream::DanmuStream;
|
||||
use danmu_stream::provider::ProviderType;
|
||||
use danmu_stream::DanmuMessageType;
|
||||
use rand::random;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
@@ -233,7 +233,13 @@ impl DouyinRecorder {
|
||||
|
||||
async fn danmu(&self) -> Result<(), super::errors::RecorderError> {
|
||||
let cookies = self.account.cookies.clone();
|
||||
let danmu_room_id = self.danmu_room_id.read().await.clone().parse::<u64>().unwrap_or(0);
|
||||
let danmu_room_id = self
|
||||
.danmu_room_id
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.parse::<u64>()
|
||||
.unwrap_or(0);
|
||||
let danmu_stream = DanmuStream::new(ProviderType::Douyin, &cookies, danmu_room_id).await;
|
||||
if danmu_stream.is_err() {
|
||||
let err = danmu_stream.err().unwrap();
|
||||
@@ -322,24 +328,22 @@ impl DouyinRecorder {
|
||||
fn parse_stream_url(&self, stream_url: &str) -> (String, String) {
|
||||
// Parse stream URL to extract base URL and query parameters
|
||||
// Example: http://7167739a741646b4651b6949b2f3eb8e.livehwc3.cn/pull-hls-l26.douyincdn.com/third/stream-693342996808860134_or4.m3u8?sub_m3u8=true&user_session_id=16090eb45ab8a2f042f7c46563936187&major_anchor_level=common&edge_slice=true&expire=67d944ec&sign=47b95cc6e8de20d82f3d404412fa8406
|
||||
|
||||
|
||||
let base_url = stream_url
|
||||
.rfind('/')
|
||||
.map(|i| &stream_url[..=i])
|
||||
.unwrap_or(stream_url)
|
||||
.to_string();
|
||||
|
||||
|
||||
let query_params = stream_url
|
||||
.find('?')
|
||||
.map(|i| &stream_url[i..])
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
|
||||
(base_url, query_params)
|
||||
}
|
||||
|
||||
|
||||
|
||||
async fn update_entries(&self) -> Result<u128, RecorderError> {
|
||||
let task_begin_time = std::time::Instant::now();
|
||||
|
||||
@@ -363,8 +367,8 @@ impl DouyinRecorder {
|
||||
|
||||
let mut new_segment_fetched = false;
|
||||
let mut is_first_segment = self.entry_store.read().await.is_none();
|
||||
let work_dir ;
|
||||
|
||||
let work_dir;
|
||||
|
||||
// If this is the first segment, prepare but don't create directories yet
|
||||
if is_first_segment {
|
||||
// Generate live_id for potential use
|
||||
@@ -410,7 +414,7 @@ impl DouyinRecorder {
|
||||
} else {
|
||||
// Parse the stream URL to extract base URL and query parameters
|
||||
let (base_url, query_params) = self.parse_stream_url(&stream_url);
|
||||
|
||||
|
||||
// Check if the segment URI already has query parameters
|
||||
if uri.contains('?') {
|
||||
// If segment URI has query params, append m3u8 query params with &
|
||||
@@ -420,17 +424,17 @@ impl DouyinRecorder {
|
||||
format!("{}{}{}", base_url, uri, query_params)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Download segment with retry mechanism
|
||||
let mut retry_count = 0;
|
||||
let max_retries = 3;
|
||||
let mut download_success = false;
|
||||
let mut work_dir_created = false;
|
||||
|
||||
|
||||
while retry_count < max_retries && !download_success {
|
||||
let file_name = format!("{}.ts", sequence);
|
||||
let file_path = format!("{}/{}", work_dir, file_name);
|
||||
|
||||
|
||||
// If this is the first segment, create work directory before first download attempt
|
||||
if is_first_segment && !work_dir_created {
|
||||
// Create work directory only when we're about to download
|
||||
@@ -440,12 +444,8 @@ impl DouyinRecorder {
|
||||
}
|
||||
work_dir_created = true;
|
||||
}
|
||||
|
||||
match self
|
||||
.client
|
||||
.download_ts(&ts_url, &file_path)
|
||||
.await
|
||||
{
|
||||
|
||||
match self.client.download_ts(&ts_url, &file_path).await {
|
||||
Ok(size) => {
|
||||
if size == 0 {
|
||||
log::error!("Download segment failed (empty response): {}", ts_url);
|
||||
@@ -499,7 +499,9 @@ impl DouyinRecorder {
|
||||
if let Some(danmu_task) = self.danmu_task.lock().await.as_mut() {
|
||||
danmu_task.abort();
|
||||
}
|
||||
if let Some(danmu_stream_task) = self.danmu_stream_task.lock().await.as_mut() {
|
||||
if let Some(danmu_stream_task) =
|
||||
self.danmu_stream_task.lock().await.as_mut()
|
||||
{
|
||||
danmu_stream_task.abort();
|
||||
}
|
||||
let live_id = self.live_id.read().await.clone();
|
||||
@@ -533,50 +535,76 @@ impl DouyinRecorder {
|
||||
download_success = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to download segment (attempt {}/{}): {} - URL: {}",
|
||||
retry_count + 1, max_retries, e, ts_url);
|
||||
log::warn!(
|
||||
"Failed to download segment (attempt {}/{}): {} - URL: {}",
|
||||
retry_count + 1,
|
||||
max_retries,
|
||||
e,
|
||||
ts_url
|
||||
);
|
||||
retry_count += 1;
|
||||
if retry_count < max_retries {
|
||||
tokio::time::sleep(Duration::from_millis(1000 * retry_count as u64)).await;
|
||||
tokio::time::sleep(Duration::from_millis(1000 * retry_count as u64))
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
// If all retries failed, check if it's a 400 error
|
||||
if e.to_string().contains("400") {
|
||||
log::error!("HTTP 400 error for segment, stream URL may be expired: {}", ts_url);
|
||||
log::error!(
|
||||
"HTTP 400 error for segment, stream URL may be expired: {}",
|
||||
ts_url
|
||||
);
|
||||
*self.stream_url.write().await = None;
|
||||
|
||||
|
||||
// Clean up empty directory if first segment failed
|
||||
if is_first_segment && work_dir_created {
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await {
|
||||
log::warn!("Failed to cleanup empty work directory {}: {}", work_dir, cleanup_err);
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Err(RecorderError::NoStreamAvailable);
|
||||
}
|
||||
|
||||
|
||||
// Clean up empty directory if first segment failed
|
||||
if is_first_segment && work_dir_created {
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await {
|
||||
log::warn!("Failed to cleanup empty work directory {}: {}", work_dir, cleanup_err);
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !download_success {
|
||||
log::error!("Failed to download segment after {} retries: {}", max_retries, ts_url);
|
||||
|
||||
log::error!(
|
||||
"Failed to download segment after {} retries: {}",
|
||||
max_retries,
|
||||
ts_url
|
||||
);
|
||||
|
||||
// Clean up empty directory if first segment failed after all retries
|
||||
if is_first_segment && work_dir_created {
|
||||
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&work_dir).await {
|
||||
log::warn!("Failed to cleanup empty work directory {}: {}", work_dir, cleanup_err);
|
||||
log::warn!(
|
||||
"Failed to cleanup empty work directory {}: {}",
|
||||
work_dir,
|
||||
cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -737,7 +765,10 @@ impl Recorder for DouyinRecorder {
|
||||
m3u8_content
|
||||
}
|
||||
|
||||
async fn get_archive_subtitle(&self, live_id: &str) -> Result<String, super::errors::RecorderError> {
|
||||
async fn get_archive_subtitle(
|
||||
&self,
|
||||
live_id: &str,
|
||||
) -> Result<String, super::errors::RecorderError> {
|
||||
let work_dir = self.get_work_dir(live_id).await;
|
||||
let subtitle_file_path = format!("{}/{}", work_dir, "subtitle.srt");
|
||||
let subtitle_file = File::open(subtitle_file_path).await;
|
||||
@@ -753,7 +784,10 @@ impl Recorder for DouyinRecorder {
|
||||
Ok(subtitle_content)
|
||||
}
|
||||
|
||||
async fn generate_archive_subtitle(&self, live_id: &str) -> Result<String, super::errors::RecorderError> {
|
||||
async fn generate_archive_subtitle(
|
||||
&self,
|
||||
live_id: &str,
|
||||
) -> Result<String, super::errors::RecorderError> {
|
||||
// generate subtitle file under work_dir
|
||||
let work_dir = self.get_work_dir(live_id).await;
|
||||
let subtitle_file_path = format!("{}/{}", work_dir, "subtitle.srt");
|
||||
@@ -765,22 +799,43 @@ impl Recorder for DouyinRecorder {
|
||||
tokio::fs::write(&m3u8_index_file_path, m3u8_content).await?;
|
||||
// generate a tmp clip file
|
||||
let clip_file_path = format!("{}/{}", work_dir, "tmp.mp4");
|
||||
if let Err(e) = crate::ffmpeg::clip_from_m3u8(None::<&crate::progress_reporter::ProgressReporter>, Path::new(&m3u8_index_file_path), Path::new(&clip_file_path)).await {
|
||||
if let Err(e) = crate::ffmpeg::clip_from_m3u8(
|
||||
None::<&crate::progress_reporter::ProgressReporter>,
|
||||
Path::new(&m3u8_index_file_path),
|
||||
Path::new(&clip_file_path),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(super::errors::RecorderError::SubtitleGenerationFailed {
|
||||
error: e.to_string(),
|
||||
});
|
||||
}
|
||||
// generate subtitle file
|
||||
let config = self.config.read().await;
|
||||
let result = crate::ffmpeg::generate_video_subtitle(None, Path::new(&clip_file_path), "whisper", &config.whisper_model, &config.whisper_prompt, &config.openai_api_key, &config.openai_api_endpoint, &config.whisper_language).await;
|
||||
let result = crate::ffmpeg::generate_video_subtitle(
|
||||
None,
|
||||
Path::new(&clip_file_path),
|
||||
"whisper",
|
||||
&config.whisper_model,
|
||||
&config.whisper_prompt,
|
||||
&config.openai_api_key,
|
||||
&config.openai_api_endpoint,
|
||||
&config.whisper_language,
|
||||
)
|
||||
.await;
|
||||
// write subtitle file
|
||||
if let Err(e) = result {
|
||||
return Err(super::errors::RecorderError::SubtitleGenerationFailed {
|
||||
error: e.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let result = result.unwrap();
|
||||
let subtitle_content = result.subtitle_content.iter().map(item_to_srt).collect::<Vec<String>>().join("");
|
||||
let subtitle_content = result
|
||||
.subtitle_content
|
||||
.iter()
|
||||
.map(item_to_srt)
|
||||
.collect::<Vec<String>>()
|
||||
.join("");
|
||||
subtitle_file.write_all(subtitle_content.as_bytes()).await?;
|
||||
|
||||
// remove tmp file
|
||||
@@ -857,7 +912,11 @@ impl Recorder for DouyinRecorder {
|
||||
Ok(if live_id == *self.live_id.read().await {
|
||||
// just return current cache content
|
||||
match self.danmu_store.read().await.as_ref() {
|
||||
Some(storage) => storage.get_entries(self.first_segment_ts(live_id).await).await,
|
||||
Some(storage) => {
|
||||
storage
|
||||
.get_entries(self.first_segment_ts(live_id).await)
|
||||
.await
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
@@ -875,7 +934,9 @@ impl Recorder for DouyinRecorder {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let storage = storage.unwrap();
|
||||
storage.get_entries(self.first_segment_ts(live_id).await).await
|
||||
storage
|
||||
.get_entries(self.first_segment_ts(live_id).await)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ impl DouyinClient {
|
||||
if let Ok(data) = resp.json::<super::response::DouyinRelationResponse>().await {
|
||||
if data.status_code == 0 {
|
||||
let owner_sec_uid = &data.owner_sec_uid;
|
||||
|
||||
|
||||
// Find the user's own info in the followings list by matching sec_uid
|
||||
if let Some(followings) = &data.followings {
|
||||
for following in followings {
|
||||
@@ -109,15 +109,13 @@ impl DouyinClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If not found in followings, create a minimal user info from owner_sec_uid
|
||||
let user = super::response::User {
|
||||
id_str: "".to_string(), // We don't have the numeric UID
|
||||
sec_uid: owner_sec_uid.clone(),
|
||||
nickname: "抖音用户".to_string(), // Default nickname
|
||||
avatar_thumb: super::response::AvatarThumb {
|
||||
url_list: vec![],
|
||||
},
|
||||
avatar_thumb: super::response::AvatarThumb { url_list: vec![] },
|
||||
follow_info: super::response::FollowInfo::default(),
|
||||
foreign_user: 0,
|
||||
open_id_str: "".to_string(),
|
||||
@@ -126,10 +124,10 @@ impl DouyinClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Err(DouyinClientError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"Failed to get user info from Douyin relation API"
|
||||
"Failed to get user info from Douyin relation API",
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -148,7 +146,8 @@ impl DouyinClient {
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<(MediaPlaylist, String), DouyinClientError> {
|
||||
let content = self.client
|
||||
let content = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("Referer", "https://live.douyin.com/")
|
||||
.header("User-Agent", USER_AGENT)
|
||||
@@ -183,7 +182,8 @@ impl DouyinClient {
|
||||
}
|
||||
|
||||
pub async fn download_ts(&self, url: &str, path: &str) -> Result<u64, DouyinClientError> {
|
||||
let response = self.client
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("Referer", "https://live.douyin.com/")
|
||||
.header("User-Agent", USER_AGENT)
|
||||
@@ -212,5 +212,3 @@ impl DouyinClient {
|
||||
Ok(size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -182,8 +182,7 @@ pub struct Extra {
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullDatas {
|
||||
}
|
||||
pub struct PullDatas {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -436,8 +435,7 @@ pub struct Stats {
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkerMap {
|
||||
}
|
||||
pub struct LinkerMap {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -478,13 +476,11 @@ pub struct LinkerDetail {
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkerMapStr {
|
||||
}
|
||||
pub struct LinkerMapStr {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaymodeDetail {
|
||||
}
|
||||
pub struct PlaymodeDetail {}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -676,4 +672,4 @@ pub struct AvatarSmall {
|
||||
pub uri: String,
|
||||
#[serde(rename = "url_list")]
|
||||
pub url_list: Vec<String>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,7 +613,12 @@ impl RecorderManager {
|
||||
Ok(self.db.get_record(room_id, live_id).await?)
|
||||
}
|
||||
|
||||
pub async fn get_archive_subtitle(&self, platform: PlatformType, room_id: u64, live_id: &str) -> Result<String, RecorderManagerError> {
|
||||
pub async fn get_archive_subtitle(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
live_id: &str,
|
||||
) -> Result<String, RecorderManagerError> {
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
let recorder = recorder_ref.as_ref();
|
||||
@@ -623,7 +628,12 @@ impl RecorderManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_archive_subtitle(&self, platform: PlatformType, room_id: u64, live_id: &str) -> Result<String, RecorderManagerError> {
|
||||
pub async fn generate_archive_subtitle(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
live_id: &str,
|
||||
) -> Result<String, RecorderManagerError> {
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
let recorder = recorder_ref.as_ref();
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
"plugins": {
|
||||
"sql": {
|
||||
"preload": ["sqlite:data_v2.db"]
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["bsr"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
|
||||
@@ -5,12 +5,47 @@
|
||||
import Setting from "./page/Setting.svelte";
|
||||
import Account from "./page/Account.svelte";
|
||||
import About from "./page/About.svelte";
|
||||
import { log } from "./lib/invoker";
|
||||
import { log, onOpenUrl } from "./lib/invoker";
|
||||
import Clip from "./page/Clip.svelte";
|
||||
import Task from "./page/Task.svelte";
|
||||
import AI from "./page/AI.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let active = "总览";
|
||||
|
||||
onMount(async () => {
|
||||
await onOpenUrl((urls: string[]) => {
|
||||
console.log("Received Deep Link:", urls);
|
||||
if (urls.length > 0) {
|
||||
const url = urls[0];
|
||||
// extract platform and room_id from url
|
||||
// url example:
|
||||
// bsr://live.bilibili.com/167537?live_from=85001&spm_id_from=333.1365.live_users.item.click
|
||||
// bsr://live.douyin.com/200525029536
|
||||
|
||||
let platform = "";
|
||||
let room_id = "";
|
||||
|
||||
if (url.startsWith("bsr://live.bilibili.com/")) {
|
||||
// 1. remove bsr://live.bilibili.com/
|
||||
// 2. remove all query params
|
||||
room_id = url.replace("bsr://live.bilibili.com/", "").split("?")[0];
|
||||
platform = "bilibili";
|
||||
}
|
||||
|
||||
if (url.startsWith("bsr://live.douyin.com/")) {
|
||||
room_id = url.replace("bsr://live.douyin.com/", "").split("?")[0];
|
||||
platform = "douyin";
|
||||
}
|
||||
|
||||
if (platform && room_id) {
|
||||
// switch to room page
|
||||
active = "直播间";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
log.info("App loaded");
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetch as tauri_fetch } from "@tauri-apps/plugin-http";
|
||||
import { convertFileSrc as tauri_convert } from "@tauri-apps/api/core";
|
||||
import { listen as tauri_listen } from "@tauri-apps/api/event";
|
||||
import { open as tauri_open } from "@tauri-apps/plugin-shell";
|
||||
import { onOpenUrl as tauri_onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -169,6 +170,12 @@ async function close_window() {
|
||||
window.close();
|
||||
}
|
||||
|
||||
async function onOpenUrl(func: (urls: string[]) => void) {
|
||||
if (TAURI_ENV) {
|
||||
return await tauri_onOpenUrl(func);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
invoke,
|
||||
get,
|
||||
@@ -180,4 +187,5 @@ export {
|
||||
open,
|
||||
log,
|
||||
close_window,
|
||||
onOpenUrl,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { invoke, open } from "../lib/invoker";
|
||||
import { invoke, open, onOpenUrl } from "../lib/invoker";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
import { Dropdown, DropdownItem } from "flowbite-svelte";
|
||||
@@ -15,11 +15,11 @@
|
||||
Trash2,
|
||||
X,
|
||||
History,
|
||||
Activity,
|
||||
} from "lucide-svelte";
|
||||
import BilibiliIcon from "../lib/BilibiliIcon.svelte";
|
||||
import DouyinIcon from "../lib/DouyinIcon.svelte";
|
||||
import AutoRecordIcon from "../lib/AutoRecordIcon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let room_count = 0;
|
||||
let room_active = 0;
|
||||
@@ -62,13 +62,6 @@
|
||||
update_summary();
|
||||
setInterval(update_summary, 5000);
|
||||
|
||||
function format_time(time: number) {
|
||||
let hours = Math.floor(time / 3600);
|
||||
let minutes = Math.floor((time % 3600) / 60);
|
||||
let seconds = Math.floor(time % 60);
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// modals
|
||||
let deleteModal = false;
|
||||
let deleteRoom = null;
|
||||
@@ -146,9 +139,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Add toggle state for auto-recording
|
||||
let autoRecordStates = new Map<string, boolean>();
|
||||
|
||||
// Function to toggle auto-record state
|
||||
function toggleEnabled(room: RecorderInfo) {
|
||||
invoke("set_enable", {
|
||||
@@ -176,6 +166,54 @@
|
||||
open("https://live.douyin.com/" + room.room_id);
|
||||
}
|
||||
}
|
||||
|
||||
function addNewRecorder(room_id: number, platform: string) {
|
||||
invoke("add_recorder", {
|
||||
roomId: room_id,
|
||||
platform: platform,
|
||||
})
|
||||
.then(() => {
|
||||
addModal = false;
|
||||
addRoom = "";
|
||||
})
|
||||
.catch(async (e) => {
|
||||
await message(e);
|
||||
});
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await onOpenUrl((urls: string[]) => {
|
||||
console.log("Received Deep Link:", urls);
|
||||
if (urls.length > 0) {
|
||||
const url = urls[0];
|
||||
// extract platform and room_id from url
|
||||
// url example:
|
||||
// bsr://live.bilibili.com/167537?live_from=85001&spm_id_from=333.1365.live_users.item.click
|
||||
// bsr://live.douyin.com/200525029536
|
||||
|
||||
let platform = "";
|
||||
let room_id = "";
|
||||
|
||||
if (url.startsWith("bsr://live.bilibili.com/")) {
|
||||
// 1. remove bsr://live.bilibili.com/
|
||||
// 2. remove all query params
|
||||
room_id = url.replace("bsr://live.bilibili.com/", "").split("?")[0];
|
||||
platform = "bilibili";
|
||||
}
|
||||
|
||||
if (url.startsWith("bsr://live.douyin.com/")) {
|
||||
room_id = url.replace("bsr://live.douyin.com/", "").split("?")[0];
|
||||
platform = "douyin";
|
||||
}
|
||||
|
||||
if (platform && room_id) {
|
||||
addModal = true;
|
||||
addRoom = room_id;
|
||||
selectedPlatform = platform;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 p-6 overflow-auto custom-scrollbar-light bg-gray-50">
|
||||
@@ -526,17 +564,9 @@
|
||||
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={() => {
|
||||
invoke("add_recorder", {
|
||||
roomId: Number(addRoom),
|
||||
platform: selectedPlatform,
|
||||
})
|
||||
.then(() => {
|
||||
addModal = false;
|
||||
addRoom = "";
|
||||
})
|
||||
.catch(async (e) => {
|
||||
await message(e);
|
||||
});
|
||||
addNewRecorder(Number(addRoom), selectedPlatform);
|
||||
addModal = false;
|
||||
addRoom = "";
|
||||
}}
|
||||
>
|
||||
添加
|
||||
|
||||
@@ -899,6 +899,13 @@
|
||||
"@tauri-apps/cli-win32-ia32-msvc" "2.6.2"
|
||||
"@tauri-apps/cli-win32-x64-msvc" "2.6.2"
|
||||
|
||||
"@tauri-apps/plugin-deep-link@~2":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.1.tgz#2f22d01d3e3795a607a2b31857cf99fb56126701"
|
||||
integrity sha512-I8Bo+spcAKGhIIJ1qN/gapp/Ot3mosQL98znxr975Zn2ODAkUZ++BQ9FnTpR7PDwfIl5ANSGdIW/YU01zVTcJw==
|
||||
dependencies:
|
||||
"@tauri-apps/api" "^2.6.0"
|
||||
|
||||
"@tauri-apps/plugin-dialog@~2":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.3.0.tgz#123d2cd3d98467b9b115d23ad71eef469d6ead35"
|
||||
|
||||
Reference in New Issue
Block a user