mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-25 04:22:24 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab4b7d693 | ||
|
|
ce2f097d32 | ||
|
|
f7575cd327 | ||
|
|
8634c6a211 | ||
|
|
b070013efc | ||
|
|
d2d9112f6c | ||
|
|
9fea18f2de |
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
@@ -59,12 +59,6 @@ jobs:
|
||||
if: matrix.platform == 'windows-latest' && matrix.features == 'cuda'
|
||||
uses: Jimver/cuda-toolkit@v0.2.24
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./src-tauri -> target"
|
||||
shared-key: ${{ matrix.platform }}
|
||||
|
||||
- name: Setup ffmpeg
|
||||
if: matrix.platform == 'windows-latest'
|
||||
working-directory: ./
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
/target/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bili-shadowreplay",
|
||||
"private": true,
|
||||
"version": "2.11.0",
|
||||
"version": "2.11.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -537,7 +537,7 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||
|
||||
[[package]]
|
||||
name = "bili-shadowreplay"
|
||||
version = "2.11.0"
|
||||
version = "2.11.3"
|
||||
dependencies = [
|
||||
"async-ffmpeg-sidecar",
|
||||
"async-std",
|
||||
|
||||
@@ -4,7 +4,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "bili-shadowreplay"
|
||||
version = "2.11.0"
|
||||
version = "2.11.3"
|
||||
description = "BiliBili ShadowReplay"
|
||||
authors = ["Xinrea"]
|
||||
license = ""
|
||||
|
||||
@@ -985,6 +985,165 @@ pub async fn generate_thumbnail(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 解析FFmpeg时间字符串为秒数 (格式: "HH:MM:SS.mmm")
|
||||
pub fn parse_ffmpeg_time(time_str: &str) -> Result<f64, String> {
|
||||
let parts: Vec<&str> = time_str.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!("Invalid time format: {}", time_str));
|
||||
}
|
||||
|
||||
let hours: f64 = parts[0].parse().map_err(|_| format!("Invalid hours: {}", parts[0]))?;
|
||||
let minutes: f64 = parts[1].parse().map_err(|_| format!("Invalid minutes: {}", parts[1]))?;
|
||||
let seconds: f64 = parts[2].parse().map_err(|_| format!("Invalid seconds: {}", parts[2]))?;
|
||||
|
||||
Ok(hours * 3600.0 + minutes * 60.0 + seconds)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 执行FFmpeg转换的通用函数
|
||||
pub async fn execute_ffmpeg_conversion(
|
||||
mut cmd: tokio::process::Command,
|
||||
total_duration: f64,
|
||||
reporter: &ProgressReporter,
|
||||
mode_name: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Stdio;
|
||||
use tokio::io::BufReader;
|
||||
use async_ffmpeg_sidecar::event::FfmpegEvent;
|
||||
use async_ffmpeg_sidecar::log_parser::FfmpegLogParser;
|
||||
|
||||
let mut child = cmd
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动FFmpeg进程失败: {}", e))?;
|
||||
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut parser = FfmpegLogParser::new(reader);
|
||||
|
||||
let mut conversion_error = None;
|
||||
while let Ok(event) = parser.parse_next_event().await {
|
||||
match event {
|
||||
FfmpegEvent::Progress(p) => {
|
||||
if total_duration > 0.0 {
|
||||
// 解析时间字符串为浮点数 (格式: "HH:MM:SS.mmm")
|
||||
if let Ok(current_time) = parse_ffmpeg_time(&p.time) {
|
||||
let progress = (current_time / total_duration * 100.0).min(100.0);
|
||||
reporter.update(&format!("正在转换视频格式... {:.1}% ({})", progress, mode_name));
|
||||
} else {
|
||||
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
|
||||
}
|
||||
} else {
|
||||
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
|
||||
}
|
||||
}
|
||||
FfmpegEvent::LogEOF => break,
|
||||
FfmpegEvent::Log(level, content) => {
|
||||
if matches!(level, async_ffmpeg_sidecar::event::LogLevel::Error) && content.contains("Error") {
|
||||
conversion_error = Some(content);
|
||||
}
|
||||
}
|
||||
FfmpegEvent::Error(e) => {
|
||||
conversion_error = Some(e);
|
||||
}
|
||||
_ => {} // 忽略其他事件类型
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| format!("等待FFmpeg进程失败: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
let error_msg = conversion_error.unwrap_or_else(|| format!("FFmpeg退出码: {}", status.code().unwrap_or(-1)));
|
||||
return Err(format!("视频格式转换失败 ({}): {}", mode_name, error_msg));
|
||||
}
|
||||
|
||||
reporter.update(&format!("视频格式转换完成 100% ({})", mode_name));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 尝试流复制转换(无损,速度快)
|
||||
pub async fn try_stream_copy_conversion(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 获取视频时长以计算进度
|
||||
let metadata = extract_video_metadata(source).await?;
|
||||
let total_duration = metadata.duration;
|
||||
|
||||
reporter.update("正在转换视频格式... 0% (无损模式)");
|
||||
|
||||
// 构建ffmpeg命令 - 流复制模式
|
||||
let mut cmd = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
|
||||
cmd.args([
|
||||
"-i", &source.to_string_lossy(),
|
||||
"-c:v", "copy", // 直接复制视频流,零损失
|
||||
"-c:a", "copy", // 直接复制音频流,零损失
|
||||
"-avoid_negative_ts", "make_zero", // 修复时间戳问题
|
||||
"-movflags", "+faststart", // 优化web播放
|
||||
"-progress", "pipe:2", // 输出进度到stderr
|
||||
"-y", // 覆盖输出文件
|
||||
&dest.to_string_lossy(),
|
||||
]);
|
||||
|
||||
execute_ffmpeg_conversion(cmd, total_duration, reporter, "无损转换").await
|
||||
}
|
||||
|
||||
// 高质量重编码转换(兼容性好,质量高)
|
||||
pub async fn try_high_quality_conversion(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 获取视频时长以计算进度
|
||||
let metadata = extract_video_metadata(source).await?;
|
||||
let total_duration = metadata.duration;
|
||||
|
||||
reporter.update("正在转换视频格式... 0% (高质量模式)");
|
||||
|
||||
// 构建ffmpeg命令 - 高质量重编码
|
||||
let mut cmd = tokio::process::Command::new(ffmpeg_path());
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
|
||||
cmd.args([
|
||||
"-i", &source.to_string_lossy(),
|
||||
"-c:v", "libx264", // H.264编码器
|
||||
"-preset", "slow", // 慢速预设,更好的压缩效率
|
||||
"-crf", "18", // 高质量设置 (18-23范围,越小质量越高)
|
||||
"-c:a", "aac", // AAC音频编码器
|
||||
"-b:a", "192k", // 高音频码率
|
||||
"-avoid_negative_ts", "make_zero", // 修复时间戳问题
|
||||
"-movflags", "+faststart", // 优化web播放
|
||||
"-progress", "pipe:2", // 输出进度到stderr
|
||||
"-y", // 覆盖输出文件
|
||||
&dest.to_string_lossy(),
|
||||
]);
|
||||
|
||||
execute_ffmpeg_conversion(cmd, total_duration, reporter, "高质量转换").await
|
||||
}
|
||||
|
||||
// 带进度的视频格式转换函数(智能质量保持策略)
|
||||
pub async fn convert_video_format(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 先尝试stream copy(无损转换),如果失败则使用高质量重编码
|
||||
match try_stream_copy_conversion(source, dest, reporter).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(stream_copy_error) => {
|
||||
reporter.update("流复制失败,使用高质量重编码模式...");
|
||||
log::warn!("Stream copy failed: {}, falling back to re-encoding", stream_copy_error);
|
||||
try_high_quality_conversion(source, dest, reporter).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tests
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -10,8 +10,67 @@ use crate::recorder_manager::ClipRangeParams;
|
||||
use crate::subtitle_generator::item_to_srt;
|
||||
use chrono::{Local, Utc};
|
||||
use serde_json::json;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// 检测是否为网络协议路径(排除Windows盘符)
|
||||
fn is_network_protocol(path_str: &str) -> bool {
|
||||
// 常见的网络协议
|
||||
let network_protocols = [
|
||||
"ftp://", "sftp://", "ftps://", "http://", "https://", "smb://", "cifs://", "nfs://",
|
||||
"afp://", "ssh://", "scp://",
|
||||
];
|
||||
|
||||
// 检查是否以网络协议开头
|
||||
for protocol in &network_protocols {
|
||||
if path_str.to_lowercase().starts_with(protocol) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 排除Windows盘符格式 (如 C:/, D:/, E:/ 等)
|
||||
if cfg!(windows) {
|
||||
// 检查是否为Windows盘符格式:单字母 + : + /
|
||||
if path_str.len() >= 3 {
|
||||
let chars: Vec<char> = path_str.chars().collect();
|
||||
if chars.len() >= 3
|
||||
&& chars[0].is_ascii_alphabetic()
|
||||
&& chars[1] == ':'
|
||||
&& (chars[2] == '/' || chars[2] == '\\')
|
||||
{
|
||||
return false; // 这是Windows盘符,不是网络路径
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// 判断是否需要转换视频格式
|
||||
fn should_convert_video_format(extension: &str) -> bool {
|
||||
// FLV格式在现代浏览器中播放兼容性差,需要转换为MP4
|
||||
matches!(extension.to_lowercase().as_str(), "flv")
|
||||
}
|
||||
|
||||
// 获取最佳缩略图时间点
|
||||
fn get_optimal_thumbnail_timestamp(duration: f64) -> f64 {
|
||||
// 根据视频长度选择最佳时间点
|
||||
if duration <= 10.0 {
|
||||
// 短视频(10秒以内):选择1/3位置,避免开头黑屏
|
||||
duration / 3.0
|
||||
} else if duration <= 60.0 {
|
||||
// 1分钟以内:选择第3秒
|
||||
3.0
|
||||
} else if duration <= 300.0 {
|
||||
// 5分钟以内:选择第5秒
|
||||
5.0
|
||||
} else {
|
||||
// 长视频:选择第10秒,确保跳过开头可能的黑屏/logo
|
||||
10.0
|
||||
}
|
||||
}
|
||||
|
||||
use crate::state::State;
|
||||
use crate::state_type;
|
||||
|
||||
@@ -26,6 +85,241 @@ struct ImportedVideoMetadata {
|
||||
resolution: Option<String>,
|
||||
}
|
||||
|
||||
// 带进度的文件复制函数
|
||||
async fn copy_file_with_progress(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
let mut source_file = File::open(source).map_err(|e| format!("无法打开源文件: {}", e))?;
|
||||
let mut dest_file = File::create(dest).map_err(|e| format!("无法创建目标文件: {}", e))?;
|
||||
|
||||
let total_size = source_file
|
||||
.metadata()
|
||||
.map_err(|e| format!("无法获取文件大小: {}", e))?
|
||||
.len();
|
||||
let mut copied = 0u64;
|
||||
|
||||
// 根据文件大小调整缓冲区大小
|
||||
let buffer_size = if total_size > 100 * 1024 * 1024 {
|
||||
// 100MB以上
|
||||
1024 * 1024 // 1MB buffer for large files
|
||||
} else if total_size > 10 * 1024 * 1024 {
|
||||
// 10MB-100MB
|
||||
256 * 1024 // 256KB buffer
|
||||
} else {
|
||||
64 * 1024 // 64KB buffer for small files
|
||||
};
|
||||
|
||||
let mut buffer = vec![0u8; buffer_size];
|
||||
|
||||
let mut last_reported_percent = 0;
|
||||
|
||||
loop {
|
||||
let bytes_read = source_file
|
||||
.read(&mut buffer)
|
||||
.map_err(|e| format!("读取文件失败: {}", e))?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
dest_file
|
||||
.write_all(&buffer[..bytes_read])
|
||||
.map_err(|e| format!("写入文件失败: {}", e))?;
|
||||
copied += bytes_read as u64;
|
||||
|
||||
// 计算进度百分比,只在变化时更新
|
||||
let percent = if total_size > 0 {
|
||||
((copied as f64 / total_size as f64) * 100.0) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// 根据文件大小调整报告频率
|
||||
let report_threshold = if total_size > 100 * 1024 * 1024 {
|
||||
// 100MB以上
|
||||
1 // 每1%报告一次
|
||||
} else if total_size > 10 * 1024 * 1024 {
|
||||
// 10MB-100MB
|
||||
2 // 每2%报告一次
|
||||
} else {
|
||||
5 // 小文件每5%报告一次
|
||||
};
|
||||
|
||||
if percent != last_reported_percent && (percent % report_threshold == 0 || percent == 100) {
|
||||
reporter.update(&format!("正在复制视频文件... {}%", percent));
|
||||
last_reported_percent = percent;
|
||||
}
|
||||
}
|
||||
|
||||
dest_file
|
||||
.flush()
|
||||
.map_err(|e| format!("刷新文件缓冲区失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 智能边拷贝边转换函数(针对网络文件优化)
|
||||
async fn copy_and_convert_with_progress(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
need_conversion: bool,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
if !need_conversion {
|
||||
// 非转换文件直接使用原有拷贝逻辑
|
||||
return copy_file_with_progress(source, dest, reporter).await;
|
||||
}
|
||||
|
||||
// 检查源文件是否在网络位置(启发式判断)
|
||||
let source_str = source.to_string_lossy();
|
||||
let is_network_source = source_str.starts_with("\\\\") || // UNC path (Windows网络共享)
|
||||
is_network_protocol(&source_str); // 网络协议但排除Windows盘符
|
||||
|
||||
if is_network_source {
|
||||
// 网络文件:先复制到本地临时位置,再转换
|
||||
reporter.update("检测到网络文件,使用先复制后转换策略...");
|
||||
copy_then_convert_strategy(source, dest, reporter).await
|
||||
} else {
|
||||
// 本地文件:直接转换(更高效)
|
||||
reporter.update("检测到本地文件,使用直接转换策略...");
|
||||
ffmpeg::convert_video_format(source, dest, reporter).await
|
||||
}
|
||||
}
|
||||
|
||||
// 网络文件处理策略:先复制到本地临时位置,再转换
|
||||
async fn copy_then_convert_strategy(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
// 创建临时文件路径
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_filename = format!(
|
||||
"temp_video_{}.{}",
|
||||
chrono::Utc::now().timestamp(),
|
||||
source.extension().and_then(|e| e.to_str()).unwrap_or("tmp")
|
||||
);
|
||||
let temp_path = temp_dir.join(&temp_filename);
|
||||
|
||||
// 确保临时目录存在
|
||||
if let Some(parent) = temp_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建临时目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 第一步:将网络文件复制到本地临时位置(使用优化的缓冲区)
|
||||
reporter.update("第1步:从网络复制文件到本地临时位置...");
|
||||
copy_file_with_network_optimization(source, &temp_path, reporter).await?;
|
||||
|
||||
// 第二步:从本地临时文件转换到目标位置
|
||||
reporter.update("第2步:从临时文件转换到目标格式...");
|
||||
let convert_result = ffmpeg::convert_video_format(&temp_path, dest, reporter).await;
|
||||
|
||||
// 清理临时文件
|
||||
if temp_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&temp_path) {
|
||||
log::warn!("删除临时文件失败: {} - {}", temp_path.display(), e);
|
||||
} else {
|
||||
log::info!("已清理临时文件: {}", temp_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
convert_result
|
||||
}
|
||||
|
||||
// 针对网络文件优化的复制函数
|
||||
async fn copy_file_with_network_optimization(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
reporter: &ProgressReporter,
|
||||
) -> Result<(), String> {
|
||||
let mut source_file = File::open(source).map_err(|e| format!("无法打开网络源文件: {}", e))?;
|
||||
let mut dest_file = File::create(dest).map_err(|e| format!("无法创建本地临时文件: {}", e))?;
|
||||
|
||||
let total_size = source_file
|
||||
.metadata()
|
||||
.map_err(|e| format!("无法获取文件大小: {}", e))?
|
||||
.len();
|
||||
let mut copied = 0u64;
|
||||
|
||||
// 网络文件使用更大的缓冲区以减少网络请求次数
|
||||
let buffer_size = if total_size > 1024 * 1024 * 1024 {
|
||||
// >1GB
|
||||
8 * 1024 * 1024 // 8MB buffer for very large files
|
||||
} else if total_size > 100 * 1024 * 1024 {
|
||||
// >100MB
|
||||
4 * 1024 * 1024 // 4MB buffer for large files
|
||||
} else {
|
||||
2 * 1024 * 1024 // 2MB buffer for network files
|
||||
};
|
||||
|
||||
let mut buffer = vec![0u8; buffer_size];
|
||||
let mut last_reported_percent = 0;
|
||||
let mut consecutive_errors = 0;
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
loop {
|
||||
match source_file.read(&mut buffer) {
|
||||
Ok(bytes_read) => {
|
||||
if bytes_read == 0 {
|
||||
break; // 文件读取完成
|
||||
}
|
||||
|
||||
// 重置错误计数
|
||||
consecutive_errors = 0;
|
||||
|
||||
dest_file
|
||||
.write_all(&buffer[..bytes_read])
|
||||
.map_err(|e| format!("写入临时文件失败: {}", e))?;
|
||||
copied += bytes_read as u64;
|
||||
|
||||
// 计算并报告进度
|
||||
let percent = if total_size > 0 {
|
||||
((copied as f64 / total_size as f64) * 100.0) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// 网络文件更频繁地报告进度
|
||||
if percent != last_reported_percent {
|
||||
reporter.update(&format!(
|
||||
"正在从网络复制文件... {}% ({:.1}MB/{:.1}MB)",
|
||||
percent,
|
||||
copied as f64 / (1024.0 * 1024.0),
|
||||
total_size as f64 / (1024.0 * 1024.0)
|
||||
));
|
||||
last_reported_percent = percent;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
log::warn!(
|
||||
"网络读取错误 (尝试 {}/{}): {}",
|
||||
consecutive_errors,
|
||||
MAX_RETRIES,
|
||||
e
|
||||
);
|
||||
|
||||
if consecutive_errors >= MAX_RETRIES {
|
||||
return Err(format!("网络文件读取失败,已重试{}次: {}", MAX_RETRIES, e));
|
||||
}
|
||||
|
||||
// 等待一小段时间后重试
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
reporter.update(&format!(
|
||||
"网络连接中断,正在重试... ({}/{})",
|
||||
consecutive_errors, MAX_RETRIES
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dest_file
|
||||
.flush()
|
||||
.map_err(|e| format!("刷新临时文件缓冲区失败: {}", e))?;
|
||||
reporter.update("网络文件复制完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct ClipMetadata {
|
||||
parent_video_id: i64,
|
||||
@@ -356,27 +650,56 @@ pub async fn get_video_cover(state: state_type!(), id: i64) -> Result<String, St
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn delete_video(state: state_type!(), id: i64) -> Result<(), String> {
|
||||
// get video info from dbus
|
||||
// get video info from db
|
||||
let video = state.db.get_video(id).await?;
|
||||
let config = state.config.read().await;
|
||||
|
||||
// delete video from db
|
||||
state.db.delete_video(id).await?;
|
||||
// delete video files
|
||||
let filepath = Path::new(state.config.read().await.output.as_str()).join(&video.file);
|
||||
let file = Path::new(&filepath);
|
||||
let _ = std::fs::remove_file(file);
|
||||
|
||||
// delete srt file
|
||||
// delete video files
|
||||
let filepath = Path::new(&config.output).join(&video.file);
|
||||
let file = Path::new(&filepath);
|
||||
if let Err(e) = std::fs::remove_file(file) {
|
||||
log::warn!("删除视频文件失败: {} - {}", file.display(), e);
|
||||
} else {
|
||||
log::info!("已删除视频文件: {}", file.display());
|
||||
}
|
||||
|
||||
// delete subtitle files
|
||||
let srt_path = file.with_extension("srt");
|
||||
let _ = std::fs::remove_file(srt_path);
|
||||
// delete wav file
|
||||
let wav_path = file.with_extension("wav");
|
||||
let _ = std::fs::remove_file(wav_path);
|
||||
// delete mp3 file
|
||||
let mp3_path = file.with_extension("mp3");
|
||||
let _ = std::fs::remove_file(mp3_path);
|
||||
|
||||
// delete thumbnail file based on video type
|
||||
delete_video_thumbnail(&config.output, &video).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 根据视频类型删除对应的缩略图文件
|
||||
async fn delete_video_thumbnail(output_dir: &str, video: &VideoRow) {
|
||||
if video.cover.is_empty() {
|
||||
return; // 没有缩略图,无需删除
|
||||
}
|
||||
|
||||
// 构建缩略图完整路径
|
||||
let thumbnail_path = Path::new(output_dir).join(&video.cover);
|
||||
|
||||
if thumbnail_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&thumbnail_path) {
|
||||
log::warn!("删除缩略图失败: {} - {}", thumbnail_path.display(), e);
|
||||
} else {
|
||||
log::info!("已删除缩略图: {}", thumbnail_path.display());
|
||||
}
|
||||
} else {
|
||||
log::debug!("缩略图文件不存在: {}", thumbnail_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn get_video_typelist(
|
||||
state: state_type!(),
|
||||
@@ -576,13 +899,12 @@ async fn encode_video_subtitle_inner(
|
||||
let video = state.db.get_video(id).await?;
|
||||
let config = state.config.read().await;
|
||||
let filepath = Path::new(&config.output).join(&video.file);
|
||||
|
||||
|
||||
// 查找字幕文件:对于切片视频,需要查找原视频的字幕文件
|
||||
let subtitle_path = find_subtitle_file(state, &video, &filepath).await?;
|
||||
|
||||
|
||||
let output_file =
|
||||
ffmpeg::encode_video_subtitle(reporter, &filepath, &subtitle_path, srt_style)
|
||||
.await?;
|
||||
ffmpeg::encode_video_subtitle(reporter, &filepath, &subtitle_path, srt_style).await?;
|
||||
|
||||
// 构建正确的相对路径:如果原文件在子目录中,保持相同的目录结构
|
||||
let relative_output_file = if let Some((parent_dir, _)) = video.file.rsplit_once('/') {
|
||||
@@ -636,59 +958,102 @@ pub async fn generic_ffmpeg_command(
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn import_external_video(
|
||||
state: state_type!(),
|
||||
event_id: String,
|
||||
file_path: String,
|
||||
title: String,
|
||||
_original_name: String,
|
||||
size: i64,
|
||||
room_id: u64,
|
||||
) -> Result<VideoRow, String> {
|
||||
// 设置进度事件发射器
|
||||
#[cfg(feature = "gui")]
|
||||
let emitter = EventEmitter::new(state.app_handle.clone());
|
||||
#[cfg(feature = "headless")]
|
||||
let emitter = EventEmitter::new(state.progress_manager.get_event_sender());
|
||||
let reporter = ProgressReporter::new(&emitter, &event_id).await?;
|
||||
|
||||
let source_path = Path::new(&file_path);
|
||||
|
||||
|
||||
// 验证文件存在
|
||||
if !source_path.exists() {
|
||||
return Err("文件不存在".to_string());
|
||||
}
|
||||
|
||||
// 获取视频元数据
|
||||
|
||||
// 步骤1: 获取视频元数据
|
||||
reporter.update("正在提取视频元数据...");
|
||||
let metadata = ffmpeg::extract_video_metadata(source_path).await?;
|
||||
|
||||
|
||||
// 生成目标文件名
|
||||
let config = state.config.read().await;
|
||||
let output_dir = Path::new(&config.output).join("imported");
|
||||
if !output_dir.exists() {
|
||||
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let extension = source_path.extension()
|
||||
let extension = source_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("mp4");
|
||||
let target_filename = format!("imported_{}_{}.{}", timestamp,
|
||||
sanitize_filename(&title), extension);
|
||||
let mut target_filename = format!(
|
||||
"imported_{}_{}.{}",
|
||||
timestamp,
|
||||
sanitize_filename(&title),
|
||||
extension
|
||||
);
|
||||
let target_path = output_dir.join(&target_filename);
|
||||
|
||||
// 复制文件到目标位置
|
||||
std::fs::copy(source_path, &target_path).map_err(|e| e.to_string())?;
|
||||
|
||||
// 生成缩略图
|
||||
let thumbnail_dir = Path::new(&config.output).join("thumbnails").join("imported");
|
||||
|
||||
// 步骤2: 智能复制或转换文件到目标位置
|
||||
let need_conversion = should_convert_video_format(extension);
|
||||
let final_target_path = if need_conversion {
|
||||
// FLV文件需要转换为MP4
|
||||
let mp4_filename = format!("imported_{}_{}.mp4", timestamp, sanitize_filename(&title));
|
||||
let mp4_target_path = output_dir.join(&mp4_filename);
|
||||
|
||||
reporter.update("准备转换视频格式 (FLV → MP4)...");
|
||||
// 使用智能转换函数,自动检测网络优化
|
||||
copy_and_convert_with_progress(source_path, &mp4_target_path, true, &reporter).await?;
|
||||
|
||||
// 更新最终文件名和路径
|
||||
target_filename = mp4_filename;
|
||||
mp4_target_path
|
||||
} else {
|
||||
// 其他格式使用智能拷贝
|
||||
copy_and_convert_with_progress(source_path, &target_path, false, &reporter).await?;
|
||||
target_path
|
||||
};
|
||||
|
||||
// 步骤3: 生成缩略图
|
||||
reporter.update("正在生成视频缩略图...");
|
||||
let thumbnail_dir = Path::new(&config.output)
|
||||
.join("thumbnails")
|
||||
.join("imported");
|
||||
if !thumbnail_dir.exists() {
|
||||
std::fs::create_dir_all(&thumbnail_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let thumbnail_filename = format!("{}.jpg",
|
||||
target_path.file_stem().unwrap().to_str().unwrap());
|
||||
|
||||
let thumbnail_filename = format!(
|
||||
"{}.jpg",
|
||||
final_target_path.file_stem().unwrap().to_str().unwrap()
|
||||
);
|
||||
let thumbnail_path = thumbnail_dir.join(&thumbnail_filename);
|
||||
|
||||
// 生成缩略图,如果失败则使用默认封面
|
||||
let cover_path = match ffmpeg::generate_thumbnail(&target_path, &thumbnail_path, metadata.duration / 2.0).await {
|
||||
Ok(_) => format!("thumbnails/imported/{}", thumbnail_filename),
|
||||
Err(e) => {
|
||||
log::warn!("生成缩略图失败: {}", e);
|
||||
"".to_string() // 使用空字符串,前端会显示默认图标
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 生成缩略图,使用智能时间点选择
|
||||
let thumbnail_timestamp = get_optimal_thumbnail_timestamp(metadata.duration);
|
||||
let cover_path =
|
||||
match ffmpeg::generate_thumbnail(&final_target_path, &thumbnail_path, thumbnail_timestamp)
|
||||
.await
|
||||
{
|
||||
Ok(_) => format!("thumbnails/imported/{}", thumbnail_filename),
|
||||
Err(e) => {
|
||||
log::warn!("生成缩略图失败: {}", e);
|
||||
"".to_string() // 使用空字符串,前端会显示默认图标
|
||||
}
|
||||
};
|
||||
|
||||
// 步骤4: 保存到数据库
|
||||
reporter.update("正在保存视频信息...");
|
||||
|
||||
// 构建导入视频的元数据
|
||||
let import_metadata = ImportedVideoMetadata {
|
||||
original_path: file_path.clone(),
|
||||
@@ -698,16 +1063,19 @@ pub async fn import_external_video(
|
||||
duration: metadata.duration,
|
||||
resolution: Some(format!("{}x{}", metadata.width, metadata.height)),
|
||||
};
|
||||
|
||||
|
||||
// 添加到数据库
|
||||
let video = VideoRow {
|
||||
id: 0,
|
||||
room_id, // 使用传入的 room_id
|
||||
room_id, // 使用传入的 room_id
|
||||
platform: "imported".to_string(), // 使用 platform 字段标识
|
||||
title,
|
||||
file: format!("imported/{}", target_filename), // 包含完整相对路径
|
||||
length: metadata.duration as i64,
|
||||
size: target_path.metadata().map_err(|e| e.to_string())?.len() as i64,
|
||||
size: final_target_path
|
||||
.metadata()
|
||||
.map_err(|e| e.to_string())?
|
||||
.len() as i64,
|
||||
status: 1, // 导入完成
|
||||
cover: cover_path,
|
||||
desc: serde_json::to_string(&import_metadata).unwrap_or_default(),
|
||||
@@ -716,15 +1084,19 @@ pub async fn import_external_video(
|
||||
area: 0,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
|
||||
let result = state.db.add_video(&video).await?;
|
||||
|
||||
|
||||
// 完成进度通知
|
||||
reporter.finish(true, "视频导入完成").await;
|
||||
|
||||
// 发送通知消息
|
||||
state.db.new_message(
|
||||
"视频导入完成",
|
||||
&format!("成功导入视频:{}", result.title),
|
||||
).await?;
|
||||
|
||||
state
|
||||
.db
|
||||
.new_message("视频导入完成", &format!("成功导入视频:{}", result.title))
|
||||
.await?;
|
||||
|
||||
log::info!("导入视频成功: {} -> {}", file_path, result.file);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -740,18 +1112,18 @@ pub async fn clip_video(
|
||||
) -> Result<VideoRow, String> {
|
||||
// 获取父视频信息
|
||||
let parent_video = state.db.get_video(parent_video_id).await?;
|
||||
|
||||
|
||||
// 检查是否为正在录制的视频
|
||||
if parent_video.status == -1 {
|
||||
return Err("正在录制的视频无法进行切片".to_string());
|
||||
}
|
||||
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
let emitter = EventEmitter::new(state.app_handle.clone());
|
||||
#[cfg(feature = "headless")]
|
||||
let emitter = EventEmitter::new(state.progress_manager.get_event_sender());
|
||||
let reporter = ProgressReporter::new(&emitter, &event_id).await?;
|
||||
|
||||
|
||||
// 创建任务记录
|
||||
let task = TaskRow {
|
||||
id: event_id.clone(),
|
||||
@@ -763,27 +1135,41 @@ pub async fn clip_video(
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"clip_title": clip_title,
|
||||
}).to_string(),
|
||||
})
|
||||
.to_string(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
state.db.add_task(&task).await?;
|
||||
|
||||
match clip_video_inner(&state, &reporter, parent_video, start_time, end_time, clip_title).await {
|
||||
|
||||
match clip_video_inner(
|
||||
&state,
|
||||
&reporter,
|
||||
parent_video,
|
||||
start_time,
|
||||
end_time,
|
||||
clip_title,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(video) => {
|
||||
reporter.finish(true, "切片完成").await;
|
||||
state.db.update_task(&event_id, "success", "切片完成", None).await?;
|
||||
state
|
||||
.db
|
||||
.update_task(&event_id, "success", "切片完成", None)
|
||||
.await?;
|
||||
Ok(video)
|
||||
}
|
||||
Err(e) => {
|
||||
reporter.finish(false, &format!("切片失败: {}", e)).await;
|
||||
state.db.update_task(&event_id, "failed", &format!("切片失败: {}", e), None).await?;
|
||||
state
|
||||
.db
|
||||
.update_task(&event_id, "failed", &format!("切片失败: {}", e), None)
|
||||
.await?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async fn clip_video_inner(
|
||||
state: &state_type!(),
|
||||
reporter: &ProgressReporter,
|
||||
@@ -793,36 +1179,36 @@ async fn clip_video_inner(
|
||||
clip_title: String,
|
||||
) -> Result<VideoRow, String> {
|
||||
let config = state.config.read().await;
|
||||
|
||||
|
||||
// 构建输入文件路径
|
||||
let input_path = Path::new(&config.output)
|
||||
.join(&parent_video.file);
|
||||
|
||||
let input_path = Path::new(&config.output).join(&parent_video.file);
|
||||
|
||||
if !input_path.exists() {
|
||||
return Err("原视频文件不存在".to_string());
|
||||
}
|
||||
|
||||
|
||||
// 统一的输出目录:clips
|
||||
let output_dir = Path::new(&config.output).join("clips");
|
||||
if !output_dir.exists() {
|
||||
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
|
||||
let timestamp = Local::now().format("%Y%m%d%H%M").to_string();
|
||||
let extension = input_path.extension()
|
||||
let extension = input_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("mp4");
|
||||
|
||||
|
||||
// 获取原文件名(不含扩展名)
|
||||
let original_filename = input_path.file_stem()
|
||||
let original_filename = input_path
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("video");
|
||||
|
||||
|
||||
// 生成新的文件名格式:原文件名[clip][时间戳].扩展名
|
||||
let output_filename = format!("{}[clip][{}].{}",
|
||||
original_filename, timestamp, extension);
|
||||
let output_filename = format!("{}[clip][{}].{}", original_filename, timestamp, extension);
|
||||
let output_path = output_dir.join(&output_filename);
|
||||
|
||||
|
||||
// 执行切片
|
||||
reporter.update("开始切片处理");
|
||||
ffmpeg::clip_from_video_file(
|
||||
@@ -831,31 +1217,38 @@ async fn clip_video_inner(
|
||||
&output_path,
|
||||
start_time,
|
||||
end_time - start_time,
|
||||
).await?;
|
||||
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 生成缩略图
|
||||
let thumbnail_dir = Path::new(&config.output).join("thumbnails").join("clips");
|
||||
if !thumbnail_dir.exists() {
|
||||
std::fs::create_dir_all(&thumbnail_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
|
||||
// 生成缩略图文件名,确保路径安全
|
||||
let clip_thumbnail_filename = if let Some(stem) = output_path.file_stem().and_then(|s| s.to_str()) {
|
||||
format!("{}.jpg", stem)
|
||||
} else {
|
||||
format!("thumbnail_{}.jpg", timestamp)
|
||||
};
|
||||
let clip_thumbnail_filename =
|
||||
if let Some(stem) = output_path.file_stem().and_then(|s| s.to_str()) {
|
||||
format!("{}.jpg", stem)
|
||||
} else {
|
||||
format!("thumbnail_{}.jpg", timestamp)
|
||||
};
|
||||
let thumbnail_path = thumbnail_dir.join(&clip_thumbnail_filename);
|
||||
|
||||
// 生成缩略图,如果失败则使用默认封面
|
||||
let clip_cover_path = match ffmpeg::generate_thumbnail(&output_path, &thumbnail_path, (end_time - start_time) / 2.0).await {
|
||||
Ok(_) => format!("thumbnails/clips/{}", clip_thumbnail_filename),
|
||||
Err(e) => {
|
||||
log::warn!("生成切片缩略图失败: {}", e);
|
||||
"".to_string() // 使用空字符串,前端会显示默认图标
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 生成缩略图,选择切片开头的合理位置
|
||||
let clip_duration = end_time - start_time;
|
||||
let clip_thumbnail_timestamp = get_optimal_thumbnail_timestamp(clip_duration);
|
||||
let clip_cover_path =
|
||||
match ffmpeg::generate_thumbnail(&output_path, &thumbnail_path, clip_thumbnail_timestamp)
|
||||
.await
|
||||
{
|
||||
Ok(_) => format!("thumbnails/clips/{}", clip_thumbnail_filename),
|
||||
Err(e) => {
|
||||
log::warn!("生成切片缩略图失败: {}", e);
|
||||
"".to_string() // 使用空字符串,前端会显示默认图标
|
||||
}
|
||||
};
|
||||
|
||||
// 构建统一的切片元数据
|
||||
let clip_metadata = ClipMetadata {
|
||||
parent_video_id: parent_video.id,
|
||||
@@ -865,10 +1258,10 @@ async fn clip_video_inner(
|
||||
original_platform: parent_video.platform.clone(),
|
||||
original_room_id: parent_video.room_id,
|
||||
};
|
||||
|
||||
|
||||
// 获取输出文件信息
|
||||
let file_metadata = output_path.metadata().map_err(|e| e.to_string())?;
|
||||
|
||||
|
||||
// 添加到数据库 - 统一使用 "clip" 平台类型
|
||||
let clip_video = VideoRow {
|
||||
id: 0,
|
||||
@@ -886,15 +1279,15 @@ async fn clip_video_inner(
|
||||
area: parent_video.area,
|
||||
created_at: Local::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
|
||||
let result = state.db.add_video(&clip_video).await?;
|
||||
|
||||
|
||||
// 发送通知消息
|
||||
state.db.new_message(
|
||||
"视频切片完成",
|
||||
&format!("生成切片:{}", result.title),
|
||||
).await?;
|
||||
|
||||
state
|
||||
.db
|
||||
.new_message("视频切片完成", &format!("生成切片:{}", result.title))
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -918,14 +1311,14 @@ async fn find_subtitle_file(
|
||||
if local_subtitle.exists() {
|
||||
return Ok(local_subtitle);
|
||||
}
|
||||
|
||||
|
||||
// 如果是切片视频,尝试查找原视频的字幕文件
|
||||
if video.platform == "clip" && !video.desc.is_empty() {
|
||||
// 解析切片元数据,获取父视频ID
|
||||
if let Ok(metadata) = serde_json::from_str::<ClipMetadata>(&video.desc) {
|
||||
if let Ok(parent_video) = state.db.get_video(metadata.parent_video_id).await {
|
||||
let parent_filepath = Path::new(&state.config.read().await.output)
|
||||
.join(&parent_video.file);
|
||||
let parent_filepath =
|
||||
Path::new(&state.config.read().await.output).join(&parent_video.file);
|
||||
let parent_subtitle = parent_filepath.with_extension("srt");
|
||||
if parent_subtitle.exists() {
|
||||
return Ok(parent_subtitle);
|
||||
@@ -933,20 +1326,18 @@ async fn find_subtitle_file(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果都找不到,返回默认路径(即使文件不存在,让ffmpeg处理错误)
|
||||
Ok(local_subtitle)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 获取文件大小
|
||||
#[cfg_attr(feature = "gui", tauri::command)]
|
||||
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
|
||||
let path = Path::new(&file_path);
|
||||
match std::fs::metadata(path) {
|
||||
Ok(metadata) => Ok(metadata.len()),
|
||||
Err(e) => Err(format!("无法获取文件信息: {}", e))
|
||||
Err(e) => Err(format!("无法获取文件信息: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ use crate::{
|
||||
task::{delete_task, get_tasks},
|
||||
utils::{console_log, get_disk_info, list_folder, DiskInfo},
|
||||
video::{
|
||||
cancel, clip_range, clip_video, delete_video, encode_video_subtitle, generate_video_subtitle,
|
||||
generic_ffmpeg_command, get_all_videos, get_file_size, get_video, get_video_cover, get_video_subtitle,
|
||||
get_video_typelist, get_videos, import_external_video, update_video_cover, update_video_subtitle,
|
||||
upload_procedure,
|
||||
cancel, clip_range, clip_video, delete_video, encode_video_subtitle,
|
||||
generate_video_subtitle, generic_ffmpeg_command, get_all_videos, get_file_size,
|
||||
get_video, get_video_cover, get_video_subtitle, get_video_typelist, get_videos,
|
||||
import_external_video, update_video_cover, update_video_subtitle, upload_procedure,
|
||||
},
|
||||
AccountInfo,
|
||||
},
|
||||
@@ -52,7 +52,7 @@ use crate::{
|
||||
};
|
||||
use axum::{extract::Query, response::sse};
|
||||
use axum::{
|
||||
extract::{DefaultBodyLimit, Json, Path, Multipart},
|
||||
extract::{DefaultBodyLimit, Json, Multipart, Path},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Sse},
|
||||
routing::{get, post},
|
||||
@@ -806,14 +806,14 @@ async fn handler_image_base64(
|
||||
Ok(cover) => cover,
|
||||
Err(_) => return Err(StatusCode::NOT_FOUND),
|
||||
};
|
||||
|
||||
|
||||
// 检查是否是base64数据URL
|
||||
if cover.starts_with("data:image/") {
|
||||
if let Some(base64_start) = cover.find("base64,") {
|
||||
let base64_data = &cover[base64_start + 7..]; // 跳过 "base64,"
|
||||
|
||||
let base64_data = &cover[base64_start + 7..]; // 跳过 "base64,"
|
||||
|
||||
// 解码base64数据
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
if let Ok(image_data) = general_purpose::STANDARD.decode(base64_data) {
|
||||
// 确定MIME类型
|
||||
let content_type = if cover.contains("data:image/png") {
|
||||
@@ -827,8 +827,9 @@ async fn handler_image_base64(
|
||||
} else {
|
||||
"image/png" // 默认
|
||||
};
|
||||
|
||||
let mut response = axum::response::Response::new(axum::body::Body::from(image_data));
|
||||
|
||||
let mut response =
|
||||
axum::response::Response::new(axum::body::Body::from(image_data));
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
@@ -838,12 +839,12 @@ async fn handler_image_base64(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
"public, max-age=3600".parse().unwrap(),
|
||||
);
|
||||
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
@@ -930,7 +931,16 @@ async fn handler_import_external_video(
|
||||
state: axum::extract::State<State>,
|
||||
Json(param): Json<ImportExternalVideoRequest>,
|
||||
) -> Result<Json<ApiResponse<String>>, ApiError> {
|
||||
import_external_video(state.0, param.file_path.clone(), param.title, param.original_name, param.size, param.room_id).await?;
|
||||
import_external_video(
|
||||
state.0,
|
||||
param.event_id.clone(),
|
||||
param.file_path.clone(),
|
||||
param.title,
|
||||
param.original_name,
|
||||
param.size,
|
||||
param.room_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success(param.event_id)))
|
||||
}
|
||||
|
||||
@@ -955,7 +965,8 @@ async fn handler_clip_video(
|
||||
param.start_time,
|
||||
param.end_time,
|
||||
param.clip_title,
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success(param.event_id)))
|
||||
}
|
||||
|
||||
@@ -1145,7 +1156,7 @@ async fn handler_upload_file(
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| e.to_string())? {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
|
||||
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
file_name = field.file_name().unwrap_or("unknown").to_string();
|
||||
@@ -1180,23 +1191,25 @@ async fn handler_upload_file(
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or("upload");
|
||||
|
||||
|
||||
let unique_filename = if extension.is_empty() {
|
||||
format!("{}_{}", base_name, timestamp)
|
||||
} else {
|
||||
format!("{}_{}.{}", base_name, timestamp, extension)
|
||||
};
|
||||
|
||||
|
||||
let file_path = upload_dir.join(&unique_filename);
|
||||
|
||||
|
||||
// 写入文件
|
||||
tokio::fs::write(&file_path, &file_data).await.map_err(|e| e.to_string())?;
|
||||
|
||||
tokio::fs::write(&file_path, &file_data)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let file_size = file_data.len() as u64;
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
|
||||
log::info!("File uploaded: {} ({} bytes)", file_path_str, file_size);
|
||||
|
||||
|
||||
Ok(Json(ApiResponse::success(FileUploadResponse {
|
||||
file_path: file_path_str,
|
||||
file_name: unique_filename,
|
||||
@@ -1353,9 +1366,19 @@ async fn handler_output(
|
||||
);
|
||||
|
||||
// Only set Content-Disposition for non-media files to allow inline playback/display
|
||||
if !matches!(content_type,
|
||||
"video/mp4" | "video/webm" | "video/x-m4v" | "video/x-matroska" | "video/x-msvideo" |
|
||||
"image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/svg+xml") {
|
||||
if !matches!(
|
||||
content_type,
|
||||
"video/mp4"
|
||||
| "video/webm"
|
||||
| "video/x-m4v"
|
||||
| "video/x-matroska"
|
||||
| "video/x-msvideo"
|
||||
| "image/jpeg"
|
||||
| "image/png"
|
||||
| "image/gif"
|
||||
| "image/webp"
|
||||
| "image/svg+xml"
|
||||
) {
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
@@ -1447,6 +1470,8 @@ async fn handler_sse(
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_BODY_SIZE: usize = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
pub async fn start_api_server(state: State) {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
@@ -1595,7 +1620,7 @@ pub async fn start_api_server(state: State) {
|
||||
|
||||
let router = app
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(20 * 1024 * 1024))
|
||||
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
|
||||
.with_state(state);
|
||||
|
||||
let addr = "0.0.0.0:3000";
|
||||
|
||||
@@ -23,7 +23,6 @@ use errors::BiliClientError;
|
||||
use m3u8_rs::{Playlist, QuotedOrUnquoted, VariantStream};
|
||||
use regex::Regex;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::fs::File;
|
||||
@@ -62,7 +61,6 @@ pub struct BiliRecorder {
|
||||
cover: Arc<RwLock<Option<String>>>,
|
||||
entry_store: Arc<RwLock<Option<EntryStore>>>,
|
||||
is_recording: Arc<RwLock<bool>>,
|
||||
force_update: Arc<AtomicBool>,
|
||||
last_update: Arc<RwLock<i64>>,
|
||||
quit: Arc<Mutex<bool>>,
|
||||
live_stream: Arc<RwLock<Option<BiliStream>>>,
|
||||
@@ -144,7 +142,6 @@ impl BiliRecorder {
|
||||
live_id: Arc::new(RwLock::new(String::new())),
|
||||
cover: Arc::new(RwLock::new(cover)),
|
||||
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
|
||||
force_update: Arc::new(AtomicBool::new(false)),
|
||||
quit: Arc::new(Mutex::new(false)),
|
||||
live_stream: Arc::new(RwLock::new(None)),
|
||||
danmu_storage: Arc::new(RwLock::new(None)),
|
||||
@@ -332,34 +329,27 @@ impl BiliRecorder {
|
||||
|
||||
let stream = new_stream.unwrap();
|
||||
|
||||
let should_update_stream = self.live_stream.read().await.is_none()
|
||||
|| self.force_update.load(Ordering::Relaxed);
|
||||
|
||||
if should_update_stream {
|
||||
self.force_update.store(false, Ordering::Relaxed);
|
||||
|
||||
let new_stream = self.fetch_real_stream(&stream).await;
|
||||
if new_stream.is_err() {
|
||||
log::error!(
|
||||
"[{}]Fetch real stream failed: {}",
|
||||
self.room_id,
|
||||
new_stream.err().unwrap()
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
let new_stream = new_stream.unwrap();
|
||||
*self.live_stream.write().await = Some(new_stream);
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
|
||||
log::info!(
|
||||
"[{}]Update to a new stream: {:?} => {}",
|
||||
let new_stream = self.fetch_real_stream(&stream).await;
|
||||
if new_stream.is_err() {
|
||||
log::error!(
|
||||
"[{}]Fetch real stream failed: {}",
|
||||
self.room_id,
|
||||
self.live_stream.read().await.clone(),
|
||||
stream
|
||||
new_stream.err().unwrap()
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
let new_stream = new_stream.unwrap();
|
||||
*self.live_stream.write().await = Some(new_stream);
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
|
||||
log::info!(
|
||||
"[{}]Update to a new stream: {:?} => {}",
|
||||
self.room_id,
|
||||
self.live_stream.read().await.clone(),
|
||||
stream
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -579,7 +569,6 @@ impl BiliRecorder {
|
||||
let current_stream = current_stream.unwrap();
|
||||
let parsed = self.get_playlist().await;
|
||||
if parsed.is_err() {
|
||||
self.force_update.store(true, Ordering::Relaxed);
|
||||
return Err(parsed.err().unwrap());
|
||||
}
|
||||
|
||||
@@ -1065,8 +1054,7 @@ impl BiliRecorder {
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.expire - Utc::now().timestamp() < pre_offset as i64)
|
||||
{
|
||||
log::info!("Stream is nearly expired, force update");
|
||||
self.force_update.store(true, Ordering::Relaxed);
|
||||
log::info!("Stream is nearly expired");
|
||||
return Err(super::errors::RecorderError::StreamExpired {
|
||||
stream: current_stream.unwrap(),
|
||||
});
|
||||
@@ -1153,7 +1141,10 @@ impl super::Recorder for BiliRecorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// whatever error happened during update entries, reset to start another recording.
|
||||
*self_clone.is_recording.write().await = false;
|
||||
self_clone.reset().await;
|
||||
// go check status again after random 2-5 secs
|
||||
let secs = rand::random::<u64>() % 4 + 2;
|
||||
tokio::time::sleep(Duration::from_secs(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { invoke, TAURI_ENV, ENDPOINT } from "../lib/invoker";
|
||||
import { invoke, TAURI_ENV, ENDPOINT, listen } from "../lib/invoker";
|
||||
import { Upload, X, CheckCircle } from "lucide-svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { ProgressUpdate, ProgressFinished } from "./interface";
|
||||
|
||||
export let showDialog = false;
|
||||
export let roomId: number | null = null;
|
||||
@@ -18,6 +19,60 @@
|
||||
let uploadProgress = 0;
|
||||
let dragOver = false;
|
||||
let fileInput: HTMLInputElement;
|
||||
let importProgress = "";
|
||||
let currentImportEventId: string | null = null;
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(sizeInBytes: number): string {
|
||||
if (sizeInBytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const k = 1024;
|
||||
let unitIndex = 0;
|
||||
let size = sizeInBytes;
|
||||
|
||||
// 找到合适的单位
|
||||
while (size >= k && unitIndex < units.length - 1) {
|
||||
size /= k;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// 对于GB以上,显示2位小数;MB显示2位小数;KB及以下显示1位小数
|
||||
const decimals = unitIndex >= 3 ? 2 : (unitIndex >= 2 ? 2 : 1);
|
||||
|
||||
return size.toFixed(decimals) + " " + units[unitIndex];
|
||||
}
|
||||
|
||||
// 进度监听器
|
||||
const progressUpdateListener = listen<ProgressUpdate>('progress-update', (e) => {
|
||||
if (e.payload.id === currentImportEventId) {
|
||||
importProgress = e.payload.content;
|
||||
}
|
||||
});
|
||||
|
||||
const progressFinishedListener = listen<ProgressFinished>('progress-finished', (e) => {
|
||||
if (e.payload.id === currentImportEventId) {
|
||||
if (e.payload.success) {
|
||||
// 导入成功,关闭对话框并刷新列表
|
||||
showDialog = false;
|
||||
selectedFilePath = null;
|
||||
selectedFileName = "";
|
||||
selectedFileSize = 0;
|
||||
videoTitle = "";
|
||||
dispatch("imported");
|
||||
} else {
|
||||
alert("导入失败: " + e.payload.message);
|
||||
}
|
||||
importing = false;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
progressUpdateListener?.then(fn => fn());
|
||||
progressFinishedListener?.then(fn => fn());
|
||||
});
|
||||
|
||||
async function handleFileSelect() {
|
||||
if (TAURI_ENV) {
|
||||
@@ -151,8 +206,12 @@
|
||||
if (!selectedFilePath) return;
|
||||
|
||||
importing = true;
|
||||
importProgress = "准备导入...";
|
||||
|
||||
try {
|
||||
const eventId = "import_" + Date.now();
|
||||
currentImportEventId = eventId;
|
||||
|
||||
await invoke("import_external_video", {
|
||||
eventId: eventId,
|
||||
filePath: selectedFilePath,
|
||||
@@ -162,20 +221,13 @@
|
||||
roomId: roomId || 0
|
||||
});
|
||||
|
||||
// 导入成功,关闭对话框并刷新列表
|
||||
showDialog = false;
|
||||
selectedFilePath = null;
|
||||
selectedFileName = "";
|
||||
selectedFileSize = 0;
|
||||
videoTitle = "";
|
||||
dispatch("imported");
|
||||
// 注意:成功处理移到了progressFinishedListener中
|
||||
} catch (error) {
|
||||
console.error("导入失败:", error);
|
||||
alert("导入失败: " + error);
|
||||
} finally {
|
||||
importing = false;
|
||||
uploading = false;
|
||||
uploadProgress = 0;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +239,9 @@
|
||||
videoTitle = "";
|
||||
uploading = false;
|
||||
uploadProgress = 0;
|
||||
importing = false;
|
||||
currentImportEventId = null;
|
||||
importProgress = "";
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
@@ -251,7 +306,7 @@
|
||||
<CheckCircle class="w-12 h-12 text-green-500 mx-auto" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-900 dark:text-white font-medium">{selectedFileName}</p>
|
||||
<p class="text-xs text-gray-500">大小: {(selectedFileSize / 1024 / 1024).toFixed(2)} MB</p>
|
||||
<p class="text-xs text-gray-500">大小: {formatFileSize(selectedFileSize)}</p>
|
||||
<p class="text-xs text-gray-400 break-all" title={selectedFilePath}>{selectedFilePath}</p>
|
||||
<button
|
||||
on:click={() => {
|
||||
@@ -327,9 +382,15 @@
|
||||
<button
|
||||
on:click={startImport}
|
||||
disabled={!selectedFilePath || importing || !videoTitle.trim() || uploading}
|
||||
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
{importing ? "导入中..." : "开始导入"}
|
||||
{#if importing}
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{importing ? (importProgress || "导入中...") : "开始导入"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,6 +238,8 @@
|
||||
}
|
||||
selectedVideos.clear();
|
||||
await loadVideos();
|
||||
showDeleteConfirm = false;
|
||||
videoToDelete = null;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete selected videos:", error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user