feat: 进程内动态库插件系统

This commit is contained in:
lanyeeee
2026-03-11 08:57:43 +08:00
parent 6be3289d93
commit 933f8000dd
26 changed files with 2011 additions and 52 deletions

View File

@@ -7,6 +7,7 @@ dist-ssr/
# Rust/Tauri backend # Rust/Tauri backend
src-tauri/ src-tauri/
src-plugin/
# Dependencies # Dependencies
node_modules/ node_modules/

3
src-plugin/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
target/

215
src-plugin/Cargo.lock generated Normal file
View File

@@ -0,0 +1,215 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bilibili-video-downloader-plugin-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "bilibili-video-downloader-plugin-sdk"
version = "0.1.0"
dependencies = [
"bilibili-video-downloader-plugin-api",
"eyre",
"parking_lot",
"serde_json",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
"indenter",
"once_cell",
]
[[package]]
name = "indenter"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

3
src-plugin/Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["plugin-api", "plugin-sdk"]
resolver = "3"

View File

@@ -0,0 +1,10 @@
[package]
name = "bilibili-video-downloader-plugin-api"
version = "0.1.0"
edition = "2024"
[lib]
name = "bilibili_video_downloader_plugin_api"
[dependencies]
serde = { version = "1", features = ["derive"] }

View File

@@ -0,0 +1,349 @@
pub const SDK_API_VERSION_V1: u32 = 1;
pub mod v1 {
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HookPointV1 {
AfterPrepare,
BeforeVideoProcess,
OnCompleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PluginFailurePolicy {
FailOpen,
FailClosed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginDescriptorV1 {
pub sdk_api_version: u32,
pub id: String,
pub name: String,
pub version: String,
pub hooks: Vec<HookPointV1>,
pub failure_policy: PluginFailurePolicy,
pub description: String,
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookReadonlyMetaV1 {
pub app_version: String,
pub os: String,
pub arch: String,
pub process_id: u32,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BeforeVideoProcessPayloadV1 {
pub progress: DownloadProgressV1,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AfterPreparePayloadV1 {
pub progress: DownloadProgressV1,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OnCompletedPayloadV1 {
pub progress: DownloadProgressV1,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HookPayloadV1 {
BeforeVideoProcess(BeforeVideoProcessPayloadV1),
AfterPrepare(AfterPreparePayloadV1),
OnCompleted(OnCompletedPayloadV1),
}
impl Default for HookPayloadV1 {
fn default() -> Self {
Self::BeforeVideoProcess(BeforeVideoProcessPayloadV1::default())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HookInputV1 {
pub hook_point: HookPointV1,
pub payload: HookPayloadV1,
pub readonly_meta: HookReadonlyMetaV1,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HookOutputV1 {
pub payload: HookPayloadV1,
}
pub type HostApiGetConfigJsonV1 =
unsafe extern "C" fn(out_ptr: *mut *mut u8, out_len: *mut usize) -> i32;
pub type HostApiFreeBufferV1 = unsafe extern "C" fn(ptr: *mut u8, len: usize);
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct HostApiV1 {
pub get_config_json: HostApiGetConfigJsonV1,
pub free_buffer: HostApiFreeBufferV1,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProxyModeV1 {
#[default]
NoProxy,
System,
Custom,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileExistActionV1 {
#[default]
Overwrite,
Skip,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CanvasConfigV1 {
pub duration: f64,
pub width: u32,
pub height: u32,
pub font: String,
pub font_size: u32,
pub width_ratio: f64,
pub horizontal_gap: f64,
pub lane_size: u32,
pub float_percentage: f64,
pub alpha: f64,
pub bold: bool,
pub outline: f64,
pub time_offset: f64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
#[allow(clippy::struct_field_names)]
pub struct HostConfigV1 {
pub download_dir: PathBuf,
pub enable_file_logger: bool,
pub sessdata: String,
pub video_quality_priority: Vec<VideoQualityV1>,
pub codec_type_priority: Vec<CodecTypeV1>,
pub audio_quality_priority: Vec<AudioQualityV1>,
pub download_video: bool,
pub download_audio: bool,
pub auto_merge: bool,
pub embed_chapter: bool,
pub embed_skip: bool,
pub download_xml_danmaku: bool,
pub download_ass_danmaku: bool,
pub download_json_danmaku: bool,
pub download_subtitle: bool,
pub download_cover: bool,
pub download_nfo: bool,
pub download_json: bool,
pub dir_fmt: String,
pub dir_fmt_for_part: String,
pub time_fmt: String,
pub proxy_mode: ProxyModeV1,
pub proxy_host: String,
pub proxy_port: u16,
pub task_concurrency: usize,
pub task_download_interval_sec: u64,
pub chunk_concurrency: usize,
pub chunk_download_interval_sec: u64,
pub danmaku_config: CanvasConfigV1,
pub file_exist_action: FileExistActionV1,
pub auto_start_download_task: bool,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EpisodeTypeV1 {
#[default]
Normal,
Bangumi,
Cheese,
}
#[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[repr(i64)]
pub enum VideoQualityV1 {
#[default]
Unknown = -1,
#[serde(rename = "240P")]
Video240P = 6,
#[serde(rename = "360P")]
Video360P = 16,
#[serde(rename = "480P")]
Video480P = 32,
#[serde(rename = "720P")]
Video720P = 64,
#[serde(rename = "720P60")]
Video720P60 = 74,
#[serde(rename = "1080P")]
Video1080P = 80,
#[serde(rename = "AiRepair")]
VideoAiRepair = 100,
#[serde(rename = "1080P+")]
Video1080PPlus = 112,
#[serde(rename = "1080P60")]
Video1080P60 = 116,
#[serde(rename = "4K")]
Video4K = 120,
#[serde(rename = "HDR")]
VideoHDR = 125,
#[serde(rename = "Dolby")]
VideoDolby = 126,
#[serde(rename = "8K")]
Video8K = 127,
}
#[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[repr(i64)]
pub enum AudioQualityV1 {
#[default]
Unknown = -1,
#[serde(rename = "64K")]
Audio64K = 30216,
#[serde(rename = "132K")]
Audio132K = 30232,
#[serde(rename = "192K")]
Audio192K = 30280,
#[serde(rename = "Dolby")]
AudioDolby = 30250,
#[serde(rename = "HiRes")]
AudioHiRes = 30251,
}
#[derive(Default, Debug, Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[repr(i64)]
pub enum CodecTypeV1 {
#[default]
Unknown = -1,
Audio = 0,
AVC = 7,
HEVC = 12,
AV1 = 13,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MediaChunkV1 {
pub start: u64,
pub end: u64,
pub completed: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoTaskV1 {
pub selected: bool,
pub url: String,
pub video_quality: VideoQualityV1,
pub codec_type: CodecTypeV1,
pub content_length: u64,
pub chunks: Vec<MediaChunkV1>,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AudioTaskV1 {
pub selected: bool,
pub url: String,
pub audio_quality: AudioQualityV1,
pub content_length: u64,
pub chunks: Vec<MediaChunkV1>,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct VideoProcessTaskV1 {
pub merge_selected: bool,
pub embed_chapter_selected: bool,
pub embed_skip_selected: bool,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SubtitleTaskV1 {
pub selected: bool,
pub completed: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct DanmakuTaskV1 {
pub xml_selected: bool,
pub ass_selected: bool,
pub json_selected: bool,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CoverTaskV1 {
pub selected: bool,
pub url: String,
pub completed: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct NfoTaskV1 {
pub selected: bool,
pub completed: bool,
pub skipped: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct JsonTaskV1 {
pub selected: bool,
pub completed: bool,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DownloadProgressV1 {
pub task_id: String,
pub episode_type: EpisodeTypeV1,
pub aid: i64,
pub bvid: Option<String>,
pub cid: i64,
pub ep_id: Option<i64>,
pub duration: u64,
pub pub_ts: i64,
pub collection_title: String,
pub part_title: Option<String>,
pub part_order: Option<i64>,
pub episode_title: String,
pub episode_order: i64,
pub up_name: Option<String>,
pub up_uid: Option<i64>,
pub up_avatar: Option<String>,
pub episode_dir: PathBuf,
pub filename: String,
pub video_task: VideoTaskV1,
pub audio_task: AudioTaskV1,
pub video_process_task: VideoProcessTaskV1,
pub subtitle_task: SubtitleTaskV1,
pub danmaku_task: DanmakuTaskV1,
pub cover_task: CoverTaskV1,
pub nfo_task: NfoTaskV1,
pub json_task: JsonTaskV1,
pub create_ts: u64,
pub completed_ts: Option<u64>,
pub is_drm: bool,
pub is_preview: bool,
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "bilibili-video-downloader-plugin-sdk"
version = "0.1.0"
edition = "2024"
[lib]
name = "bilibili_video_downloader_plugin_sdk"
[dependencies]
bilibili-video-downloader-plugin-api = { path = "../plugin-api" }
eyre = { version = "0.6.12" }
serde_json = { version = "1" }
parking_lot = { version = "0.12.5" }

View File

@@ -0,0 +1,191 @@
pub use bilibili_video_downloader_plugin_api::SDK_API_VERSION_V1 as SDK_API_VERSION;
pub use bilibili_video_downloader_plugin_api::v1::{
AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, CanvasConfigV1, DownloadProgressV1,
FileExistActionV1, HookInputV1, HookOutputV1, HookPayloadV1, HookPointV1, HostApiV1,
HostConfigV1, OnCompletedPayloadV1, PluginDescriptorV1, PluginFailurePolicy, ProxyModeV1,
};
pub use eyre;
pub use parking_lot;
pub use serde_json;
use std::sync::LazyLock;
use parking_lot::Mutex;
pub trait PluginV1: Default + Send + 'static {
fn descriptor(&self) -> PluginDescriptorV1;
#[allow(clippy::missing_errors_doc)]
fn on_hook(&mut self, input: HookInputV1) -> eyre::Result<HookOutputV1>;
}
static HOST_API_V1: LazyLock<Mutex<Option<HostApiV1>>> = LazyLock::new(|| Mutex::new(None));
#[doc(hidden)]
pub unsafe fn register_host_api_v1(api_ptr: *const HostApiV1) -> i32 {
if api_ptr.is_null() {
return 1;
}
let api = unsafe { *api_ptr };
*HOST_API_V1.lock() = Some(api);
0
}
fn get_host_api_v1() -> eyre::Result<HostApiV1> {
HOST_API_V1
.lock()
.as_ref()
.copied()
.ok_or_else(|| eyre::eyre!("host api 未注册"))
}
pub mod host {
use crate::HostConfigV1;
#[allow(clippy::missing_errors_doc)]
pub fn get_config() -> eyre::Result<HostConfigV1> {
let host_api = crate::get_host_api_v1()?;
let mut output_ptr: *mut u8 = std::ptr::null_mut();
let mut output_len: usize = 0;
let rc = unsafe { (host_api.get_config_json)(&raw mut output_ptr, &raw mut output_len) };
if rc != 0 {
return Err(eyre::eyre!("host get_config_json 调用失败: rc={rc}"));
}
if output_ptr.is_null() {
return Err(eyre::eyre!("host get_config_json 返回的缓冲区为空指针"));
}
let output_bytes = unsafe { std::slice::from_raw_parts(output_ptr, output_len) }.to_vec();
unsafe {
(host_api.free_buffer)(output_ptr, output_len);
}
let host_config = serde_json::from_slice::<HostConfigV1>(&output_bytes)?;
Ok(host_config)
}
}
#[macro_export]
macro_rules! export_plugin_v1 {
($ty:ty) => {
use std::ffi::{CString, c_char};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::LazyLock;
use $crate::parking_lot::Mutex;
fn to_cstring_lossy(value: String) -> CString {
// Replacing NUL guarantees CString invariants and avoids fallible construction.
let sanitized = value.replace('\0', " ");
unsafe { CString::from_vec_unchecked(sanitized.into_bytes()) }
}
static INSTANCE_V1: LazyLock<Mutex<$ty>> = LazyLock::new(|| Mutex::new(<$ty>::default()));
static DESCRIPTOR_JSON_V1: LazyLock<CString> = LazyLock::new(|| {
let instance = INSTANCE_V1.lock();
let descriptor = instance.descriptor();
let descriptor_json = match $crate::serde_json::to_string(&descriptor) {
Ok(json) => json,
Err(err) => format!("{{\"error\":\"序列化 descriptor 失败: {err}\"}}"),
};
to_cstring_lossy(descriptor_json)
});
static LAST_ERROR_V1: LazyLock<Mutex<CString>> =
LazyLock::new(|| Mutex::new(to_cstring_lossy(String::new())));
fn set_last_error_v1(message: String) {
let mut guard = LAST_ERROR_V1.lock();
*guard = to_cstring_lossy(message);
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_descriptor_v1")]
pub extern "C" fn descriptor_v1() -> *const c_char {
DESCRIPTOR_JSON_V1.as_ptr()
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_last_error_v1")]
pub extern "C" fn last_error_v1() -> *const c_char {
LAST_ERROR_V1.lock().as_ptr()
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_set_host_api_v1")]
pub unsafe extern "C" fn set_host_api_v1(api: *const $crate::HostApiV1) -> i32 {
let rc = unsafe { $crate::register_host_api_v1(api) };
if rc != 0 {
set_last_error_v1("无效的 host api 指针".to_string());
}
rc
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_on_hook_v1")]
pub unsafe extern "C" fn on_hook_v1(
input_ptr: *const u8,
input_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
if input_ptr.is_null() || out_ptr.is_null() || out_len.is_null() {
set_last_error_v1("参数里有空指针".to_string());
return 1;
}
let input_slice = unsafe { std::slice::from_raw_parts(input_ptr, input_len) };
let hook_input: $crate::HookInputV1 = match $crate::serde_json::from_slice(input_slice)
{
Ok(input) => input,
Err(err) => {
set_last_error_v1(format!("解析 hook 输入失败: {err}"));
return 2;
}
};
let hook_output = match catch_unwind(AssertUnwindSafe(|| {
let mut plugin = INSTANCE_V1.lock();
plugin.on_hook(hook_input)
})) {
Ok(Ok(output)) => output,
Ok(Err(err)) => {
set_last_error_v1(format!("{err:?}"));
return 3;
}
Err(_) => {
set_last_error_v1("处理 on_hook 时插件内部发生 panic".to_string());
return 5;
}
};
let output_bytes = match $crate::serde_json::to_vec(&hook_output) {
Ok(bytes) => bytes,
Err(err) => {
set_last_error_v1(format!("序列化 hook 输出失败: {err}"));
return 4;
}
};
let boxed = output_bytes.into_boxed_slice();
let len = boxed.len();
let ptr = Box::into_raw(boxed) as *mut u8;
unsafe {
*out_ptr = ptr;
*out_len = len;
}
0
}
#[unsafe(export_name = "bilibili_video_downloader_plugin_free_buffer_v1")]
pub unsafe extern "C" fn free_buffer_v1(ptr: *mut u8, len: usize) {
if ptr.is_null() || len == 0 {
return;
}
let raw_slice = std::ptr::slice_from_raw_parts_mut(ptr, len);
unsafe {
drop(Box::from_raw(raw_slice));
}
}
};
}

View File

@@ -0,0 +1,54 @@
use bilibili_video_downloader_plugin_sdk::{
AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, HookInputV1, HookOutputV1, HookPayloadV1,
HookPointV1, OnCompletedPayloadV1, PluginDescriptorV1, PluginFailurePolicy, PluginV1,
SDK_API_VERSION, export_plugin_v1, eyre, host,
};
#[derive(Default)]
struct MacroSmokePlugin;
impl PluginV1 for MacroSmokePlugin {
fn descriptor(&self) -> PluginDescriptorV1 {
PluginDescriptorV1 {
sdk_api_version: SDK_API_VERSION,
id: "macro-smoke".to_string(),
name: "Macro Smoke".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hooks: vec![HookPointV1::BeforeVideoProcess],
failure_policy: PluginFailurePolicy::FailOpen,
description: "Compile-time macro smoke test".to_string(),
}
}
fn on_hook(&mut self, input: HookInputV1) -> eyre::Result<HookOutputV1> {
let payload = match input.payload {
HookPayloadV1::BeforeVideoProcess(payload) => {
HookPayloadV1::BeforeVideoProcess(BeforeVideoProcessPayloadV1 {
progress: payload.progress,
})
}
HookPayloadV1::AfterPrepare(payload) => {
HookPayloadV1::AfterPrepare(AfterPreparePayloadV1 {
progress: payload.progress,
})
}
HookPayloadV1::OnCompleted(payload) => {
HookPayloadV1::OnCompleted(OnCompletedPayloadV1 {
progress: payload.progress,
})
}
};
Ok(HookOutputV1 { payload })
}
}
export_plugin_v1!(MacroSmokePlugin);
#[test]
fn macro_smoke_builds() {
assert_eq!(SDK_API_VERSION, 1);
let err = host::get_config().unwrap_err();
assert!(err.to_string().contains("host api 未注册"));
}

23
src-tauri/Cargo.lock generated
View File

@@ -286,9 +286,11 @@ name = "bilibili-video-downloader"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bilibili-video-downloader-plugin-api",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono", "chrono",
"dlopen2 0.8.2",
"eyre", "eyre",
"float-ord", "float-ord",
"fs4", "fs4",
@@ -323,6 +325,13 @@ dependencies = [
"yaserde", "yaserde",
] ]
[[package]]
name = "bilibili-video-downloader-plugin-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -861,6 +870,18 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "dlopen2"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
dependencies = [
"dlopen2_derive",
"libc",
"once_cell",
"winapi",
]
[[package]] [[package]]
name = "dlopen2_derive" name = "dlopen2_derive"
version = "0.4.1" version = "0.4.1"
@@ -4186,7 +4207,7 @@ dependencies = [
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dispatch", "dispatch",
"dlopen2", "dlopen2 0.7.0",
"dpi", "dpi",
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",

View File

@@ -22,6 +22,8 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
dlopen2 = { version = "0.8.2" }
bilibili-video-downloader-plugin-api = { path = "../src-plugin/plugin-api" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -63,4 +65,3 @@ strip = true
lto = true lto = true
codegen-units = 1 codegen-units = 1
panic = "abort" panic = "abort"

View File

@@ -32,6 +32,7 @@ use crate::{
history_info::HistoryInfo, history_info::HistoryInfo,
log_metadata::LogMetadata, log_metadata::LogMetadata,
normal_info::NormalInfo, normal_info::NormalInfo,
plugin_info::PluginInfo,
qrcode_data::QrcodeData, qrcode_data::QrcodeData,
qrcode_status::QrcodeStatus, qrcode_status::QrcodeStatus,
restart_download_task_params::RestartDownloadTaskParams, restart_download_task_params::RestartDownloadTaskParams,
@@ -64,15 +65,16 @@ pub fn save_config(app: AppHandle, config: Config) -> CommandResult<()> {
let bili_client = app.get_bili_client(); let bili_client = app.get_bili_client();
let config_state = app.get_config(); let config_state = app.get_config();
let proxy_changed = {
let config_state = config_state.read();
config_state.proxy_mode != config.proxy_mode
|| config_state.proxy_host != config.proxy_host
|| config_state.proxy_port != config.proxy_port
};
let enable_file_logger = config.enable_file_logger; let enable_file_logger = config.enable_file_logger;
let file_logger_changed = config_state.read().enable_file_logger != enable_file_logger; let (proxy_changed, file_logger_changed) = {
let current_config = config_state.read();
(
current_config.proxy_mode != config.proxy_mode
|| current_config.proxy_host != config.proxy_host
|| current_config.proxy_port != config.proxy_port,
current_config.enable_file_logger != enable_file_logger,
)
};
{ {
// 包裹在大括号中,以便自动释放写锁 // 包裹在大括号中,以便自动释放写锁
@@ -506,3 +508,71 @@ pub fn open_log_file(path: &str) -> CommandResult<Vec<LogMetadata>> {
Ok(logs) Ok(logs)
} }
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all)]
pub fn get_plugin_infos(app: AppHandle) -> Vec<PluginInfo> {
app.get_plugin_manager().get_plugin_infos()
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
pub fn add_plugin(app: AppHandle, plugin_path: String) -> CommandResult<()> {
let plugin_manager = app.get_plugin_manager();
plugin_manager
.add_plugin(&plugin_path)
.map_err(|err| CommandError::from("加载插件失败", err))?;
Ok(())
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
pub fn uninstall_plugin(app: AppHandle, plugin_path: String) -> CommandResult<()> {
let plugin_manager = app.get_plugin_manager();
plugin_manager
.uninstall_plugin(&plugin_path)
.map_err(|err| CommandError::from("卸载插件失败", err))?;
Ok(())
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, enabled = enabled))]
pub fn set_plugin_enabled(app: AppHandle, plugin_path: String, enabled: bool) -> CommandResult<()> {
let plugin_manager = app.get_plugin_manager();
plugin_manager
.set_plugin_enabled(&plugin_path, enabled)
.map_err(|err| CommandError::from("设置插件启用状态失败", err))?;
Ok(())
}
#[allow(clippy::needless_pass_by_value)]
#[tauri::command(async)]
#[specta::specta]
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, priority = priority))]
pub fn set_plugin_priority(
app: AppHandle,
plugin_path: String,
priority: i32,
) -> CommandResult<()> {
let plugin_manager = app.get_plugin_manager();
plugin_manager
.set_plugin_priority(&plugin_path, priority)
.map_err(|err| CommandError::from("设置插件优先级失败", err))?;
Ok(())
}

View File

@@ -24,6 +24,9 @@ use crate::{
}, },
events::DownloadEvent, events::DownloadEvent,
extensions::AppHandleExt, extensions::AppHandleExt,
plugin::hook_context::{
AfterPrepareContext, BeforeVideoProcessContext, HookContext, OnCompletedContext,
},
types::{ types::{
audio_quality::AudioQuality, audio_quality::AudioQuality,
bangumi_info::BangumiInfo, bangumi_info::BangumiInfo,
@@ -200,15 +203,23 @@ impl DownloadProgress {
} }
#[instrument(level = "error", skip_all)] #[instrument(level = "error", skip_all)]
#[allow(clippy::too_many_lines)]
pub async fn process(&mut self, download_task: &Arc<DownloadTask>) -> eyre::Result<()> { pub async fn process(&mut self, download_task: &Arc<DownloadTask>) -> eyre::Result<()> {
let app = &download_task.app;
let _ = DownloadEvent::ProgressPreparing { let _ = DownloadEvent::ProgressPreparing {
task_id: self.task_id.clone(), task_id: self.task_id.clone(),
} }
.emit(&download_task.app); .emit(app);
self.prepare(&download_task.app) self.prepare(app).await.wrap_err("准备下载失败")?;
.await
.wrap_err("准备下载失败")?; let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::AfterPrepare(AfterPrepareContext::new(self)))
.await?;
if *self != progress_before_hook {
download_task.update_progress(|p| *p = self.clone());
}
self.completed_ts = None; // 重置完成时间戳 self.completed_ts = None; // 重置完成时间戳
download_task.update_progress(|p| *p = self.clone()); download_task.update_progress(|p| *p = self.clone());
@@ -216,35 +227,36 @@ impl DownloadProgress {
std::fs::create_dir_all(&self.episode_dir) std::fs::create_dir_all(&self.episode_dir)
.wrap_err(format!("创建目录`{}`失败", self.episode_dir.display()))?; .wrap_err(format!("创建目录`{}`失败", self.episode_dir.display()))?;
let video_task = &self.video_task;
let audio_task = &self.audio_task;
let video_process_task = &self.video_process_task;
let danmaku_task = &self.danmaku_task;
let subtitle_task = &self.subtitle_task;
let cover_task = &self.cover_task;
let nfo_task = &self.nfo_task;
let json_task = &self.json_task;
let mut player_info = None; let mut player_info = None;
let mut episode_info = None; let mut episode_info = None;
if !video_task.is_completed() && video_task.content_length != 0 { if !self.video_task.is_completed() && self.video_task.content_length != 0 {
video_task self.video_task
.process(download_task, self) .process(download_task, self)
.await .await
.wrap_err("下载视频文件失败")?; .wrap_err("下载视频文件失败")?;
tracing::debug!("视频下载任务完成"); tracing::debug!("视频下载任务完成");
} }
if !audio_task.is_completed() && audio_task.content_length != 0 { if !self.audio_task.is_completed() && self.audio_task.content_length != 0 {
audio_task self.audio_task
.process(download_task, self) .process(download_task, self)
.await .await
.wrap_err("下载音频文件失败")?; .wrap_err("下载音频文件失败")?;
tracing::debug!("音频下载任务完成"); tracing::debug!("音频下载任务完成");
} }
let video_process_task_is_completed = video_process_task.is_completed(); let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::BeforeVideoProcess(
BeforeVideoProcessContext::new(self),
))
.await?;
if *self != progress_before_hook {
download_task.update_progress(|p| *p = self.clone());
}
let video_process_task_is_completed = self.video_process_task.is_completed();
if self.is_drm && !video_process_task_is_completed { if self.is_drm && !video_process_task_is_completed {
download_task.update_progress(|p| { download_task.update_progress(|p| {
p.video_process_task.skipped = true; p.video_process_task.skipped = true;
@@ -252,47 +264,47 @@ impl DownloadProgress {
}); });
tracing::debug!("受版权保护(DRM),无法处理,已跳过视频处理任务"); tracing::debug!("受版权保护(DRM),无法处理,已跳过视频处理任务");
} else if !video_process_task_is_completed { } else if !video_process_task_is_completed {
video_process_task self.video_process_task
.process(download_task, self, &mut player_info) .process(download_task, self, &mut player_info)
.await .await
.wrap_err("视频处理失败")?; .wrap_err("视频处理失败")?;
tracing::debug!("视频处理任务完成"); tracing::debug!("视频处理任务完成");
} }
if !danmaku_task.is_completed() { if !self.danmaku_task.is_completed() {
danmaku_task self.danmaku_task
.process(download_task, self) .process(download_task, self)
.await .await
.wrap_err("下载弹幕失败")?; .wrap_err("下载弹幕失败")?;
tracing::debug!("弹幕下载任务完成"); tracing::debug!("弹幕下载任务完成");
} }
if !subtitle_task.is_completed() { if !self.subtitle_task.is_completed() {
subtitle_task self.subtitle_task
.process(download_task, self, &mut player_info) .process(download_task, self, &mut player_info)
.await .await
.wrap_err("下载字幕失败")?; .wrap_err("下载字幕失败")?;
tracing::debug!("字幕下载任务完成"); tracing::debug!("字幕下载任务完成");
} }
if !cover_task.is_completed() { if !self.cover_task.is_completed() {
cover_task self.cover_task
.process(download_task, self) .process(download_task, self)
.await .await
.wrap_err("下载封面失败")?; .wrap_err("下载封面失败")?;
tracing::debug!("封面下载任务完成"); tracing::debug!("封面下载任务完成");
} }
if !nfo_task.is_completed() { if !self.nfo_task.is_completed() {
nfo_task self.nfo_task
.process(download_task, self, &mut episode_info) .process(download_task, self, &mut episode_info)
.await .await
.wrap_err("下载NFO失败")?; .wrap_err("下载NFO失败")?;
tracing::debug!("NFO下载任务完成"); tracing::debug!("NFO下载任务完成");
} }
if !json_task.is_completed() { if !self.json_task.is_completed() {
json_task self.json_task
.process(download_task, self, &mut episode_info) .process(download_task, self, &mut episode_info)
.await .await
.wrap_err("下载JSON元数据失败")?; .wrap_err("下载JSON元数据失败")?;
@@ -303,8 +315,17 @@ impl DownloadProgress {
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.ok(); .ok();
if completed_ts.is_some() { if let Some(completed_ts) = completed_ts {
download_task.update_progress(|p| p.completed_ts = completed_ts); self.completed_ts = Some(completed_ts);
download_task.update_progress(|p| p.completed_ts = Some(completed_ts));
}
let progress_before_hook = self.clone();
app.get_plugin_manager()
.run_hook(HookContext::OnCompleted(OnCompletedContext::new(self)))
.await?;
if *self != progress_before_hook {
download_task.update_progress(|p| *p = self.clone());
} }
Ok(()) Ok(())

View File

@@ -5,6 +5,7 @@ use tauri_specta::Event;
use crate::downloader::{ use crate::downloader::{
download_progress::DownloadProgress, download_task_state::DownloadTaskState, download_progress::DownloadProgress, download_task_state::DownloadTaskState,
}; };
use crate::types::plugin_info::PluginInfo;
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -46,3 +47,11 @@ pub enum DownloadEvent {
progress: DownloadProgress, progress: DownloadProgress,
}, },
} }
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(tag = "event", content = "data")]
pub enum PluginEvent {
Loaded { plugin_info: PluginInfo },
Update { plugin_info: PluginInfo },
Uninstall { plugin_path: String },
}

View File

@@ -7,6 +7,7 @@ use crate::{
bili_client::BiliClient, bili_client::BiliClient,
config::Config, config::Config,
downloader::{download_manager::DownloadManager, download_progress::DownloadProgress}, downloader::{download_manager::DownloadManager, download_progress::DownloadProgress},
plugin::plugin_manager::PluginManager,
types::player_info::PlayerInfo, types::player_info::PlayerInfo,
}; };
@@ -24,6 +25,7 @@ pub trait AppHandleExt {
fn get_config(&self) -> State<'_, RwLock<Config>>; fn get_config(&self) -> State<'_, RwLock<Config>>;
fn get_bili_client(&self) -> State<'_, BiliClient>; fn get_bili_client(&self) -> State<'_, BiliClient>;
fn get_download_manager(&self) -> State<'_, DownloadManager>; fn get_download_manager(&self) -> State<'_, DownloadManager>;
fn get_plugin_manager(&self) -> State<'_, PluginManager>;
} }
impl AppHandleExt for AppHandle { impl AppHandleExt for AppHandle {
@@ -36,6 +38,9 @@ impl AppHandleExt for AppHandle {
fn get_download_manager(&self) -> State<'_, DownloadManager> { fn get_download_manager(&self) -> State<'_, DownloadManager> {
self.state::<DownloadManager>() self.state::<DownloadManager>()
} }
fn get_plugin_manager(&self) -> State<'_, PluginManager> {
self.state::<PluginManager>()
}
} }
pub trait GetOrInitPlayerInfo { pub trait GetOrInitPlayerInfo {

View File

@@ -7,6 +7,7 @@ mod errors;
mod events; mod events;
mod extensions; mod extensions;
mod logger; mod logger;
mod plugin;
mod types; mod types;
mod utils; mod utils;
mod wbi; mod wbi;
@@ -16,14 +17,14 @@ mod protobuf {
} }
use commands::{ use commands::{
create_download_tasks, delete_download_tasks, generate_qrcode, get_available_media_formats, add_plugin, create_download_tasks, delete_download_tasks, generate_qrcode,
get_bangumi_follow_info, get_bangumi_info, get_config, get_fav_folders, get_fav_info, get_available_media_formats, get_bangumi_follow_info, get_bangumi_info, get_config,
get_history_info, get_logs_dir_size, get_normal_info, get_qrcode_status, get_skip_segments, get_fav_folders, get_fav_info, get_history_info, get_logs_dir_size, get_normal_info,
get_user_info, get_user_video_info, get_watch_later_info, pause_download_tasks, get_plugin_infos, get_qrcode_status, get_skip_segments, get_user_info, get_user_video_info,
restart_download_task, restart_download_tasks, restore_download_tasks, resume_download_tasks, get_watch_later_info, pause_download_tasks, restart_download_task, restart_download_tasks,
save_config, search, show_path_in_file_manager, restore_download_tasks, resume_download_tasks, save_config, search, set_plugin_enabled,
set_plugin_priority, show_path_in_file_manager, uninstall_plugin,
}; };
use config::Config;
use eyre::WrapErr; use eyre::WrapErr;
use parking_lot::RwLock; use parking_lot::RwLock;
use tauri::{Manager, Wry}; use tauri::{Manager, Wry};
@@ -31,9 +32,11 @@ use tauri::{Manager, Wry};
use crate::{ use crate::{
bili_client::BiliClient, bili_client::BiliClient,
commands::open_log_file, commands::open_log_file,
config::Config,
downloader::download_manager::DownloadManager, downloader::download_manager::DownloadManager,
errors::install_custom_eyre_handler, errors::install_custom_eyre_handler,
events::{DownloadEvent, LogEvent}, events::{DownloadEvent, LogEvent, PluginEvent},
plugin::plugin_manager::PluginManager,
}; };
fn generate_context() -> tauri::Context<Wry> { fn generate_context() -> tauri::Context<Wry> {
@@ -49,6 +52,7 @@ pub fn run() {
.commands(tauri_specta::collect_commands![ .commands(tauri_specta::collect_commands![
get_config, get_config,
save_config, save_config,
get_plugin_infos,
generate_qrcode, generate_qrcode,
get_qrcode_status, get_qrcode_status,
get_user_info, get_user_info,
@@ -73,8 +77,16 @@ pub fn run() {
get_skip_segments, get_skip_segments,
get_available_media_formats, get_available_media_formats,
open_log_file, open_log_file,
add_plugin,
uninstall_plugin,
set_plugin_enabled,
set_plugin_priority,
]) ])
.events(tauri_specta::collect_events![LogEvent, DownloadEvent]); .events(tauri_specta::collect_events![
LogEvent,
DownloadEvent,
PluginEvent,
]);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
builder builder
@@ -122,6 +134,9 @@ pub fn run() {
logger::init(app.handle())?; logger::init(app.handle())?;
let plugin_manager = PluginManager::new(app.handle())?;
app.manage(plugin_manager);
Ok(()) Ok(())
}) })
.run(generate_context()) .run(generate_context())

6
src-tauri/src/plugin.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod hook_context;
pub mod host_api;
pub mod plugin_executor;
pub mod plugin_loader;
pub mod plugin_manager;
pub mod plugin_types;

View File

@@ -0,0 +1,189 @@
use bilibili_video_downloader_plugin_api::v1::{
AfterPreparePayloadV1, BeforeVideoProcessPayloadV1, DownloadProgressV1, HookInputV1,
HookOutputV1, HookPayloadV1, HookPointV1, HookReadonlyMetaV1, OnCompletedPayloadV1,
};
use eyre::{WrapErr, eyre};
use serde::{Serialize, de::DeserializeOwned};
use crate::downloader::download_progress::DownloadProgress;
pub struct BeforeVideoProcessContext<'a> {
progress: &'a mut DownloadProgress,
}
impl<'a> BeforeVideoProcessContext<'a> {
pub fn new(progress: &'a mut DownloadProgress) -> Self {
Self { progress }
}
fn to_payload(&self) -> eyre::Result<BeforeVideoProcessPayloadV1> {
Ok(BeforeVideoProcessPayloadV1 {
progress: host_to_api_progress(self.progress)?,
})
}
fn apply_payload(&mut self, payload: BeforeVideoProcessPayloadV1) -> eyre::Result<()> {
validate_task_id_unchanged(self.progress, &payload.progress)?;
let next_progress = api_to_host_progress(payload.progress)?;
*self.progress = next_progress;
Ok(())
}
}
pub struct OnCompletedContext<'a> {
progress: &'a mut DownloadProgress,
}
impl<'a> OnCompletedContext<'a> {
pub fn new(progress: &'a mut DownloadProgress) -> Self {
Self { progress }
}
fn to_payload(&self) -> eyre::Result<OnCompletedPayloadV1> {
Ok(OnCompletedPayloadV1 {
progress: host_to_api_progress(self.progress)?,
})
}
fn apply_payload(&mut self, payload: OnCompletedPayloadV1) -> eyre::Result<()> {
validate_task_id_unchanged(self.progress, &payload.progress)?;
let next_progress = api_to_host_progress(payload.progress)?;
*self.progress = next_progress;
Ok(())
}
}
pub struct AfterPrepareContext<'a> {
progress: &'a mut DownloadProgress,
}
impl<'a> AfterPrepareContext<'a> {
pub fn new(progress: &'a mut DownloadProgress) -> Self {
Self { progress }
}
fn to_payload(&self) -> eyre::Result<AfterPreparePayloadV1> {
Ok(AfterPreparePayloadV1 {
progress: host_to_api_progress(self.progress)?,
})
}
fn apply_payload(&mut self, payload: AfterPreparePayloadV1) -> eyre::Result<()> {
validate_task_id_unchanged(self.progress, &payload.progress)?;
let next_progress = api_to_host_progress(payload.progress)?;
*self.progress = next_progress;
Ok(())
}
}
pub enum HookContext<'a> {
BeforeVideoProcess(BeforeVideoProcessContext<'a>),
AfterPrepare(AfterPrepareContext<'a>),
OnCompleted(OnCompletedContext<'a>),
}
impl HookContext<'_> {
pub fn hook_point(&self) -> HookPointV1 {
match self {
HookContext::BeforeVideoProcess(_) => HookPointV1::BeforeVideoProcess,
HookContext::AfterPrepare(_) => HookPointV1::AfterPrepare,
HookContext::OnCompleted(_) => HookPointV1::OnCompleted,
}
}
pub fn to_input(&self, app_version: &str) -> eyre::Result<HookInputV1> {
let hook_point = self.hook_point();
let payload = match self {
HookContext::BeforeVideoProcess(context) => {
HookPayloadV1::BeforeVideoProcess(context.to_payload()?)
}
HookContext::AfterPrepare(context) => {
HookPayloadV1::AfterPrepare(context.to_payload()?)
}
HookContext::OnCompleted(context) => HookPayloadV1::OnCompleted(context.to_payload()?),
};
let input = HookInputV1 {
hook_point,
payload,
readonly_meta: HookReadonlyMetaV1 {
app_version: app_version.to_string(),
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
process_id: std::process::id(),
},
};
Ok(input)
}
pub fn apply_output(&mut self, output: HookOutputV1) -> eyre::Result<()> {
let context_hook_point = self.hook_point();
match (self, output.payload) {
(
HookContext::BeforeVideoProcess(context),
HookPayloadV1::BeforeVideoProcess(payload),
) => context.apply_payload(payload),
(HookContext::AfterPrepare(context), HookPayloadV1::AfterPrepare(payload)) => {
context.apply_payload(payload)
}
(HookContext::OnCompleted(context), HookPayloadV1::OnCompleted(payload)) => {
context.apply_payload(payload)
}
(_, payload) => Err(eyre!(
"hook_point 与 payload 不匹配: hook_point={context_hook_point:?}, payload={payload:?}"
)),
}
}
}
fn validate_task_id_unchanged(
current_progress: &DownloadProgress,
next_progress: &DownloadProgressV1,
) -> eyre::Result<()> {
if current_progress.task_id != next_progress.task_id {
return Err(eyre!("task_id 不可修改"));
}
Ok(())
}
fn host_to_api_progress(progress: &DownloadProgress) -> eyre::Result<DownloadProgressV1> {
convert_via_json(
progress,
"序列化宿主 DownloadProgress 失败",
"反序列化为插件 DownloadProgressV1 失败",
)
}
fn api_to_host_progress(progress: DownloadProgressV1) -> eyre::Result<DownloadProgress> {
convert_via_json(
progress,
"序列化插件 DownloadProgressV1 失败",
"反序列化为宿主 DownloadProgress 失败",
)
}
fn convert_via_json<TSrc, TDst>(
source: TSrc,
serialize_err: &str,
deserialize_err: &str,
) -> eyre::Result<TDst>
where
TSrc: Serialize,
TDst: DeserializeOwned,
{
let value = serde_json::to_value(source).wrap_err_with(|| serialize_err.to_string())?;
serde_json::from_value(value).wrap_err_with(|| deserialize_err.to_string())
}

View File

@@ -0,0 +1,67 @@
use std::sync::OnceLock;
use bilibili_video_downloader_plugin_api::v1::{HostApiV1, HostConfigV1};
use eyre::WrapErr;
use tauri::AppHandle;
use crate::{config::Config, extensions::AppHandleExt};
static HOST_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
pub fn init(app: &AppHandle) {
HOST_APP_HANDLE.get_or_init(|| app.clone());
}
pub fn build_host_api_v1() -> HostApiV1 {
HostApiV1 {
get_config_json: host_get_config_json_v1,
free_buffer: host_free_buffer_v1,
}
}
unsafe extern "C" fn host_get_config_json_v1(out_ptr: *mut *mut u8, out_len: *mut usize) -> i32 {
if out_ptr.is_null() || out_len.is_null() {
return 1;
}
let Some(app) = HOST_APP_HANDLE.get() else {
return 2;
};
let host_config = app.get_config().read().clone();
let Ok(host_config_v1) = to_host_config_v1(&host_config) else {
return 3;
};
let Ok(output_bytes) = serde_json::to_vec(&host_config_v1) else {
return 3;
};
let boxed = output_bytes.into_boxed_slice();
let len = boxed.len();
let ptr = Box::into_raw(boxed).cast::<u8>();
unsafe {
*out_ptr = ptr;
*out_len = len;
}
0
}
unsafe extern "C" fn host_free_buffer_v1(ptr: *mut u8, len: usize) {
if ptr.is_null() || len == 0 {
return;
}
let raw_slice = std::ptr::slice_from_raw_parts_mut(ptr, len);
unsafe {
drop(Box::from_raw(raw_slice));
}
}
fn to_host_config_v1(config: &Config) -> eyre::Result<HostConfigV1> {
let value = serde_json::to_value(config).wrap_err("序列化宿主 Config 失败")?;
let host_config = serde_json::from_value(value).wrap_err("反序列化为插件 HostConfigV1 失败")?;
Ok(host_config)
}

View File

@@ -0,0 +1,66 @@
use std::{ffi::CStr, sync::Arc};
use bilibili_video_downloader_plugin_api::v1::{HookInputV1, HookOutputV1};
use dlopen2::wrapper::Container;
use eyre::eyre;
use tracing::instrument;
use crate::plugin::plugin_types::{PluginDylibApi, PluginRuntime};
#[instrument(level = "error", skip_all, fields(plugin_name = plugin.display_name(), hook_point = ?input.hook_point))]
pub async fn execute_hook(
plugin: &PluginRuntime,
input: &HookInputV1,
) -> eyre::Result<HookOutputV1> {
let input_bytes = serde_json::to_vec(input)?;
let api = plugin.api.clone();
let (tx, rx) = tokio::sync::oneshot::channel::<eyre::Result<Vec<u8>>>();
tauri::async_runtime::spawn_blocking(move || {
let result = call_on_hook_blocking(api, &input_bytes);
let _ = tx.send(result);
});
let output_bytes = rx.await??;
let output: HookOutputV1 = serde_json::from_slice(&output_bytes)?;
Ok(output)
}
#[instrument(level = "error", skip_all)]
#[allow(clippy::needless_pass_by_value)]
fn call_on_hook_blocking(
api: Arc<Container<PluginDylibApi>>,
input_bytes: &[u8],
) -> eyre::Result<Vec<u8>> {
let mut output_ptr: *mut u8 = std::ptr::null_mut();
let mut output_len: usize = 0;
let rc = unsafe {
api.on_hook(
input_bytes.as_ptr(),
input_bytes.len(),
&raw mut output_ptr,
&raw mut output_len,
)
};
if rc != 0 {
let detail = get_last_error(&api);
return Err(eyre!("插件返回错误码: code={rc}, detail={detail}"));
}
if output_ptr.is_null() {
return Err(eyre!("插件返回空输出缓冲区"));
}
let output_bytes = unsafe { std::slice::from_raw_parts(output_ptr, output_len) }.to_vec();
unsafe { api.free_buffer(output_ptr, output_len) };
Ok(output_bytes)
}
fn get_last_error(api: &Arc<Container<PluginDylibApi>>) -> String {
let error_ptr = unsafe { api.last_error() };
if error_ptr.is_null() {
return "获取错误信息失败error_ptr为null".to_string();
}
let error_cstr = unsafe { CStr::from_ptr(error_ptr) };
error_cstr.to_string_lossy().to_string()
}

View File

@@ -0,0 +1,78 @@
use std::{ffi::CStr, path::Path, sync::Arc};
use bilibili_video_downloader_plugin_api::{SDK_API_VERSION_V1, v1::PluginDescriptorV1};
use dlopen2::wrapper::Container;
use eyre::{WrapErr, eyre};
use tracing::instrument;
use crate::plugin::{
host_api,
plugin_types::{PluginDylibApi, PluginRuntime},
};
#[instrument(level = "error", skip_all, fields(plugin_path = %plugin_path.display(), priority = priority, enabled = enabled))]
pub fn load_plugin_from_path(
plugin_path: &Path,
priority: i32,
enabled: bool,
) -> eyre::Result<PluginRuntime> {
if !plugin_path.is_absolute() {
return Err(eyre!("插件路径必须是绝对路径: `{}`", plugin_path.display()));
}
if !plugin_path.exists() {
return Err(eyre!("插件动态库文件`{}`不存在", plugin_path.display()));
}
let api = unsafe { Container::<PluginDylibApi>::load(plugin_path) }
.wrap_err(format!("加载插件动态库文件`{}`失败", plugin_path.display()))?;
let descriptor_json = get_descriptor_json(&api).wrap_err("读取插件描述失败")?;
let descriptor: PluginDescriptorV1 = serde_json::from_str(&descriptor_json)
.wrap_err(format!("解析插件描述失败: {descriptor_json}"))?;
if descriptor.sdk_api_version != SDK_API_VERSION_V1 {
return Err(eyre!(
"插件SDK版本不匹配: 期望版本={}, 实际版本={}",
SDK_API_VERSION_V1,
descriptor.sdk_api_version
));
}
if descriptor.id.trim().is_empty() {
return Err(eyre!("descriptor.id 为空"));
}
if descriptor.hooks.is_empty() {
return Err(eyre!("插件未声明任何可执行 Hook"));
}
let host_api = host_api::build_host_api_v1();
let rc = unsafe { api.set_host_api(&raw const host_api) };
if rc != 0 {
return Err(eyre!(
"注册宿主 Host API 失败: plugin_id={}, rc={rc}",
descriptor.id
));
}
Ok(PluginRuntime {
descriptor,
plugin_path: plugin_path.to_path_buf(),
enabled,
priority,
api: Arc::new(api),
})
}
#[instrument(level = "error", skip_all)]
fn get_descriptor_json(api: &Container<PluginDylibApi>) -> eyre::Result<String> {
let descriptor_ptr = unsafe { api.descriptor() };
if descriptor_ptr.is_null() {
return Err(eyre!("descriptor 指针为空"));
}
let descriptor_cstr = unsafe { CStr::from_ptr(descriptor_ptr).to_str() }
.wrap_err("descriptor 非 UTF-8 字符串")?;
Ok(descriptor_cstr.to_string())
}

View File

@@ -0,0 +1,351 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use eyre::eyre;
use parking_lot::RwLock;
use tauri::{AppHandle, Manager};
use tauri_specta::Event;
use tracing::instrument;
use crate::{
events::PluginEvent,
extensions::EyreReportToMessage,
types::plugin_info::{PluginDescriptorInfo, PluginInfo, PluginMetadata, PluginRuntimeStatus},
};
use super::{
hook_context::HookContext, host_api, plugin_executor, plugin_loader,
plugin_types::PluginRuntime,
};
pub struct PluginManager {
app: AppHandle,
infos: RwLock<HashMap<String, PluginInfo>>,
runtimes: RwLock<Vec<PluginRuntime>>,
}
impl PluginManager {
#[instrument(level = "error", skip_all)]
pub fn new(app: &AppHandle) -> eyre::Result<PluginManager> {
host_api::init(app);
let app_data_dir = app.path().app_data_dir()?;
let plugin_json_path = app_data_dir.join("plugin.json");
let mut infos = HashMap::new();
if plugin_json_path.exists() {
let json_string = std::fs::read_to_string(&plugin_json_path)?;
let metadata_map: HashMap<String, PluginMetadata> =
serde_json::from_str(&json_string).unwrap_or_default();
for (plugin_path, metadata) in metadata_map {
let status = PluginRuntimeStatus::Unknown;
let info = PluginInfo::from_metadata(metadata, status);
infos.insert(plugin_path, info);
}
}
let mut runtimes = Vec::new();
for info in infos.values_mut() {
if !info.enabled {
info.runtime_status = PluginRuntimeStatus::Disabled;
continue;
}
match plugin_loader::load_plugin_from_path(&info.path, info.priority, true) {
Ok(runtime) => {
tracing::info!(
"插件加载成功: plugin_name={}, plugin_path={}",
runtime.display_name(),
runtime.plugin_path.display()
);
info.runtime_status = PluginRuntimeStatus::Loaded;
info.descriptor = PluginDescriptorInfo::from_descriptor(&runtime.descriptor);
insert_runtime_by_priority(&mut runtimes, runtime);
}
Err(err) => {
let err_title = "某个插件加载失败,已跳过";
let message = err.to_message();
tracing::error!(err_title, message);
info.runtime_status = PluginRuntimeStatus::LoadFailed;
}
}
}
let plugin_manager = Self {
app: app.clone(),
infos: RwLock::new(infos),
runtimes: RwLock::new(runtimes),
};
plugin_manager.save_metadata()?;
Ok(plugin_manager)
}
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
pub fn add_plugin(&self, plugin_path: &str) -> eyre::Result<()> {
let runtime = plugin_loader::load_plugin_from_path(&PathBuf::from(plugin_path), 0, true)?;
let plugin_info = {
let mut infos = self.infos.write();
if infos.contains_key(plugin_path) {
return Err(eyre!("插件已存在: {plugin_path}"));
}
let status = PluginRuntimeStatus::Loaded;
let metadata = PluginMetadata::from_plugin_runtime(&runtime);
let info = PluginInfo::from_metadata(metadata, status);
infos.insert(plugin_path.to_string(), info.clone());
info
};
{
let mut runtimes = self.runtimes.write();
insert_runtime_by_priority(&mut runtimes, runtime);
}
self.save_metadata()?;
let _ = PluginEvent::Loaded { plugin_info }.emit(&self.app);
Ok(())
}
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path))]
pub fn uninstall_plugin(&self, plugin_path: &str) -> eyre::Result<()> {
{
let mut infos = self.infos.write();
if !infos.contains_key(plugin_path) {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
}
infos.remove(plugin_path);
}
{
let mut runtimes = self.runtimes.write();
remove_runtime_by_path(&mut runtimes, Path::new(plugin_path));
}
let _ = PluginEvent::Uninstall {
plugin_path: plugin_path.to_string(),
}
.emit(&self.app);
self.save_metadata()?;
Ok(())
}
#[instrument(level = "error", skip_all, fields(plugin_path = plugin_path, enabled = enabled))]
pub fn set_plugin_enabled(&self, plugin_path: &str, enabled: bool) -> eyre::Result<()> {
if !enabled {
let plugin_info = {
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
if info.enabled == enabled {
return Ok(());
}
info.enabled = false;
info.runtime_status = PluginRuntimeStatus::Disabled;
info.clone()
};
{
let mut runtimes = self.runtimes.write();
remove_runtime_by_path(&mut runtimes, Path::new(plugin_path));
}
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
self.save_metadata()?;
return Ok(());
}
let (plugin_file_path, priority) = {
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
if info.enabled == enabled {
return Ok(());
}
info.enabled = true;
(info.path.clone(), info.priority)
};
let plugin_info =
match plugin_loader::load_plugin_from_path(&plugin_file_path, priority, true) {
Ok(runtime) => {
{
let mut runtimes = self.runtimes.write();
remove_runtime_by_path(&mut runtimes, &plugin_file_path);
insert_runtime_by_priority(&mut runtimes, runtime.clone());
}
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
info.runtime_status = PluginRuntimeStatus::Loaded;
info.descriptor = PluginDescriptorInfo::from_descriptor(&runtime.descriptor);
info.clone()
}
Err(err) => {
let err_title = "启用插件时加载失败";
let message = err.to_message();
tracing::error!(err_title, message);
{
let mut runtimes = self.runtimes.write();
remove_runtime_by_path(&mut runtimes, &plugin_file_path);
}
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
info.runtime_status = PluginRuntimeStatus::LoadFailed;
info.clone()
}
};
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
self.save_metadata()?;
Ok(())
}
#[instrument(
level = "error",
skip_all,
fields(plugin_path = plugin_path, priority = priority)
)]
pub fn set_plugin_priority(&self, plugin_path: &str, priority: i32) -> eyre::Result<()> {
let plugin_info = {
let mut infos = self.infos.write();
let Some(info) = infos.get_mut(plugin_path) else {
return Err(eyre!("key中没有插件路径: {plugin_path}"));
};
if info.priority == priority {
return Ok(());
}
info.priority = priority;
info.clone()
};
{
let mut runtimes = self.runtimes.write();
if let Some(mut runtime) = remove_runtime_by_path(&mut runtimes, Path::new(plugin_path))
{
runtime.priority = priority;
insert_runtime_by_priority(&mut runtimes, runtime);
}
}
let _ = PluginEvent::Update { plugin_info }.emit(&self.app);
self.save_metadata()?;
Ok(())
}
pub fn get_plugin_infos(&self) -> Vec<PluginInfo> {
self.infos.read().values().cloned().collect()
}
#[instrument(level = "error", skip_all)]
pub async fn run_hook(&self, mut context: HookContext<'_>) -> eyre::Result<()> {
let hook_point = context.hook_point();
let runtimes = self.runtimes.read().clone();
if runtimes.is_empty() {
return Ok(());
}
let app_version = self.app.package_info().version.to_string();
for runtime in &runtimes {
if !runtime.enabled || !runtime.should_run_hook(hook_point) {
continue;
}
let input = context.to_input(&app_version)?;
let output = match plugin_executor::execute_hook(runtime, &input).await {
Ok(output) => output,
Err(err) => match runtime.descriptor.failure_policy {
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailOpen => {
let err_title = "插件执行出错,按照 FailOpen 继续其他任务";
let message = err.to_message();
tracing::error!(err_title, message);
continue;
}
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailClosed => {
let err = err.wrap_err("插件执行出错,按照 FailClosed 中断任务");
return Err(err);
}
},
};
if let Err(err) = context.apply_output(output) {
match runtime.descriptor.failure_policy {
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailOpen => {
let err_title = "插件输出无效,按照 FailOpen 继续其他任务";
let message = err.to_message();
tracing::error!(err_title, message);
}
bilibili_video_downloader_plugin_api::v1::PluginFailurePolicy::FailClosed => {
let err = err.wrap_err("插件输出无效,按照 FailClosed 中断任务");
return Err(err);
}
}
}
}
Ok(())
}
#[instrument(level = "error", skip_all)]
fn save_metadata(&self) -> eyre::Result<()> {
let app_data_dir = self.app.path().app_data_dir()?;
let plugin_json_path = app_data_dir.join("plugin.json");
let metadata_by_path: HashMap<String, PluginMetadata> = self
.infos
.read()
.clone()
.into_iter()
.map(|(plugin_path, info)| (plugin_path, info.into_metadata()))
.collect();
let json_string = serde_json::to_string_pretty(&metadata_by_path)?;
std::fs::write(plugin_json_path, json_string)?;
Ok(())
}
}
fn insert_runtime_by_priority(runtimes: &mut Vec<PluginRuntime>, runtime: PluginRuntime) {
let insert_idx = runtimes
.iter()
.position(|existing| existing.priority < runtime.priority)
.unwrap_or(runtimes.len());
runtimes.insert(insert_idx, runtime);
}
fn remove_runtime_by_path(
runtimes: &mut Vec<PluginRuntime>,
plugin_path: &Path,
) -> Option<PluginRuntime> {
let remove_idx = runtimes
.iter()
.position(|runtime| runtime.plugin_path == plugin_path)?;
Some(runtimes.remove(remove_idx))
}

View File

@@ -0,0 +1,45 @@
use std::{ffi::c_char, path::PathBuf, sync::Arc};
use bilibili_video_downloader_plugin_api::v1::{HookPointV1, HostApiV1, PluginDescriptorV1};
use dlopen2::wrapper::{Container, WrapperApi};
#[derive(WrapperApi)]
pub struct PluginDylibApi {
#[dlopen2_name = "bilibili_video_downloader_plugin_descriptor_v1"]
descriptor: unsafe extern "C" fn() -> *const c_char,
#[dlopen2_name = "bilibili_video_downloader_plugin_on_hook_v1"]
on_hook: unsafe extern "C" fn(
input_ptr: *const u8,
input_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32,
#[dlopen2_name = "bilibili_video_downloader_plugin_free_buffer_v1"]
free_buffer: unsafe extern "C" fn(ptr: *mut u8, len: usize),
#[dlopen2_name = "bilibili_video_downloader_plugin_last_error_v1"]
last_error: unsafe extern "C" fn() -> *const c_char,
#[dlopen2_name = "bilibili_video_downloader_plugin_set_host_api_v1"]
set_host_api: unsafe extern "C" fn(api: *const HostApiV1) -> i32,
}
#[derive(Clone)]
pub struct PluginRuntime {
pub descriptor: PluginDescriptorV1,
pub plugin_path: PathBuf,
pub enabled: bool,
pub priority: i32,
pub api: Arc<Container<PluginDylibApi>>,
}
impl PluginRuntime {
pub fn display_name(&self) -> String {
format!(
"{} ({}, v{})",
self.descriptor.name, self.descriptor.id, self.descriptor.version
)
}
pub fn should_run_hook(&self, hook: HookPointV1) -> bool {
self.descriptor.hooks.contains(&hook)
}
}

View File

@@ -23,6 +23,7 @@ pub mod log_metadata;
pub mod normal_info; pub mod normal_info;
pub mod normal_media_url; pub mod normal_media_url;
pub mod player_info; pub mod player_info;
pub mod plugin_info;
pub mod qrcode_data; pub mod qrcode_data;
pub mod qrcode_status; pub mod qrcode_status;
pub mod restart_download_task_params; pub mod restart_download_task_params;

View File

@@ -0,0 +1,131 @@
use std::path::PathBuf;
use bilibili_video_downloader_plugin_api::v1::{
HookPointV1, PluginDescriptorV1, PluginFailurePolicy,
};
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::plugin::plugin_types::PluginRuntime;
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
pub enum PluginHookPoint {
#[default]
BeforeVideoProcess,
AfterPrepare,
OnCompleted,
}
impl From<HookPointV1> for PluginHookPoint {
fn from(value: HookPointV1) -> Self {
match value {
HookPointV1::BeforeVideoProcess => Self::BeforeVideoProcess,
HookPointV1::AfterPrepare => Self::AfterPrepare,
HookPointV1::OnCompleted => Self::OnCompleted,
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
pub enum PluginFailurePolicyInfo {
#[default]
FailOpen,
FailClosed,
}
impl From<PluginFailurePolicy> for PluginFailurePolicyInfo {
fn from(value: PluginFailurePolicy) -> Self {
match value {
PluginFailurePolicy::FailOpen => Self::FailOpen,
PluginFailurePolicy::FailClosed => Self::FailClosed,
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct PluginDescriptorInfo {
pub sdk_api_version: u32,
pub id: String,
pub name: String,
pub version: String,
pub hooks: Vec<PluginHookPoint>,
pub failure_policy: PluginFailurePolicyInfo,
pub description: String,
}
impl PluginDescriptorInfo {
pub fn from_descriptor(descriptor: &PluginDescriptorV1) -> Self {
Self {
sdk_api_version: descriptor.sdk_api_version,
id: descriptor.id.clone(),
name: descriptor.name.clone(),
version: descriptor.version.clone(),
hooks: descriptor
.hooks
.iter()
.copied()
.map(PluginHookPoint::from)
.collect(),
failure_policy: descriptor.failure_policy.into(),
description: descriptor.description.clone(),
}
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct PluginMetadata {
pub path: PathBuf,
pub enabled: bool,
pub priority: i32,
pub descriptor: PluginDescriptorInfo,
}
impl PluginMetadata {
pub fn from_plugin_runtime(runtime: &PluginRuntime) -> Self {
Self {
path: runtime.plugin_path.clone(),
enabled: runtime.enabled,
priority: runtime.priority,
descriptor: PluginDescriptorInfo::from_descriptor(&runtime.descriptor),
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
pub enum PluginRuntimeStatus {
#[default]
Unknown,
Loaded,
Disabled,
LoadFailed,
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct PluginInfo {
pub path: PathBuf,
pub enabled: bool,
pub priority: i32,
pub descriptor: PluginDescriptorInfo,
pub runtime_status: PluginRuntimeStatus,
}
impl PluginInfo {
pub fn from_metadata(metadata: PluginMetadata, runtime_status: PluginRuntimeStatus) -> Self {
Self {
path: metadata.path,
enabled: metadata.enabled,
priority: metadata.priority,
descriptor: metadata.descriptor,
runtime_status,
}
}
pub fn into_metadata(self) -> PluginMetadata {
PluginMetadata {
path: self.path,
enabled: self.enabled,
priority: self.priority,
descriptor: self.descriptor,
}
}
}

View File

@@ -16,6 +16,9 @@ async saveConfig(config: Config) : Promise<Result<null, CommandError>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getPluginInfos() : Promise<PluginInfo[]> {
return await TAURI_INVOKE("get_plugin_infos");
},
async generateQrcode() : Promise<Result<QrcodeData, CommandError>> { async generateQrcode() : Promise<Result<QrcodeData, CommandError>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("generate_qrcode") }; return { status: "ok", data: await TAURI_INVOKE("generate_qrcode") };
@@ -177,6 +180,38 @@ async openLogFile(path: string) : Promise<Result<LogMetadata[], CommandError>> {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
},
async addPlugin(pluginPath: string) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("add_plugin", { pluginPath }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async uninstallPlugin(pluginPath: string) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("uninstall_plugin", { pluginPath }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setPluginEnabled(pluginPath: string, enabled: boolean) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_plugin_enabled", { pluginPath, enabled }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setPluginPriority(pluginPath: string, priority: number) : Promise<Result<null, CommandError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_plugin_priority", { pluginPath, priority }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
} }
} }
@@ -185,10 +220,12 @@ async openLogFile(path: string) : Promise<Result<LogMetadata[], CommandError>> {
export const events = __makeEvents__<{ export const events = __makeEvents__<{
downloadEvent: DownloadEvent, downloadEvent: DownloadEvent,
logEvent: LogEvent logEvent: LogEvent,
pluginEvent: PluginEvent
}>({ }>({
downloadEvent: "download-event", downloadEvent: "download-event",
logEvent: "log-event" logEvent: "log-event",
pluginEvent: "plugin-event"
}) })
/** user-defined constants **/ /** user-defined constants **/
@@ -371,6 +408,12 @@ export type PaymentInBangumi = { discount: number; pay_type: PayType; price: str
export type PendantInCheese = { image: string; name: string; pid: number } export type PendantInCheese = { image: string; name: string; pid: number }
export type PendantInUserInfo = { pid: number; name: string; image: string; expire: number; image_enhance: string; image_enhance_frame: string; n_pid: number } export type PendantInUserInfo = { pid: number; name: string; image: string; expire: number; image_enhance: string; image_enhance_frame: string; n_pid: number }
export type PlayStrategy = { strategies: string[] } export type PlayStrategy = { strategies: string[] }
export type PluginDescriptorInfo = { sdk_api_version: number; id: string; name: string; version: string; hooks: PluginHookPoint[]; failure_policy: PluginFailurePolicyInfo; description: string }
export type PluginEvent = { event: "Loaded"; data: { plugin_info: PluginInfo } } | { event: "Update"; data: { plugin_info: PluginInfo } } | { event: "Uninstall"; data: { plugin_path: string } }
export type PluginFailurePolicyInfo = "FailOpen" | "FailClosed"
export type PluginHookPoint = "BeforeVideoProcess" | "AfterPrepare" | "OnCompleted"
export type PluginInfo = { path: string; enabled: boolean; priority: number; descriptor: PluginDescriptorInfo; runtime_status: PluginRuntimeStatus }
export type PluginRuntimeStatus = "Unknown" | "Loaded" | "Disabled" | "LoadFailed"
export type Positive = { id: number; title: string } export type Positive = { id: number; title: string }
export type PreviewedPurchaseNote = { long_watch_text: string; pay_text: string; price_format: string; watch_text: string; watching_text: string } export type PreviewedPurchaseNote = { long_watch_text: string; pay_text: string; price_format: string; watch_text: string; watching_text: string }
export type Producer = { mid: number; type: number; is_contribute: number | null; title: string } export type Producer = { mid: number; type: number; is_contribute: number | null; title: string }