This commit is contained in:
fengchuanhn@gmail.com
2026-05-22 18:33:46 +08:00
parent 0eb5dbde5b
commit 2abc2134a8
10 changed files with 891 additions and 31 deletions

View File

@@ -0,0 +1,143 @@
//! 画中画/字幕素材SQLite 元数据 + 本地文件管理
use std::path::{Component, Path, PathBuf};
use chrono::Local;
use serde_json::json;
use tauri::{AppHandle, Manager, State};
use uuid::Uuid;
use crate::material_db::MaterialDb;
use crate::material_store::MaterialStore;
const ALLOWED_EXTS: &[&str] = &[
"mp4", "mov", "avi", "mkv", "jpg", "jpeg", "png", "gif", "bmp", "webp",
];
fn normalize_path(path: &str) -> PathBuf {
Path::new(path)
.components()
.filter(|c| !matches!(c, Component::ParentDir))
.collect()
}
fn materials_dir(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {e}"))?
.join("video-material");
Ok(dir)
}
fn is_under_materials_dir(root: &Path, target: &Path) -> bool {
if let (Ok(root), Ok(canonical)) = (root.canonicalize(), target.canonicalize()) {
return canonical.starts_with(&root);
}
let root_s = root.to_string_lossy().to_lowercase();
let target_s = target.to_string_lossy().to_lowercase();
target_s.contains("video-material") && target_s.starts_with(&root_s)
}
#[tauri::command]
pub async fn list_materials(store: State<'_, MaterialStore>) -> Result<Vec<crate::material_db::MaterialRecord>, String> {
store.list().await
}
#[tauri::command]
pub async fn update_material_name(
store: State<'_, MaterialStore>,
id: i64,
name: String,
) -> Result<(), String> {
store.update_name(id, name).await
}
#[tauri::command]
pub async fn delete_material(
app: AppHandle,
store: State<'_, MaterialStore>,
id: i64,
) -> Result<(), String> {
let file_path = store.delete(id).await?;
if let Some(path) = file_path {
delete_material_file_if_allowed(&app, &path).await?;
}
Ok(())
}
/// 将用户选择的媒体文件复制到应用数据目录并写入数据库
#[tauri::command]
pub async fn import_material_files(
app: AppHandle,
store: State<'_, MaterialStore>,
source_paths: Vec<String>,
) -> Result<Vec<crate::material_db::MaterialRecord>, String> {
let mut imported = Vec::new();
for raw in source_paths {
let src = normalize_path(raw.trim());
if !src.is_file() {
return Err(format!("文件不存在: {}", src.display()));
}
let ext = src
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !ALLOWED_EXTS.contains(&ext.as_str()) {
return Err(format!("不支持的文件格式: .{ext}"));
}
let saved = copy_to_materials_dir(&app, &src, &ext).await?;
let now = chrono::Utc::now().timestamp_millis();
let name = MaterialDb::material_name_from_path(raw.trim());
let material_type = MaterialDb::material_type_from_path(raw.trim());
let info = json!({ "sourcePath": raw.trim() });
let record = store
.insert(
name,
saved,
material_type,
info,
now,
now,
)
.await?;
imported.push(record);
}
Ok(imported)
}
async fn copy_to_materials_dir(
app: &AppHandle,
src: &Path,
ext: &str,
) -> Result<String, String> {
let root = materials_dir(app)?;
let date = Local::now().format("%Y%m%d").to_string();
let dir = root.join(&date);
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("创建素材目录失败: {e}"))?;
let file_name = format!("{}_{}.{}", Uuid::new_v4(), Local::now().format("%H%M%S"), ext);
let dest = dir.join(file_name);
tokio::fs::copy(src, &dest)
.await
.map_err(|e| format!("复制素材失败: {e}"))?;
Ok(dest.to_string_lossy().to_string())
}
async fn delete_material_file_if_allowed(app: &AppHandle, path: &str) -> Result<(), String> {
let target = normalize_path(path);
let dir = materials_dir(app)?;
if !is_under_materials_dir(&dir, &target) {
return Err("不允许删除该路径下的文件".into());
}
if target.is_file() {
tokio::fs::remove_file(&target)
.await
.map_err(|e| format!("删除文件失败: {e}"))?;
}
Ok(())
}

View File

@@ -3,6 +3,7 @@ pub mod app_config;
pub mod auth;
pub mod audio;
pub mod avatar;
pub mod material;
pub mod video;
pub mod fs_util;
pub mod nodejs;

View File

@@ -5,6 +5,8 @@ pub mod audio_db;
pub mod audio_store;
pub mod avatar_db;
pub mod avatar_store;
pub mod material_db;
pub mod material_store;
pub mod video_db;
pub mod video_store;
pub mod local_config;
@@ -35,6 +37,7 @@ pub fn run() {
.manage(auth_session::AuthSession::default())
.manage(app_config::AppConfig::new())
.manage(avatar_store::AvatarStore::new())
.manage(material_store::MaterialStore::new())
.manage(audio_store::AudioStore::new())
.manage(video_store::VideoStore::new())
.manage(publish_store::PublishStore::new())
@@ -50,6 +53,7 @@ pub fn run() {
tauri::async_runtime::block_on(async move {
let app_config = handle.state::<app_config::AppConfig>();
let avatar_store = handle.state::<avatar_store::AvatarStore>();
let material_store = handle.state::<material_store::MaterialStore>();
let audio_store = handle.state::<audio_store::AudioStore>();
let video_store = handle.state::<video_store::VideoStore>();
let publish_store = handle.state::<publish_store::PublishStore>();
@@ -60,6 +64,7 @@ pub fn run() {
let db_path = dir.join("local_config.db");
app_config.init_local_store(db_path).await?;
avatar_store.init(dir.join("avatars.db")).await?;
material_store.init(dir.join("video_materials.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?;
@@ -104,6 +109,10 @@ pub fn run() {
commands::avatar::delete_avatar,
commands::avatar::avatar_name_exists,
commands::avatar::import_avatar_video,
commands::material::list_materials,
commands::material::update_material_name,
commands::material::delete_material,
commands::material::import_material_files,
commands::audio::list_generated_audios,
commands::audio::insert_generated_audio,
commands::audio::delete_generated_audio,

View File

@@ -0,0 +1,202 @@
//! 画中画/字幕素材 SQLite对齐 Electron data_video_material
use std::path::PathBuf;
use std::sync::Mutex;
use rusqlite::{params, Connection};
use serde::Serialize;
use thiserror::Error;
const IMAGE_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "bmp", "webp",
];
#[derive(Debug, Error)]
pub enum MaterialDbError {
#[error("{0}")]
Db(#[from] rusqlite::Error),
#[error("{0}")]
Message(String),
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MaterialRecord {
pub id: i64,
pub name: String,
pub path: String,
#[serde(rename = "type")]
pub material_type: String,
pub info: serde_json::Value,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug)]
pub struct MaterialDb {
conn: Mutex<Connection>,
}
impl MaterialDb {
pub fn open(path: PathBuf) -> Result<Self, MaterialDbError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
MaterialDbError::Message(format!("创建数据库目录失败: {e}"))
})?;
}
let conn = Connection::open(path)?;
conn.execute_batch(
r#"
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS video_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
type TEXT NOT NULL,
info TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
"#,
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn material_type_from_path(path: &str) -> String {
let ext = path
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
if IMAGE_EXTS.contains(&ext.as_str()) {
"image".to_string()
} else {
"video".to_string()
}
}
pub fn material_name_from_path(path: &str) -> String {
let normalized = path.replace('\\', "/");
let file_name = normalized
.rsplit('/')
.next()
.unwrap_or("未命名素材");
let stem = file_name
.rsplit_once('.')
.map(|(s, _)| s)
.unwrap_or(file_name);
if stem.is_empty() {
file_name.to_string()
} else {
stem.to_string()
}
}
fn row_to_record(row: &rusqlite::Row<'_>) -> Result<MaterialRecord, rusqlite::Error> {
let info_raw: String = row.get(4)?;
let info: serde_json::Value = serde_json::from_str(&info_raw).unwrap_or(serde_json::json!({}));
Ok(MaterialRecord {
id: row.get(0)?,
name: row.get(1)?,
path: row.get(2)?,
material_type: row.get(3)?,
info,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
}
pub fn list(&self) -> Result<Vec<MaterialRecord>, MaterialDbError> {
let conn = self.conn.lock().map_err(|_| {
MaterialDbError::Message("素材数据库锁异常".into())
})?;
let mut stmt = conn.prepare(
"SELECT id, name, path, type, info, created_at, updated_at
FROM video_materials
ORDER BY updated_at DESC, id DESC",
)?;
let rows = stmt.query_map([], Self::row_to_record)?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
pub fn insert(
&self,
name: &str,
path: &str,
material_type: &str,
info: serde_json::Value,
created_at: i64,
updated_at: i64,
) -> Result<MaterialRecord, MaterialDbError> {
let name = name.trim();
if name.is_empty() {
return Err(MaterialDbError::Message("名称不能为空".into()));
}
let path = path.trim();
if path.is_empty() {
return Err(MaterialDbError::Message("文件路径不能为空".into()));
}
let info_str = serde_json::to_string(&info).unwrap_or_else(|_| "{}".into());
let conn = self.conn.lock().map_err(|_| {
MaterialDbError::Message("素材数据库锁异常".into())
})?;
conn.execute(
"INSERT INTO video_materials (name, path, type, info, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![name, path, material_type, info_str, created_at, updated_at],
)?;
let id = conn.last_insert_rowid();
Ok(MaterialRecord {
id,
name: name.to_string(),
path: path.to_string(),
material_type: material_type.to_string(),
info,
created_at,
updated_at,
})
}
pub fn update_name(&self, id: i64, name: &str) -> Result<(), MaterialDbError> {
let name = name.trim();
if name.is_empty() {
return Err(MaterialDbError::Message("名称不能为空".into()));
}
let now = chrono::Utc::now().timestamp_millis();
let conn = self.conn.lock().map_err(|_| {
MaterialDbError::Message("素材数据库锁异常".into())
})?;
let n = conn.execute(
"UPDATE video_materials SET name = ?1, updated_at = ?2 WHERE id = ?3",
params![name, now, id],
)?;
if n == 0 {
return Err(MaterialDbError::Message("素材不存在".into()));
}
Ok(())
}
pub fn delete(&self, id: i64) -> Result<Option<String>, MaterialDbError> {
let conn = self.conn.lock().map_err(|_| {
MaterialDbError::Message("素材数据库锁异常".into())
})?;
let path: Option<String> = conn
.query_row(
"SELECT path FROM video_materials WHERE id = ?1",
params![id],
|row| row.get(0),
)
.ok();
let Some(path) = path else {
return Ok(None);
};
conn.execute("DELETE FROM video_materials WHERE id = ?1", params![id])?;
Ok(Some(path))
}
}

View File

@@ -0,0 +1,94 @@
//! 画中画/字幕素材SQLite 访问与 Tauri 状态
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::material_db::{MaterialDb, MaterialRecord};
#[derive(Debug, Default)]
pub struct MaterialStore {
inner: Arc<RwLock<Option<Arc<MaterialDb>>>>,
}
impl MaterialStore {
pub fn new() -> Self {
Self::default()
}
pub async fn init(&self, db_path: PathBuf) -> Result<(), String> {
let db = tokio::task::spawn_blocking(move || {
MaterialDb::open(db_path).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
let count = tokio::task::spawn_blocking({
let db = Arc::new(db);
let db2 = db.clone();
move || db2.list().map(|l| (db, l.len())).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
let (db, n) = count;
*self.inner.write().await = Some(db);
log::info!(target: "material", "素材数据库已初始化,共 {n} 条记录");
Ok(())
}
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
where
F: FnOnce(Arc<MaterialDb>) -> 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) -> Result<Vec<MaterialRecord>, String> {
self.with_db(|db| db.list().map_err(|e| e.to_string()))
.await
}
pub async fn insert(
&self,
name: String,
path: String,
material_type: String,
info: serde_json::Value,
created_at: i64,
updated_at: i64,
) -> Result<MaterialRecord, String> {
self.with_db(move |db| {
db.insert(
&name,
&path,
&material_type,
info,
created_at,
updated_at,
)
.map_err(|e| e.to_string())
})
.await
}
pub async fn update_name(&self, id: i64, name: String) -> Result<(), String> {
self.with_db(move |db| db.update_name(id, &name).map_err(|e| e.to_string()))
.await
}
pub async fn delete(&self, id: i64) -> Result<Option<String>, String> {
self.with_db(move |db| db.delete(id).map_err(|e| e.to_string()))
.await
}
}