""" 音频转换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 @field_validator("speed") @classmethod def validate_speed(cls, v): if not 0.5 <= v <= 2.0: raise ValueError("语速必须在0.5-2.0之间") return v @field_validator("pitch") @classmethod 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 @field_validator("file_url") @classmethod 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 @field_validator("output_formats") @classmethod 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 @field_validator("r2_file_path") @classmethod 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 @field_validator("output_formats") @classmethod 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 @router.post("/convert", response_model=ConvertResponse) 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(), } }, ) @router.post("/convert-r2", response_model=ConvertResponse) 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(), } }, ) @router.get("/convert/{task_id}/status") 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(), } }, ) @router.get("/convert/{user_id}/{task_id}/status") 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(), } }, ) @router.get("/convert/{task_id}/result") 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(), } }, )