MaiBot/plugins/tts_voice_plugin/utils/file.py

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 ""