mirror of https://github.com/Mai-with-u/MaiBot.git
281 lines
8.0 KiB
Python
281 lines
8.0 KiB
Python
"""
|
|
文件操作工具类
|
|
提供异步文件操作、临时文件管理等功能
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
import tempfile
|
|
import asyncio
|
|
import base64
|
|
from typing import Optional
|
|
from src.common.logger import get_logger
|
|
|
|
logger = get_logger("tts_file_manager")
|
|
|
|
# 音频数据最小有效大小(字节)
|
|
MIN_AUDIO_SIZE = 100
|
|
|
|
|
|
class TTSFileManager:
|
|
"""
|
|
TTS文件管理器
|
|
|
|
提供:
|
|
- 临时文件创建(避免并发冲突)
|
|
- 异步文件写入
|
|
- 自动清理
|
|
- 相对路径和绝对路径支持
|
|
"""
|
|
|
|
# 临时文件目录(兼容旧代码)
|
|
_temp_dir: Optional[str] = None
|
|
|
|
# 项目根目录(用于解析相对路径)
|
|
_project_root: Optional[str] = None
|
|
|
|
@classmethod
|
|
def set_project_root(cls, root_path: str):
|
|
"""设置项目根目录"""
|
|
if os.path.isdir(root_path):
|
|
cls._project_root = root_path
|
|
logger.debug(f"设置项目根目录: {root_path}")
|
|
else:
|
|
logger.warning(f"项目根目录不存在: {root_path}")
|
|
|
|
@classmethod
|
|
def get_project_root(cls) -> str:
|
|
"""获取项目根目录"""
|
|
if cls._project_root is None:
|
|
# 尝试从当前文件位置推断项目根目录
|
|
current_file = os.path.abspath(__file__)
|
|
# 假设结构是: project_root/plugins/tts_voice_plugin/utils/file.py
|
|
cls._project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file))))
|
|
logger.debug(f"自动推断项目根目录: {cls._project_root}")
|
|
return cls._project_root
|
|
|
|
@classmethod
|
|
def resolve_path(cls, path: str) -> str:
|
|
"""
|
|
解析路径(支持相对路径和绝对路径)
|
|
|
|
Args:
|
|
path: 路径字符串
|
|
|
|
Returns:
|
|
解析后的绝对路径
|
|
"""
|
|
if os.path.isabs(path):
|
|
# 已经是绝对路径
|
|
return path
|
|
else:
|
|
# 相对路径,相对于项目根目录
|
|
return os.path.join(cls.get_project_root(), path)
|
|
|
|
@classmethod
|
|
def ensure_dir(cls, dir_path: str) -> bool:
|
|
"""
|
|
确保目录存在,不存在则创建
|
|
|
|
Args:
|
|
dir_path: 目录路径
|
|
|
|
Returns:
|
|
是否成功
|
|
"""
|
|
try:
|
|
os.makedirs(dir_path, exist_ok=True)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"创建目录失败: {dir_path}, 错误: {e}")
|
|
return False
|
|
|
|
@classmethod
|
|
def get_temp_dir(cls) -> str:
|
|
"""
|
|
获取临时文件目录(已废弃,保留兼容性)
|
|
|
|
Returns:
|
|
临时目录路径
|
|
"""
|
|
if cls._temp_dir is None:
|
|
cls._temp_dir = tempfile.gettempdir()
|
|
return cls._temp_dir
|
|
|
|
@classmethod
|
|
def set_temp_dir(cls, path: str):
|
|
"""
|
|
设置临时文件目录(已废弃,保留兼容性)
|
|
|
|
Args:
|
|
path: 目录路径
|
|
"""
|
|
if os.path.isdir(path):
|
|
cls._temp_dir = path
|
|
else:
|
|
raise ValueError(f"目录不存在: {path}")
|
|
|
|
@classmethod
|
|
def generate_temp_path(cls, prefix: str = "tts", suffix: str = ".mp3", output_dir: str = "") -> str:
|
|
"""
|
|
生成唯一的临时文件路径
|
|
|
|
Args:
|
|
prefix: 文件名前缀
|
|
suffix: 文件扩展名
|
|
output_dir: 输出目录(支持相对路径和绝对路径,留空使用项目根目录)
|
|
|
|
Returns:
|
|
临时文件的绝对路径
|
|
"""
|
|
# 确定输出目录
|
|
if not output_dir:
|
|
# 默认使用项目根目录
|
|
resolved_dir = cls.get_project_root()
|
|
else:
|
|
# 解析用户配置的路径
|
|
resolved_dir = cls.resolve_path(output_dir)
|
|
# 确保目录存在
|
|
if not cls.ensure_dir(resolved_dir):
|
|
# 如果创建失败,降级到项目根目录
|
|
logger.warning(f"无法创建输出目录 {resolved_dir},使用项目根目录")
|
|
resolved_dir = cls.get_project_root()
|
|
|
|
# 生成唯一文件名
|
|
unique_id = uuid.uuid4().hex[:12]
|
|
filename = f"{prefix}_{unique_id}{suffix}"
|
|
return os.path.join(resolved_dir, filename)
|
|
|
|
@classmethod
|
|
async def write_audio_async(cls, path: str, data: bytes) -> bool:
|
|
"""
|
|
异步写入音频数据到文件
|
|
|
|
Args:
|
|
path: 文件路径
|
|
data: 音频二进制数据
|
|
|
|
Returns:
|
|
是否写入成功
|
|
"""
|
|
try:
|
|
# 使用线程池执行同步文件写入,避免阻塞事件循环
|
|
loop = asyncio.get_event_loop()
|
|
await loop.run_in_executor(None, cls._write_file_sync, path, data)
|
|
logger.debug(f"音频文件写入成功: {path} ({len(data)} bytes)")
|
|
return True
|
|
except IOError as e:
|
|
logger.error(f"写入音频文件失败: {path}, 错误: {e}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"写入音频文件时发生未知错误: {path}, 错误: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def _write_file_sync(path: str, data: bytes):
|
|
"""同步写入文件(内部方法)"""
|
|
with open(path, "wb") as f:
|
|
f.write(data)
|
|
|
|
@classmethod
|
|
def write_audio_sync(cls, path: str, data: bytes) -> bool:
|
|
"""
|
|
同步写入音频数据到文件
|
|
|
|
Args:
|
|
path: 文件路径
|
|
data: 音频二进制数据
|
|
|
|
Returns:
|
|
是否写入成功
|
|
"""
|
|
try:
|
|
cls._write_file_sync(path, data)
|
|
logger.debug(f"音频文件写入成功: {path} ({len(data)} bytes)")
|
|
return True
|
|
except IOError as e:
|
|
logger.error(f"写入音频文件失败: {path}, 错误: {e}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"写入音频文件时发生未知错误: {path}, 错误: {e}")
|
|
return False
|
|
|
|
@classmethod
|
|
def cleanup_file(cls, path: str, silent: bool = True) -> bool:
|
|
"""
|
|
清理临时文件
|
|
|
|
Args:
|
|
path: 文件路径
|
|
silent: 是否静默处理错误
|
|
|
|
Returns:
|
|
是否清理成功
|
|
"""
|
|
try:
|
|
if path and os.path.exists(path):
|
|
os.remove(path)
|
|
logger.debug(f"临时文件已清理: {path}")
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
if not silent:
|
|
logger.warning(f"清理临时文件失败: {path}, 错误: {e}")
|
|
return False
|
|
|
|
@classmethod
|
|
async def cleanup_file_async(cls, path: str, delay: float = 0) -> bool:
|
|
"""
|
|
异步清理临时文件(可延迟)
|
|
|
|
Args:
|
|
path: 文件路径
|
|
delay: 延迟秒数
|
|
|
|
Returns:
|
|
是否清理成功
|
|
"""
|
|
if delay > 0:
|
|
await asyncio.sleep(delay)
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(None, cls.cleanup_file, path, True)
|
|
|
|
@classmethod
|
|
def validate_audio_data(cls, data: bytes, min_size: int = None) -> tuple:
|
|
"""
|
|
验证音频数据有效性
|
|
|
|
Args:
|
|
data: 音频二进制数据
|
|
min_size: 最小有效大小
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
if data is None:
|
|
return False, "音频数据为空"
|
|
|
|
min_size = min_size or MIN_AUDIO_SIZE
|
|
|
|
if len(data) < min_size:
|
|
return False, f"音频数据过小({len(data)}字节 < {min_size}字节)"
|
|
|
|
return True, ""
|
|
|
|
@classmethod
|
|
def audio_to_base64(cls, data: bytes) -> str:
|
|
"""
|
|
将音频数据转换为base64字符串
|
|
|
|
Args:
|
|
data: 音频二进制数据
|
|
|
|
Returns:
|
|
base64编码的字符串
|
|
"""
|
|
try:
|
|
return base64.b64encode(data).decode('utf-8')
|
|
except Exception as e:
|
|
logger.error(f"音频数据转base64失败: {e}")
|
|
return ""
|