Spaces:
Runtime error
Runtime error
| """ | |
| 音频转换API路由 | |
| 处理EPUB转音频的核心业务逻辑 | |
| """ | |
| from fastapi import APIRouter, HTTPException | |
| from pydantic import BaseModel, field_validator | |
| from typing import List | |
| from datetime import datetime | |
| import re | |
| from app.models.task import ( | |
| ConversionTask, | |
| VoiceSettings as TaskVoiceSettings, | |
| TaskStatus, | |
| ) | |
| from app.services.task_queue import task_queue | |
| from app.core.logging import get_logger | |
| logger = get_logger(__name__) | |
| router = APIRouter() | |
| class VoiceSettings(BaseModel): | |
| """语音设置模型""" | |
| language: str = "zh-CN" | |
| voice_name: str = "xiaoxiao" | |
| speed: float = 1.0 | |
| pitch: int = 0 | |
| volume: float = 0.8 | |
| def validate_speed(cls, v): | |
| if not 0.5 <= v <= 2.0: | |
| raise ValueError("语速必须在0.5-2.0之间") | |
| return v | |
| def validate_pitch(cls, v): | |
| if not -20 <= v <= 20: | |
| raise ValueError("音调必须在-20到20之间") | |
| return v | |
| class ConvertRequest(BaseModel): | |
| """转换请求模型""" | |
| file_url: str | |
| voice_settings: VoiceSettings = VoiceSettings() | |
| output_formats: List[str] = ["mp3"] | |
| audio_quality: str = "high" | |
| split_by_chapter: bool = True | |
| max_segment_length: int = 300 | |
| def validate_file_url(cls, v): | |
| if not v or not v.startswith(("http://", "https://")): | |
| raise ValueError("文件URL格式无效") | |
| if not v.lower().endswith((".epub", ".txt")): | |
| raise ValueError("仅支持EPUB和TXT格式文件") | |
| return v | |
| def validate_output_formats(cls, v): | |
| supported_formats = ["mp3", "wav", "m4a"] | |
| for fmt in v: | |
| if fmt not in supported_formats: | |
| raise ValueError(f"不支持的输出格式: {fmt}") | |
| return v | |
| class ConvertR2Request(BaseModel): | |
| """R2路径转换请求模型""" | |
| r2_file_path: str | |
| voice_settings: VoiceSettings = VoiceSettings() | |
| output_formats: List[str] = ["mp3"] | |
| audio_quality: str = "high" | |
| split_by_chapter: bool = True | |
| max_segment_length: int = 300 | |
| def validate_r2_file_path(cls, v): | |
| if not v or not v.strip(): | |
| raise ValueError("R2文件路径不能为空") | |
| # 清理路径 | |
| v = v.strip() | |
| # 验证路径格式:users/{user_id}/{category}/{task_id}/{filename} | |
| path_parts = v.split('/') | |
| if len(path_parts) < 4: | |
| raise ValueError("R2文件路径格式无效,应为: users/{user_id}/{category}/{task_id}/{filename}") | |
| if path_parts[0] != "users": | |
| raise ValueError("R2文件路径必须以'users/'开头") | |
| # 验证各部分不能为空 | |
| for i, part in enumerate(path_parts): | |
| if not part.strip(): | |
| raise ValueError(f"路径第{i+1}部分不能为空") | |
| # 验证文件扩展名 | |
| filename = path_parts[-1] | |
| if not filename.lower().endswith((".epub", ".txt")): | |
| raise ValueError("仅支持EPUB和TXT格式文件") | |
| return v | |
| def validate_output_formats(cls, v): | |
| supported_formats = ["mp3", "wav", "m4a"] | |
| for fmt in v: | |
| if fmt not in supported_formats: | |
| raise ValueError(f"不支持的输出格式: {fmt}") | |
| return v | |
| class ConvertResponse(BaseModel): | |
| """转换响应模型""" | |
| task_id: str | |
| status: str | |
| message: str | |
| queue_position: int | |
| estimated_time: str | |
| queue_size: int | |
| async def submit_conversion_task(request: ConvertRequest): | |
| """提交EPUB转音频任务""" | |
| try: | |
| logger.info(f"收到转换请求: {request.file_url}") | |
| # 创建转换任务 | |
| task = ConversionTask( | |
| file_url=request.file_url, | |
| voice_settings=TaskVoiceSettings( | |
| language=request.voice_settings.language, | |
| voice_name=request.voice_settings.voice_name, | |
| speed=request.voice_settings.speed, | |
| pitch=request.voice_settings.pitch, | |
| volume=request.voice_settings.volume, | |
| ), | |
| output_formats=request.output_formats, | |
| audio_quality=request.audio_quality, | |
| split_by_chapter=request.split_by_chapter, | |
| max_segment_length=request.max_segment_length, | |
| ) | |
| # 提取原始文件名 | |
| import os | |
| task.original_filename = os.path.basename(request.file_url) | |
| # 添加到任务队列 | |
| success = task_queue.add_task(task) | |
| if not success: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "error": { | |
| "code": "QUEUE_FULL", | |
| "message": "任务队列已满,请稍后重试", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| # 获取队列状态 | |
| queue_status = task_queue.get_queue_status() | |
| return ConvertResponse( | |
| task_id=task.task_id, | |
| status=task.status.value, | |
| message="音频转换任务已添加到队列", | |
| queue_position=task.queue_position, | |
| estimated_time="15分钟", # TODO: 基于队列状态计算 | |
| queue_size=queue_status["queue_size"], | |
| ) | |
| except ValueError as e: | |
| raise HTTPException( | |
| status_code=400, | |
| detail={ | |
| "error": { | |
| "code": "INVALID_REQUEST", | |
| "message": str(e), | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| except Exception as e: | |
| logger.error(f"提交转换任务失败: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail={ | |
| "error": { | |
| "code": "INTERNAL_SERVER_ERROR", | |
| "message": "服务器内部错误", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| async def submit_conversion_task_r2(request: ConvertR2Request): | |
| """提交R2路径EPUB转音频任务""" | |
| try: | |
| logger.info(f"收到R2路径转换请求: {request.r2_file_path}") | |
| # 对于R2路径,直接使用r2://前缀,让存储服务处理下载逻辑 | |
| # 这样可以避免构建可能不正确的HTTP URL | |
| file_url = f"r2://{request.r2_file_path}" | |
| # 创建转换任务 | |
| task = ConversionTask( | |
| file_url=file_url, | |
| voice_settings=TaskVoiceSettings( | |
| language=request.voice_settings.language, | |
| voice_name=request.voice_settings.voice_name, | |
| speed=request.voice_settings.speed, | |
| pitch=request.voice_settings.pitch, | |
| volume=request.voice_settings.volume, | |
| ), | |
| output_formats=request.output_formats, | |
| audio_quality=request.audio_quality, | |
| split_by_chapter=request.split_by_chapter, | |
| max_segment_length=request.max_segment_length, | |
| ) | |
| # 设置R2路径信息 | |
| task.r2_file_path = request.r2_file_path | |
| # 提取原始文件名 | |
| import os | |
| task.original_filename = os.path.basename(request.r2_file_path) | |
| # 添加到任务队列 | |
| success = task_queue.add_task(task) | |
| if not success: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "error": { | |
| "code": "QUEUE_FULL", | |
| "message": "任务队列已满,请稍后重试", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| # 获取队列状态 | |
| queue_status = task_queue.get_queue_status() | |
| return ConvertResponse( | |
| task_id=task.task_id, | |
| status=task.status.value, | |
| message="R2路径音频转换任务已添加到队列", | |
| queue_position=task.queue_position, | |
| estimated_time="15分钟", # TODO: 基于队列状态计算 | |
| queue_size=queue_status["queue_size"], | |
| ) | |
| except ValueError as e: | |
| raise HTTPException( | |
| status_code=400, | |
| detail={ | |
| "error": { | |
| "code": "INVALID_REQUEST", | |
| "message": str(e), | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| except Exception as e: | |
| logger.error(f"提交R2路径转换任务失败: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail={ | |
| "error": { | |
| "code": "INTERNAL_SERVER_ERROR", | |
| "message": "服务器内部错误", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| async def get_conversion_status(task_id: str): | |
| """查询转换任务状态(兼容旧版本)""" | |
| try: | |
| # 验证任务ID格式 | |
| if not re.match(r"^audio_[0-9a-f-]+$", task_id): | |
| raise HTTPException( | |
| status_code=400, | |
| detail={ | |
| "error": { | |
| "code": "INVALID_TASK_ID", | |
| "message": "任务ID格式无效", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| # 获取任务信息 | |
| task = task_queue.get_task(task_id) | |
| if not task: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "error": { | |
| "code": "TASK_NOT_FOUND", | |
| "message": "任务不存在", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| return task.to_dict() | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"查询任务状态失败: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail={ | |
| "error": { | |
| "code": "INTERNAL_SERVER_ERROR", | |
| "message": "服务器内部错误", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| async def get_conversion_status_with_user(user_id: str, task_id: str): | |
| """查询转换任务状态(新版本,支持从R2读取)""" | |
| try: | |
| # 验证参数格式 | |
| if not user_id or not task_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail={ | |
| "error": { | |
| "code": "INVALID_PARAMETERS", | |
| "message": "用户ID和任务ID不能为空", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| # 获取任务信息(优先从R2读取) | |
| task = await task_queue.get_task_with_user_id(user_id, task_id) | |
| if not task: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "error": { | |
| "code": "TASK_NOT_FOUND", | |
| "message": "任务不存在", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| return task.to_dict() | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"查询任务状态失败: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail={ | |
| "error": { | |
| "code": "INTERNAL_SERVER_ERROR", | |
| "message": "服务器内部错误", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| async def get_conversion_result(task_id: str): | |
| """获取转换结果""" | |
| try: | |
| # 验证任务ID格式 | |
| if not re.match(r"^audio_[0-9a-f-]+$", task_id): | |
| raise HTTPException( | |
| status_code=400, | |
| detail={ | |
| "error": { | |
| "code": "INVALID_TASK_ID", | |
| "message": "任务ID格式无效", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| # 获取任务信息 | |
| task = task_queue.get_task(task_id) | |
| if not task: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "error": { | |
| "code": "TASK_NOT_FOUND", | |
| "message": "任务不存在", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| # 检查任务状态 | |
| if task.status != TaskStatus.COMPLETED: | |
| raise HTTPException( | |
| status_code=400, | |
| detail={ | |
| "error": { | |
| "code": "TASK_NOT_COMPLETED", | |
| "message": f"任务尚未完成,当前状态: {task.status.value}", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |
| # 返回完整的任务结果 | |
| result = task.to_dict() | |
| result.update( | |
| { | |
| "original_filename": task.original_filename, | |
| "audio_files": task.audio_files, | |
| "metadata": task.metadata, | |
| } | |
| ) | |
| return result | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"获取转换结果失败: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail={ | |
| "error": { | |
| "code": "INTERNAL_SERVER_ERROR", | |
| "message": "服务器内部错误", | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| }, | |
| ) | |