11
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::ffmpeg;
|
||||
|
||||
fn normalize_path(path: &str) -> PathBuf {
|
||||
Path::new(path)
|
||||
.components()
|
||||
@@ -16,6 +18,15 @@ fn is_allowed_local_media(path: &Path) -> bool {
|
||||
if lower.contains("aiclient-voice-preview-cache")
|
||||
|| lower.contains("aiclient-node-pipeline")
|
||||
|| lower.contains("aiclient-generated-speech")
|
||||
|| lower.contains("aiclient-runninghub")
|
||||
|| lower.contains("aiclient-digital-human")
|
||||
|| lower.contains("generated_audios")
|
||||
|| lower.contains("generated_videos")
|
||||
|| lower.contains("aiclient-video-edit")
|
||||
|| lower.contains("aiclient-subtitle-bgm")
|
||||
|| lower.contains("aiclient-cover")
|
||||
|| lower.contains("aiclient-cover-previews")
|
||||
|| lower.contains("avatars")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -45,3 +56,21 @@ pub async fn read_local_file_base64(path: String) -> Result<String, String> {
|
||||
.map_err(|e| format!("读取文件失败: {e}"))?;
|
||||
Ok(STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
/// 获取本地音视频时长(秒),供口播视频云端任务对齐音频长度。
|
||||
#[tauri::command]
|
||||
pub async fn get_media_duration_seconds(path: String) -> Result<f64, String> {
|
||||
let normalized = normalize_path(path.trim());
|
||||
if normalized.as_os_str().is_empty() {
|
||||
return Err("路径为空".into());
|
||||
}
|
||||
if !normalized.is_file() {
|
||||
return Err(format!("文件不存在: {}", normalized.display()));
|
||||
}
|
||||
if !is_allowed_local_media(&normalized) {
|
||||
return Err("不允许读取该路径下的文件".into());
|
||||
}
|
||||
ffmpeg::probe_media_duration(&normalized)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod avatar;
|
||||
pub mod video;
|
||||
pub mod fs_util;
|
||||
pub mod nodejs;
|
||||
pub mod publish;
|
||||
pub mod quickjs;
|
||||
|
||||
@@ -63,7 +63,7 @@ fn log_node_event(script_name: &str, level: &str, msg: &str, fields: &Option<Val
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_event_pump(
|
||||
pub(crate) fn spawn_event_pump(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
script_name: String,
|
||||
|
||||
276
src-tauri/src/commands/publish.rs
Normal file
276
src-tauri/src/commands/publish.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! 视频发布:账号管理与 Node 发布脚本桥接
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tauri::{AppHandle, Manager, State, Window};
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::publish_db::PlatformAccountRecord;
|
||||
use crate::publish_store::PublishStore;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishLoginParams {
|
||||
pub platform: String,
|
||||
pub account_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishCheckLoginParams {
|
||||
pub platform: String,
|
||||
pub account_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub check_browser: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishPlatformItem {
|
||||
pub platform: String,
|
||||
pub account_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishExecuteParams {
|
||||
pub platforms: Vec<PublishPlatformItem>,
|
||||
pub video_path: String,
|
||||
pub cover_path: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub auto_publish: bool,
|
||||
}
|
||||
|
||||
async fn run_publish_script(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
script_name: &str,
|
||||
params: Value,
|
||||
) -> Result<Value, String> {
|
||||
let app_config = app.state::<AppConfig>();
|
||||
let config_env = app_config.env_for_node().await;
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let pump = crate::commands::nodejs::spawn_event_pump(
|
||||
app.clone(),
|
||||
window,
|
||||
script_name.to_string(),
|
||||
rx,
|
||||
);
|
||||
let result = crate::nodejs::run_node_script(
|
||||
script_name.to_string(),
|
||||
params,
|
||||
tx,
|
||||
config_env,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
pump.await.ok();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_list_accounts(
|
||||
store: State<'_, PublishStore>,
|
||||
platform: Option<String>,
|
||||
) -> Result<Vec<PlatformAccountRecord>, String> {
|
||||
store.list(platform).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_delete_account(
|
||||
store: State<'_, PublishStore>,
|
||||
account_id: i64,
|
||||
) -> Result<(), String> {
|
||||
store.delete(account_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_login(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
store: State<'_, PublishStore>,
|
||||
app_config: State<'_, AppConfig>,
|
||||
params: PublishLoginParams,
|
||||
) -> Result<Value, String> {
|
||||
let _ = app_config;
|
||||
let result = run_publish_script(
|
||||
app,
|
||||
window,
|
||||
"publish_login.js",
|
||||
json!({
|
||||
"platform": params.platform,
|
||||
"accountId": params.account_id,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if result.get("success").and_then(|v| v.as_bool()) == Some(true) {
|
||||
let nickname = result
|
||||
.get("nickname")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let uid = result
|
||||
.get("uid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let cookies = result
|
||||
.get("cookies")
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string()))
|
||||
.unwrap_or_else(|| "[]".to_string());
|
||||
let rec = store
|
||||
.upsert_login(
|
||||
params.platform.clone(),
|
||||
nickname,
|
||||
uid,
|
||||
cookies,
|
||||
params.account_id,
|
||||
)
|
||||
.await?;
|
||||
return Ok(json!({
|
||||
"success": true,
|
||||
"message": result.get("message").and_then(|v| v.as_str()).unwrap_or("登录成功"),
|
||||
"account": rec,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_check_login(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
store: State<'_, PublishStore>,
|
||||
params: PublishCheckLoginParams,
|
||||
) -> Result<Value, String> {
|
||||
if !params.check_browser {
|
||||
let accounts = store.list(Some(params.platform.clone())).await?;
|
||||
let account = if let Some(id) = params.account_id {
|
||||
accounts.into_iter().find(|a| a.id == id)
|
||||
} else {
|
||||
accounts
|
||||
.into_iter()
|
||||
.find(|a| a.login_status == "active" && a.has_cookies)
|
||||
};
|
||||
if let Some(acc) = account {
|
||||
return Ok(json!({
|
||||
"success": true,
|
||||
"isLoggedIn": acc.login_status == "active" && acc.has_cookies,
|
||||
"userInfo": { "nickname": acc.nickname, "userId": acc.uid },
|
||||
}));
|
||||
}
|
||||
return Ok(json!({
|
||||
"success": true,
|
||||
"isLoggedIn": false,
|
||||
}));
|
||||
}
|
||||
|
||||
let mut cookies = json!([]);
|
||||
if let Some(id) = params.account_id {
|
||||
if let Some((_, c)) = store.get_with_cookies(id).await? {
|
||||
cookies = serde_json::from_str(&c).unwrap_or(json!([]));
|
||||
}
|
||||
} else if let Ok(list) = store.list(Some(params.platform.clone())).await {
|
||||
if let Some(active) = list
|
||||
.iter()
|
||||
.find(|a| a.login_status == "active" && a.has_cookies)
|
||||
{
|
||||
if let Some((_, c)) = store.get_with_cookies(active.id).await? {
|
||||
cookies = serde_json::from_str(&c).unwrap_or(json!([]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = run_publish_script(
|
||||
app,
|
||||
window,
|
||||
"publish_check_login.js",
|
||||
json!({
|
||||
"platform": params.platform,
|
||||
"accountId": params.account_id,
|
||||
"cookies": cookies,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if result.get("isLoggedIn").and_then(|v| v.as_bool()) == Some(true) {
|
||||
if let (Some(nickname), Some(account_id)) = (
|
||||
result
|
||||
.get("userInfo")
|
||||
.and_then(|u| u.get("nickname"))
|
||||
.and_then(|v| v.as_str()),
|
||||
params.account_id,
|
||||
) {
|
||||
let _ = store.set_login_status(account_id, "active".to_string()).await;
|
||||
if let Ok(Some((mut rec, _))) = store.get_with_cookies(account_id).await {
|
||||
if !nickname.is_empty() {
|
||||
rec.nickname = nickname.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(account_id) = params.account_id {
|
||||
let _ = store.set_login_status(account_id, "inactive".to_string()).await;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_execute(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
store: State<'_, PublishStore>,
|
||||
params: PublishExecuteParams,
|
||||
) -> Result<Value, String> {
|
||||
let mut platform_payloads = Vec::new();
|
||||
for item in ¶ms.platforms {
|
||||
let account_id = item.account_id;
|
||||
let (rec, cookies) = if let Some(id) = account_id {
|
||||
store
|
||||
.get_with_cookies(id)
|
||||
.await?
|
||||
.ok_or_else(|| format!("未找到账号 id={id}"))?
|
||||
} else {
|
||||
let list = store.list(Some(item.platform.clone())).await?;
|
||||
let active = list
|
||||
.into_iter()
|
||||
.find(|a| a.login_status == "active" && a.has_cookies)
|
||||
.ok_or_else(|| format!("平台 {} 无已登录账号", item.platform))?;
|
||||
let full = store
|
||||
.get_with_cookies(active.id)
|
||||
.await?
|
||||
.ok_or_else(|| "读取账号失败".to_string())?;
|
||||
full
|
||||
};
|
||||
if rec.login_status != "active" {
|
||||
return Err(format!("账号 {} 未登录", rec.nickname));
|
||||
}
|
||||
platform_payloads.push(json!({
|
||||
"platform": item.platform,
|
||||
"accountId": rec.id,
|
||||
"cookies": serde_json::from_str::<Value>(&cookies).unwrap_or(json!([])),
|
||||
"nickname": rec.nickname,
|
||||
}));
|
||||
}
|
||||
|
||||
run_publish_script(
|
||||
app,
|
||||
window,
|
||||
"video_publish.js",
|
||||
json!({
|
||||
"platforms": platform_payloads,
|
||||
"videoPath": params.video_path,
|
||||
"coverPath": params.cover_path,
|
||||
"title": params.title,
|
||||
"description": params.description.unwrap_or_default(),
|
||||
"tags": params.tags.unwrap_or_default(),
|
||||
"autoPublish": params.auto_publish,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
98
src-tauri/src/cover_python.rs
Normal file
98
src-tauri/src/cover_python.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! 封面 Python 运行时与脚本目录定位(对齐 Electron bundled python-runtime)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn exe_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"python.exe"
|
||||
} else {
|
||||
"python3"
|
||||
}
|
||||
}
|
||||
|
||||
/// bundled `python-runtimebackup` 或系统 / 环境变量 Python。
|
||||
pub fn locate_python() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("AICLIENT_PYTHON_PATH") {
|
||||
let pb = PathBuf::from(&p);
|
||||
if pb.is_file() {
|
||||
return Some(pb);
|
||||
}
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let bundled = dir
|
||||
.join("resources")
|
||||
.join("resources-bundles")
|
||||
.join("python-runtimebackup")
|
||||
.join(exe_name());
|
||||
if bundled.exists() {
|
||||
return Some(bundled);
|
||||
}
|
||||
let cover_only = dir.join("resources").join("cover-python").join(exe_name());
|
||||
if cover_only.exists() {
|
||||
return Some(cover_only);
|
||||
}
|
||||
}
|
||||
}
|
||||
let dev_bundled = PathBuf::from("src-tauri/resources/resources-bundles/python-runtimebackup")
|
||||
.join(exe_name());
|
||||
if dev_bundled.exists() {
|
||||
return Some(dev_bundled);
|
||||
}
|
||||
let dev_cover = PathBuf::from("src-tauri/resources/cover-python").join(exe_name());
|
||||
if dev_cover.exists() {
|
||||
return Some(dev_cover);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 封面脚本目录(`advanced_cover_generator.py` 等)。
|
||||
pub fn locate_cover_scripts_dir() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("AICLIENT_COVER_SCRIPTS_DIR") {
|
||||
let pb = PathBuf::from(&p);
|
||||
if pb.join("advanced_cover_generator.py").is_file() {
|
||||
return Some(pb);
|
||||
}
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let bundled = dir.join("resources").join("cover-python");
|
||||
if bundled.join("advanced_cover_generator.py").is_file() {
|
||||
return Some(bundled);
|
||||
}
|
||||
}
|
||||
}
|
||||
let dev = PathBuf::from("src-tauri/resources/cover-python");
|
||||
if dev.join("advanced_cover_generator.py").is_file() {
|
||||
return Some(dev);
|
||||
}
|
||||
let backend = PathBuf::from("../pythonbackend/scripts/cover");
|
||||
if backend.join("advanced_cover_generator.py").is_file() {
|
||||
return backend.canonicalize().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 将 ffmpeg 所在目录 prepend 到 PATH(供 Python 子进程调用 ffmpeg)。
|
||||
pub fn prepend_ffmpeg_to_path(env_path: &str) -> String {
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
if let Some(ffmpeg) = crate::ffmpeg::locate_ffmpeg() {
|
||||
if let Some(dir) = ffmpeg.parent() {
|
||||
return format!("{}{}{}", dir.display(), sep, env_path);
|
||||
}
|
||||
}
|
||||
env_path.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cover_scripts_dev_path_exists() {
|
||||
let dev = Path::new("src-tauri/resources/cover-python/advanced_cover_generator.py");
|
||||
if dev.is_file() {
|
||||
assert!(locate_cover_scripts_dir().is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,57 @@ fn exe_name() -> &'static str {
|
||||
if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }
|
||||
}
|
||||
|
||||
fn ffprobe_exe_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"ffprobe.exe"
|
||||
} else {
|
||||
"ffprobe"
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 `locate_ffmpeg` 相同目录策略,优先 bundled `binaries/ffprobe`。
|
||||
pub fn locate_ffprobe() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("AICLIENT_FFPROBE_PATH") {
|
||||
let pb = PathBuf::from(p);
|
||||
if pb.exists() {
|
||||
return Some(pb);
|
||||
}
|
||||
}
|
||||
if let Some(ffmpeg) = locate_ffmpeg() {
|
||||
if let Some(dir) = ffmpeg.parent() {
|
||||
let sibling = dir.join(ffprobe_exe_name());
|
||||
if sibling.exists() {
|
||||
return Some(sibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
for candidate in [
|
||||
dir.join("binaries").join(ffprobe_exe_name()),
|
||||
dir.join(ffprobe_exe_name()),
|
||||
dir.join("..")
|
||||
.join("..")
|
||||
.join("binaries")
|
||||
.join(ffprobe_exe_name()),
|
||||
] {
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for dev in [
|
||||
PathBuf::from("src-tauri/binaries").join(ffprobe_exe_name()),
|
||||
PathBuf::from("binaries").join(ffprobe_exe_name()),
|
||||
] {
|
||||
if dev.exists() {
|
||||
return Some(dev);
|
||||
}
|
||||
}
|
||||
Some(PathBuf::from(ffprobe_exe_name()))
|
||||
}
|
||||
|
||||
pub fn locate_ffmpeg() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("AICLIENT_FFMPEG_PATH") {
|
||||
let pb = PathBuf::from(p);
|
||||
@@ -86,3 +137,62 @@ pub async fn extract_audio(video_path: &Path, dest_wav: &Path) -> Result<(), Ffm
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_duration_hms(text: &str) -> Option<f64> {
|
||||
let marker = "Duration:";
|
||||
let idx = text.find(marker)?;
|
||||
let rest = text[idx + marker.len()..].trim_start();
|
||||
let time_part = rest.split(',').next()?.trim();
|
||||
let mut parts = time_part.split(':');
|
||||
let hours: f64 = parts.next()?.parse().ok()?;
|
||||
let minutes: f64 = parts.next()?.parse().ok()?;
|
||||
let seconds: f64 = parts.next()?.parse().ok()?;
|
||||
Some(hours * 3600.0 + minutes * 60.0 + seconds)
|
||||
}
|
||||
|
||||
/// 读取媒体时长(秒),优先 ffprobe,回退解析 `ffmpeg -i` 的 stderr。
|
||||
pub async fn probe_media_duration(path: &Path) -> Result<f64, FfmpegError> {
|
||||
if !path.is_file() {
|
||||
return Err(FfmpegError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("文件不存在: {}", path.display()),
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(ffprobe) = locate_ffprobe() {
|
||||
let output = Command::new(&ffprobe)
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
.arg("-show_entries")
|
||||
.arg("format=duration")
|
||||
.arg("-of")
|
||||
.arg("default=noprint_wrappers=1:nokey=1")
|
||||
.arg(path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if let Ok(secs) = stdout.trim().parse::<f64>() {
|
||||
if secs > 0.0 {
|
||||
return Ok(secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ffmpeg = locate_ffmpeg().ok_or(FfmpegError::NotFound)?;
|
||||
let output = Command::new(&ffmpeg)
|
||||
.arg("-i")
|
||||
.arg(path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
parse_duration_hms(&stderr).ok_or_else(|| FfmpegError::Failed {
|
||||
status: output.status.code(),
|
||||
stderr: format!("无法解析媒体时长: {}", stderr.chars().take(400).collect::<String>()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,11 +12,14 @@ pub mod asr;
|
||||
pub mod auth_session;
|
||||
pub mod chat;
|
||||
pub mod commands;
|
||||
pub mod cover_python;
|
||||
pub mod ffmpeg;
|
||||
pub mod http;
|
||||
pub mod js_runtime;
|
||||
pub mod nodejs;
|
||||
pub mod oss;
|
||||
pub mod publish_db;
|
||||
pub mod publish_store;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
@@ -30,6 +33,7 @@ pub fn run() {
|
||||
.manage(avatar_store::AvatarStore::new())
|
||||
.manage(audio_store::AudioStore::new())
|
||||
.manage(video_store::VideoStore::new())
|
||||
.manage(publish_store::PublishStore::new())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
@@ -37,6 +41,7 @@ pub fn run() {
|
||||
let avatar_store = handle.state::<avatar_store::AvatarStore>();
|
||||
let audio_store = handle.state::<audio_store::AudioStore>();
|
||||
let video_store = handle.state::<video_store::VideoStore>();
|
||||
let publish_store = handle.state::<publish_store::PublishStore>();
|
||||
let dir = handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
@@ -46,6 +51,7 @@ pub fn run() {
|
||||
avatar_store.init(dir.join("avatars.db")).await?;
|
||||
audio_store.init(dir.join("generated_audios.db")).await?;
|
||||
video_store.init(dir.join("generated_videos.db")).await?;
|
||||
publish_store.init(dir.join("publish_accounts.db")).await?;
|
||||
Ok::<(), String>(())
|
||||
})
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||
@@ -77,6 +83,7 @@ pub fn run() {
|
||||
commands::nodejs::run_nodejs_script_source,
|
||||
commands::nodejs::list_nodejs_scripts,
|
||||
commands::fs_util::read_local_file_base64,
|
||||
commands::fs_util::get_media_duration_seconds,
|
||||
commands::avatar::list_avatars,
|
||||
commands::avatar::insert_avatar,
|
||||
commands::avatar::update_avatar_name,
|
||||
@@ -90,7 +97,19 @@ pub fn run() {
|
||||
commands::video::insert_generated_video,
|
||||
commands::video::import_generated_video,
|
||||
commands::video::delete_generated_video,
|
||||
commands::publish::publish_list_accounts,
|
||||
commands::publish::publish_login,
|
||||
commands::publish::publish_check_login,
|
||||
commands::publish::publish_execute,
|
||||
commands::publish::publish_delete_account,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
if let tauri::RunEvent::Ready = event {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,6 +369,12 @@ async fn run_script_bundle(
|
||||
cmd.env("AICLIENT_FFMPEG_PATH", ffmpeg_path);
|
||||
}
|
||||
}
|
||||
if let Some(py) = crate::cover_python::locate_python() {
|
||||
cmd.env("AICLIENT_PYTHON_PATH", py);
|
||||
}
|
||||
if let Some(dir) = crate::cover_python::locate_cover_scripts_dir() {
|
||||
cmd.env("AICLIENT_COVER_SCRIPTS_DIR", dir);
|
||||
}
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
// 把 params JSON 灌进 stdin
|
||||
|
||||
193
src-tauri/src/publish_db.rs
Normal file
193
src-tauri/src/publish_db.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! 平台发布账号 SQLite(对齐 Electron platform_accounts)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PublishDbError {
|
||||
#[error("{0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformAccountRecord {
|
||||
pub id: i64,
|
||||
pub platform: String,
|
||||
pub nickname: String,
|
||||
pub uid: String,
|
||||
pub avatar: String,
|
||||
pub login_status: String,
|
||||
pub has_cookies: bool,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PublishDb {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl PublishDb {
|
||||
pub fn open(path: PathBuf) -> Result<Self, PublishDbError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| PublishDbError::Message(format!("创建目录失败: {e}")))?;
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS platform_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
cookies TEXT NOT NULL DEFAULT '[]',
|
||||
tokens TEXT NOT NULL DEFAULT '',
|
||||
login_status TEXT NOT NULL DEFAULT 'inactive',
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_accounts_platform
|
||||
ON platform_accounts(platform);
|
||||
"#,
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(&self, platform: Option<&str>) -> Result<Vec<PlatformAccountRecord>, PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
let (sql, use_platform) = match platform {
|
||||
Some(_) => (
|
||||
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
|
||||
FROM platform_accounts WHERE platform = ?1 ORDER BY updated_at DESC",
|
||||
true,
|
||||
),
|
||||
None => (
|
||||
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
|
||||
FROM platform_accounts ORDER BY platform ASC, updated_at DESC",
|
||||
false,
|
||||
),
|
||||
};
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let map_row = |row: &rusqlite::Row<'_>| {
|
||||
let cookies: String = row.get(5)?;
|
||||
Ok(PlatformAccountRecord {
|
||||
id: row.get(0)?,
|
||||
platform: row.get(1)?,
|
||||
nickname: row.get(2)?,
|
||||
uid: row.get(3)?,
|
||||
avatar: row.get(4)?,
|
||||
login_status: row.get(6)?,
|
||||
has_cookies: cookies.len() > 4,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
};
|
||||
let rows = if use_platform {
|
||||
stmt.query_map(params![platform.unwrap()], map_row)?
|
||||
} else {
|
||||
stmt.query_map([], map_row)?
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i64) -> Result<Option<(PlatformAccountRecord, String)>, PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
|
||||
FROM platform_accounts WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![id])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let cookies: String = row.get(5)?;
|
||||
let rec = PlatformAccountRecord {
|
||||
id: row.get(0)?,
|
||||
platform: row.get(1)?,
|
||||
nickname: row.get(2)?,
|
||||
uid: row.get(3)?,
|
||||
avatar: row.get(4)?,
|
||||
login_status: row.get(6)?,
|
||||
has_cookies: cookies.len() > 4,
|
||||
updated_at: row.get(7)?,
|
||||
};
|
||||
return Ok(Some((rec, cookies)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn upsert_login(
|
||||
&self,
|
||||
platform: &str,
|
||||
nickname: &str,
|
||||
uid: &str,
|
||||
cookies_json: &str,
|
||||
account_id: Option<i64>,
|
||||
) -> Result<PlatformAccountRecord, PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let expires = now + 30_i64 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if let Some(id) = account_id {
|
||||
conn.execute(
|
||||
"UPDATE platform_accounts SET nickname = ?1, uid = ?2, cookies = ?3,
|
||||
login_status = 'active', expires_at = ?4, updated_at = ?5 WHERE id = ?6",
|
||||
params![nickname, uid, cookies_json, expires, now, id],
|
||||
)?;
|
||||
return self
|
||||
.get(id)?
|
||||
.map(|(r, _)| r)
|
||||
.ok_or_else(|| PublishDbError::Message("更新账号失败".into()));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO platform_accounts (platform, nickname, uid, avatar, cookies, tokens,
|
||||
login_status, expires_at, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, '', ?4, '', 'active', ?5, ?6, ?6)",
|
||||
params![platform, nickname, uid, cookies_json, expires, now],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
self.get(id)?
|
||||
.map(|(r, _)| r)
|
||||
.ok_or_else(|| PublishDbError::Message("插入账号失败".into()))
|
||||
}
|
||||
|
||||
pub fn set_login_status(&self, id: i64, status: &str) -> Result<(), PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
conn.execute(
|
||||
"UPDATE platform_accounts SET login_status = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![status, now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: i64) -> Result<(), PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
conn.execute("DELETE FROM platform_accounts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
92
src-tauri/src/publish_store.rs
Normal file
92
src-tauri/src/publish_store.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! 发布账号 Tauri 状态
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::publish_db::{PlatformAccountRecord, PublishDb};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PublishStore {
|
||||
inner: Arc<RwLock<Option<Arc<PublishDb>>>>,
|
||||
}
|
||||
|
||||
impl PublishStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn init(&self, db_path: PathBuf) -> Result<(), String> {
|
||||
let db = tokio::task::spawn_blocking(move || {
|
||||
PublishDb::open(db_path).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
*self.inner.write().await = Some(Arc::new(db));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(Arc<PublishDb>) -> Result<T, String> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let db = self
|
||||
.inner
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "发布账号数据库未初始化".to_string())?;
|
||||
tokio::task::spawn_blocking(move || f(db))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
pub async fn list(&self, platform: Option<String>) -> Result<Vec<PlatformAccountRecord>, String> {
|
||||
self.with_db(move |db| {
|
||||
db.list(platform.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_with_cookies(
|
||||
&self,
|
||||
id: i64,
|
||||
) -> Result<Option<(PlatformAccountRecord, String)>, String> {
|
||||
self.with_db(move |db| db.get(id).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upsert_login(
|
||||
&self,
|
||||
platform: String,
|
||||
nickname: String,
|
||||
uid: String,
|
||||
cookies_json: String,
|
||||
account_id: Option<i64>,
|
||||
) -> Result<PlatformAccountRecord, String> {
|
||||
self.with_db(move |db| {
|
||||
db.upsert_login(
|
||||
&platform,
|
||||
&nickname,
|
||||
&uid,
|
||||
&cookies_json,
|
||||
account_id,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_login_status(&self, id: i64, status: String) -> Result<(), String> {
|
||||
self.with_db(move |db| db.set_login_status(id, &status).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i64) -> Result<(), String> {
|
||||
self.with_db(move |db| db.delete(id).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user