import asyncio import json import logging from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy import func, case from sqlalchemy.orm import Session from api.auth import get_current_user from core.database import get_db from db import crud from db.models import User, AudiobookSegment from schemas.audiobook import ( AudiobookProjectCreate, AudiobookProjectResponse, AudiobookProjectDetail, AudiobookCharacterResponse, AudiobookChapterResponse, AudiobookCharacterEdit, AudiobookSegmentResponse, AudiobookSegmentUpdate, AudiobookGenerateRequest, AudiobookAnalyzeRequest, ScriptGenerationRequest, SynopsisGenerationRequest, ContinueScriptRequest, NsfwSynopsisGenerationRequest, NsfwScriptGenerationRequest, ) from core.config import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/audiobook", tags=["audiobook"]) async def require_nsfw(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): from db.crud import can_user_use_nsfw if not can_user_use_nsfw(current_user): raise HTTPException(status_code=403, detail="NSFW access not granted") return current_user def _project_to_response(project, segment_total: int = 0, segment_done: int = 0) -> AudiobookProjectResponse: return AudiobookProjectResponse( id=project.id, user_id=project.user_id, title=project.title, source_type=project.source_type, status=project.status, llm_model=project.llm_model, error_message=project.error_message, script_config=getattr(project, 'script_config', None), created_at=project.created_at, updated_at=project.updated_at, segment_total=segment_total, segment_done=segment_done, ) def _char_to_response(c, db: Session) -> AudiobookCharacterResponse: vd_name = None vd_speaker = None if c.voice_design_id: from db.models import VoiceDesign vd = db.query(VoiceDesign).filter(VoiceDesign.id == c.voice_design_id).first() if vd: vd_name = vd.name meta = vd.meta_data or {} vd_speaker = meta.get('speaker') or vd.aliyun_voice_id or vd.instruct or None return AudiobookCharacterResponse( id=c.id, project_id=c.project_id, name=c.name, gender=c.gender, description=c.description, instruct=c.instruct, voice_design_id=c.voice_design_id, voice_design_name=vd_name, voice_design_speaker=vd_speaker, use_indextts2=c.use_indextts2 or False, ) def _project_to_detail(project, db: Session) -> AudiobookProjectDetail: characters = [_char_to_response(c, db) for c in (project.characters or [])] chapters = [ AudiobookChapterResponse( id=ch.id, project_id=ch.project_id, chapter_index=ch.chapter_index, title=ch.title, status=ch.status, error_message=ch.error_message, ) for ch in (project.chapters or []) ] return AudiobookProjectDetail( id=project.id, user_id=project.user_id, title=project.title, source_type=project.source_type, status=project.status, llm_model=project.llm_model, error_message=project.error_message, created_at=project.created_at, updated_at=project.updated_at, characters=characters, chapters=chapters, ) @router.post("/projects", response_model=AudiobookProjectResponse, status_code=status.HTTP_201_CREATED) async def create_project( data: AudiobookProjectCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): if data.source_type not in ("text", "epub"): raise HTTPException(status_code=400, detail="source_type must be 'text' or 'epub'") if data.source_type == "text" and not data.source_text: raise HTTPException(status_code=400, detail="source_text required for text type") project = crud.create_audiobook_project( db=db, user_id=current_user.id, title=data.title, source_type=data.source_type, source_text=data.source_text, llm_model=crud.get_system_setting(db, "llm_model"), ) return _project_to_response(project) @router.post("/projects/upload", response_model=AudiobookProjectResponse, status_code=status.HTTP_201_CREATED) async def upload_epub_project( title: str = Form(...), file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): if not file.filename.endswith(".epub"): raise HTTPException(status_code=400, detail="Only .epub files are supported") upload_dir = Path(settings.OUTPUT_DIR) / "audiobook" / "uploads" upload_dir.mkdir(parents=True, exist_ok=True) from datetime import datetime ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") safe_name = "".join(c for c in file.filename if c.isalnum() or c in "._-") file_path = upload_dir / f"{ts}_{safe_name}" content = await file.read() with open(file_path, "wb") as f: f.write(content) project = crud.create_audiobook_project( db=db, user_id=current_user.id, title=title, source_type="epub", source_path=str(file_path), llm_model=crud.get_system_setting(db, "llm_model"), ) return _project_to_response(project) @router.get("/projects", response_model=list[AudiobookProjectResponse]) async def list_projects( skip: int = 0, limit: int = 50, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): projects = crud.list_audiobook_projects(db, current_user.id, skip=skip, limit=limit) project_ids = [p.id for p in projects] counts = db.query( AudiobookSegment.project_id, func.count(AudiobookSegment.id).label('total'), func.sum(case((AudiobookSegment.status == 'done', 1), else_=0)).label('done'), ).filter(AudiobookSegment.project_id.in_(project_ids)).group_by(AudiobookSegment.project_id).all() count_map = {r.project_id: (int(r.total), int(r.done)) for r in counts} return [_project_to_response(p, *count_map.get(p.id, (0, 0))) for p in projects] @router.post("/projects/generate-synopsis") async def generate_synopsis( data: SynopsisGenerationRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): from db.crud import get_system_setting if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.") from core.audiobook_service import _get_llm_service llm = _get_llm_service(db) system_prompt = ( "你是一位专业的小说策划师,擅长根据创作参数生成引人入胜的故事简介。" "请根据用户提供的类型、风格、主角、冲突等参数,生成一段200-400字的中文故事简介。" "简介需涵盖:世界观背景、主角基本情况、核心矛盾冲突、故事基调。" "暴力程度和色情程度数值越高,简介中相关情节描写越多、越直接。" "直接输出简介正文,不要加任何前缀标题或说明文字。" ) parts = [f"书名:{data.title}", f"类型:{data.genre}"] if data.subgenre: parts.append(f"子类型:{data.subgenre}") if data.protagonist_type: parts.append(f"主角类型:{data.protagonist_type}") if data.tone: parts.append(f"故事基调:{data.tone}") if data.conflict_scale: parts.append(f"冲突规模:{data.conflict_scale}") parts.append(f"角色数量:约{data.num_characters}个主要角色") parts.append(f"故事体量:约{data.num_chapters}章") if data.violence_level > 0: parts.append(f"暴力程度:{data.violence_level}/10") if data.eroticism_level > 0: parts.append(f"色情程度:{data.eroticism_level}/10") user_message = "\n".join(parts) + "\n\n请生成故事简介:" try: synopsis = await llm.chat(system_prompt, user_message) except Exception as e: logger.error(f"Synopsis generation failed: {e}") raise HTTPException(status_code=500, detail=f"LLM generation failed: {str(e)}") return {"synopsis": synopsis} @router.post("/projects/generate-script", response_model=AudiobookProjectResponse, status_code=status.HTTP_201_CREATED) async def create_ai_script_project( data: ScriptGenerationRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): from db.crud import get_system_setting if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.") project = crud.create_audiobook_project( db=db, user_id=current_user.id, title=data.title, source_type="ai_generated", script_config=data.model_dump(), ) from core.audiobook_service import generate_ai_script from core.database import SessionLocal project_id = project.id user_id = current_user.id async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, user_id) await generate_ai_script(project_id, db_user, async_db) finally: async_db.close() asyncio.create_task(run()) return _project_to_response(project) @router.post("/projects/{project_id}/regenerate-characters") async def regenerate_characters( project_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") if project.source_type != "ai_generated": raise HTTPException(status_code=400, detail="Only AI-generated projects support this operation") if project.status in ("analyzing", "generating"): raise HTTPException(status_code=400, detail=f"Project is currently {project.status}, please wait") cfg = project.script_config or {} is_nsfw = cfg.get("nsfw_mode", False) if is_nsfw: from db.crud import can_user_use_nsfw if not can_user_use_nsfw(current_user): raise HTTPException(status_code=403, detail="NSFW access not granted") from db.crud import get_system_setting if not get_system_setting(db, "grok_api_key") or not get_system_setting(db, "grok_base_url"): raise HTTPException(status_code=400, detail="Grok config not set. Please configure Grok API key first.") from core.audiobook_service import generate_ai_script_nsfw service_fn = generate_ai_script_nsfw else: from db.crud import get_system_setting if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.") from core.audiobook_service import generate_ai_script service_fn = generate_ai_script from core.database import SessionLocal user_id = current_user.id async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, user_id) await service_fn(project_id, db_user, async_db) finally: async_db.close() asyncio.create_task(run()) return {"message": "Character regeneration started", "project_id": project_id} @router.post("/projects/{project_id}/continue-script") async def continue_script( project_id: int, data: ContinueScriptRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") if project.source_type != "ai_generated": raise HTTPException(status_code=400, detail="Only AI-generated projects support this operation") if project.status not in ("ready", "done", "error"): raise HTTPException(status_code=400, detail=f"Project must be in 'ready' or 'done' state, current: {project.status}") from db.crud import get_system_setting cfg = project.script_config or {} if cfg.get("nsfw_mode"): from db.crud import can_user_use_nsfw if not can_user_use_nsfw(current_user): raise HTTPException(status_code=403, detail="NSFW access not granted") if not get_system_setting(db, "grok_api_key") or not get_system_setting(db, "grok_base_url"): raise HTTPException(status_code=400, detail="Grok config not set. Please configure Grok API key first.") else: if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.") from core.audiobook_service import continue_ai_script_chapters from core.database import SessionLocal additional_chapters = max(1, min(20, data.additional_chapters)) user_id = current_user.id async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, user_id) await continue_ai_script_chapters(project_id, additional_chapters, db_user, async_db) finally: async_db.close() asyncio.create_task(run()) return {"message": f"Continuing script generation ({additional_chapters} chapters)", "project_id": project_id} @router.post("/projects/generate-synopsis-nsfw") async def generate_synopsis_nsfw( data: NsfwSynopsisGenerationRequest, current_user: User = Depends(require_nsfw), db: Session = Depends(get_db), ): from db.crud import get_system_setting if not get_system_setting(db, "grok_api_key") or not get_system_setting(db, "grok_base_url"): raise HTTPException(status_code=400, detail="Grok config not set. Please configure Grok API key first.") from core.audiobook_service import _get_grok_service llm = _get_grok_service(db) system_prompt = ( "你是一位专业的成人小说策划师,擅长根据创作参数生成引人入胜的故事简介。" "请根据用户提供的类型、风格、主角、冲突等参数,生成一段200-400字的中文故事简介。" "简介需涵盖:世界观背景、主角基本情况、核心矛盾冲突、故事基调。" "暴力程度和色情程度数值越高,简介中相关情节描写越多、越露骨直接。" "直接输出简介正文,不要加任何前缀标题或说明文字。" ) parts = [f"书名:{data.title}", f"类型:{data.genre}"] if data.subgenre: parts.append(f"子类型:{data.subgenre}") if data.protagonist_type: parts.append(f"主角类型:{data.protagonist_type}") if data.tone: parts.append(f"故事基调:{data.tone}") if data.conflict_scale: parts.append(f"冲突规模:{data.conflict_scale}") parts.append(f"角色数量:约{data.num_characters}个主要角色") parts.append(f"故事体量:约{data.num_chapters}章") if data.violence_level > 0: parts.append(f"暴力程度:{data.violence_level}/10") if data.eroticism_level > 0: parts.append(f"色情程度:{data.eroticism_level}/10") user_message = "\n".join(parts) + "\n\n请生成故事简介:" try: synopsis = await llm.chat(system_prompt, user_message) except Exception as e: logger.error(f"NSFW synopsis generation failed: {e}") raise HTTPException(status_code=500, detail=f"Grok generation failed: {str(e)}") return {"synopsis": synopsis} @router.post("/projects/generate-script-nsfw", response_model=AudiobookProjectResponse, status_code=status.HTTP_201_CREATED) async def create_nsfw_script_project( data: NsfwScriptGenerationRequest, current_user: User = Depends(require_nsfw), db: Session = Depends(get_db), ): from db.crud import get_system_setting if not get_system_setting(db, "grok_api_key") or not get_system_setting(db, "grok_base_url"): raise HTTPException(status_code=400, detail="Grok config not set. Please configure Grok API key first.") script_config = data.model_dump() script_config["nsfw_mode"] = True project = crud.create_audiobook_project( db=db, user_id=current_user.id, title=data.title, source_type="ai_generated", script_config=script_config, ) from core.audiobook_service import generate_ai_script_nsfw from core.database import SessionLocal project_id = project.id user_id = current_user.id async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, user_id) await generate_ai_script_nsfw(project_id, db_user, async_db) finally: async_db.close() asyncio.create_task(run()) return _project_to_response(project) @router.get("/projects/{project_id}", response_model=AudiobookProjectDetail) async def get_project( project_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") return _project_to_detail(project, db) @router.post("/projects/{project_id}/analyze") async def analyze_project( project_id: int, data: AudiobookAnalyzeRequest = AudiobookAnalyzeRequest(), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") if project.status in ("analyzing", "generating", "parsing"): raise HTTPException(status_code=400, detail=f"Project is currently {project.status}, please wait") from db.crud import get_system_setting if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.") from core.audiobook_service import analyze_project as _analyze from core.database import SessionLocal turbo = data.turbo async def run_analysis(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, current_user.id) await _analyze(project_id, db_user, async_db, turbo=turbo) finally: async_db.close() asyncio.create_task(run_analysis()) return {"message": "Analysis started", "project_id": project_id, "turbo": turbo} @router.post("/projects/{project_id}/confirm") async def confirm_characters( project_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") if project.status != "characters_ready": raise HTTPException(status_code=400, detail="Project must be in 'characters_ready' state to confirm characters") if project.source_type == "ai_generated": from core.audiobook_service import generate_ai_script_chapters from core.database import SessionLocal user_id = current_user.id async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, user_id) await generate_ai_script_chapters(project_id, db_user, async_db) finally: async_db.close() asyncio.create_task(run()) return {"message": "Script generation started", "project_id": project_id} from core.audiobook_service import identify_chapters try: identify_chapters(project_id, db, project) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) return {"message": "Chapters identified", "project_id": project_id} @router.get("/projects/{project_id}/characters/{char_id}/audio") async def get_character_audio( project_id: int, char_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") audio_path = Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) / "previews" / f"char_{char_id}.wav" if not audio_path.exists(): raise HTTPException(status_code=404, detail="Preview audio not generated yet") return FileResponse(audio_path, media_type="audio/wav") @router.post("/projects/{project_id}/characters/{char_id}/regenerate-preview") async def regenerate_character_preview_endpoint( project_id: int, char_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") from core.audiobook_service import generate_character_preview try: await generate_character_preview(project_id, char_id, current_user, db) return {"message": "Preview generated successfully"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to regenerate preview: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/projects/{project_id}/chapters", response_model=list[AudiobookChapterResponse]) async def list_chapters( project_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") chapters = crud.list_audiobook_chapters(db, project_id) return [ AudiobookChapterResponse( id=ch.id, project_id=ch.project_id, chapter_index=ch.chapter_index, title=ch.title, status=ch.status, error_message=ch.error_message, ) for ch in chapters ] @router.post("/projects/{project_id}/chapters/{chapter_id}/parse") async def parse_chapter( project_id: int, chapter_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") chapter = crud.get_audiobook_chapter(db, chapter_id) if not chapter or chapter.project_id != project_id: raise HTTPException(status_code=404, detail="Chapter not found") if chapter.status == "parsing": raise HTTPException(status_code=400, detail="Chapter is already being parsed") from db.crud import get_system_setting if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): raise HTTPException(status_code=400, detail="LLM config not set") from core.audiobook_service import parse_one_chapter from core.database import SessionLocal async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, current_user.id) await parse_one_chapter(project_id, chapter_id, db_user, async_db) finally: async_db.close() asyncio.create_task(run()) return {"message": "Parsing started", "chapter_id": chapter_id} @router.post("/projects/{project_id}/parse-all") async def parse_all_chapters_endpoint( project_id: int, only_errors: bool = False, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") if project.status not in ("ready", "generating", "done", "error"): raise HTTPException(status_code=400, detail=f"Project must be in 'ready' state, current: {project.status}") from db.crud import get_system_setting if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): raise HTTPException(status_code=400, detail="LLM config not set") from core.audiobook_service import parse_all_chapters from core.database import SessionLocal statuses = ("error",) if only_errors else ("pending", "error") async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, current_user.id) await parse_all_chapters(project_id, db_user, async_db, statuses=statuses) finally: async_db.close() asyncio.create_task(run()) return {"message": "Batch parsing started", "project_id": project_id, "only_errors": only_errors} @router.post("/projects/{project_id}/process-all") async def process_all_endpoint( project_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") if project.status not in ("ready", "generating", "done", "error"): raise HTTPException(status_code=400, detail=f"Project must be in 'ready' state, current: {project.status}") from db.crud import get_system_setting if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): raise HTTPException(status_code=400, detail="LLM config not set") from core.audiobook_service import process_all from core.database import SessionLocal async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, current_user.id) await process_all(project_id, db_user, async_db) finally: async_db.close() asyncio.create_task(run()) return {"message": "Full processing started", "project_id": project_id} @router.post("/projects/{project_id}/cancel-batch") async def cancel_batch_endpoint( project_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") from core.audiobook_service import cancel_batch cancel_batch(project_id) return {"message": "Cancellation signalled", "project_id": project_id} @router.put("/projects/{project_id}/characters/{char_id}", response_model=AudiobookCharacterResponse) async def update_character( project_id: int, char_id: int, data: AudiobookCharacterEdit, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") char = crud.get_audiobook_character(db, char_id) if not char or char.project_id != project_id: raise HTTPException(status_code=404, detail="Character not found") if data.voice_design_id is not None: voice_design = crud.get_voice_design(db, data.voice_design_id, current_user.id) if not voice_design: raise HTTPException(status_code=404, detail="Voice design not found") char = crud.update_audiobook_character( db, char_id, name=data.name, gender=data.gender, description=data.description, instruct=data.instruct, voice_design_id=data.voice_design_id, use_indextts2=data.use_indextts2, ) if data.instruct is not None and char.voice_design_id: voice_design = crud.get_voice_design(db, char.voice_design_id, current_user.id) if voice_design: voice_design.instruct = data.instruct db.commit() return _char_to_response(char, db) @router.post("/projects/{project_id}/generate") async def generate_project( project_id: int, data: AudiobookGenerateRequest = AudiobookGenerateRequest(), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") if project.status == "analyzing": raise HTTPException(status_code=400, detail="Project is currently analyzing, please wait") if project.status not in ("ready", "generating", "done", "error"): raise HTTPException(status_code=400, detail=f"Project must be in 'ready' state, current: {project.status}") from core.audiobook_service import generate_project as _generate from core.database import SessionLocal chapter_index = data.chapter_index force = data.force async def run_generation(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, current_user.id) await _generate(project_id, db_user, async_db, chapter_index=chapter_index, force=force) finally: async_db.close() asyncio.create_task(run_generation()) msg = f"Generation started for chapter {chapter_index}" if chapter_index is not None else "Generation started" return {"message": msg, "project_id": project_id, "chapter_index": chapter_index} @router.get("/projects/{project_id}/logs") async def stream_project_logs( project_id: int, chapter_id: Optional[int] = None, current_user: User = Depends(get_current_user), ): from core import progress_store as ps log_key = f"ch_{chapter_id}" if chapter_id is not None else str(project_id) async def generator(): sent_complete = -1 last_streaming = "" while True: state = ps.get_snapshot(log_key) lines = state["lines"] n = len(lines) for i in range(sent_complete + 1, max(0, n - 1)): yield f"data: {json.dumps({'index': i, 'line': lines[i]})}\n\n" sent_complete = i if n > 0: cur = lines[n - 1] if cur != last_streaming or (sent_complete < n - 1): yield f"data: {json.dumps({'index': n - 1, 'line': cur})}\n\n" last_streaming = cur sent_complete = max(sent_complete, n - 2) if state["done"]: yield f"data: {json.dumps({'done': True})}\n\n" break await asyncio.sleep(0.05) return StreamingResponse( generator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @router.get("/projects/{project_id}/segments", response_model=list[AudiobookSegmentResponse]) async def get_segments( project_id: int, chapter: Optional[int] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") segments = crud.list_audiobook_segments(db, project_id, chapter_index=chapter) result = [] for seg in segments: char_name = seg.character.name if seg.character else None result.append(AudiobookSegmentResponse( id=seg.id, project_id=seg.project_id, chapter_index=seg.chapter_index, segment_index=seg.segment_index, character_id=seg.character_id, character_name=char_name, text=seg.text, emo_text=seg.emo_text, emo_alpha=seg.emo_alpha, audio_path=seg.audio_path, status=seg.status, )) return result @router.put("/projects/{project_id}/segments/{segment_id}", response_model=AudiobookSegmentResponse) async def update_segment( project_id: int, segment_id: int, data: AudiobookSegmentUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") seg = db.query(AudiobookSegment).filter( AudiobookSegment.id == segment_id, AudiobookSegment.project_id == project_id, ).first() if not seg: raise HTTPException(status_code=404, detail="Segment not found") seg = crud.update_audiobook_segment(db, segment_id, data.text, data.emo_text, data.emo_alpha) char_name = seg.character.name if seg.character else None return AudiobookSegmentResponse( id=seg.id, project_id=seg.project_id, chapter_index=seg.chapter_index, segment_index=seg.segment_index, character_id=seg.character_id, character_name=char_name, text=seg.text, emo_text=seg.emo_text, emo_alpha=seg.emo_alpha, audio_path=seg.audio_path, status=seg.status, ) @router.post("/projects/{project_id}/segments/{segment_id}/regenerate", response_model=AudiobookSegmentResponse) async def regenerate_segment( project_id: int, segment_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") seg = db.query(AudiobookSegment).filter( AudiobookSegment.id == segment_id, AudiobookSegment.project_id == project_id, ).first() if not seg: raise HTTPException(status_code=404, detail="Segment not found") from core.audiobook_service import generate_single_segment from core.database import SessionLocal async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, current_user.id) await generate_single_segment(segment_id, db_user, async_db) finally: async_db.close() asyncio.create_task(run()) char_name = seg.character.name if seg.character else None return AudiobookSegmentResponse( id=seg.id, project_id=seg.project_id, chapter_index=seg.chapter_index, segment_index=seg.segment_index, character_id=seg.character_id, character_name=char_name, text=seg.text, emo_text=seg.emo_text, emo_alpha=seg.emo_alpha, audio_path=seg.audio_path, status="generating", ) @router.get("/projects/{project_id}/segments/{segment_id}/audio") async def get_segment_audio( project_id: int, segment_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") seg = db.query(AudiobookSegment).filter( AudiobookSegment.id == segment_id, AudiobookSegment.project_id == project_id, ).first() if not seg: raise HTTPException(status_code=404, detail="Segment not found") if not seg.audio_path or not Path(seg.audio_path).exists(): raise HTTPException(status_code=404, detail="Audio not available") return FileResponse(seg.audio_path, media_type="audio/wav") @router.get("/projects/{project_id}/download") async def download_project( project_id: int, chapter: Optional[int] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") segments = crud.list_audiobook_segments(db, project_id, chapter_index=chapter) done_segments = [s for s in segments if s.status == "done" and s.audio_path] if not done_segments: raise HTTPException(status_code=404, detail="No completed audio segments found") audio_paths = [s.audio_path for s in done_segments] if chapter is not None: output_path = str( Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) / "chapters" / f"chapter_{chapter}.wav" ) else: output_path = str( Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) / "full.wav" ) from core.audiobook_service import merge_audio_files merge_audio_files(audio_paths, output_path) filename = f"chapter_{chapter}.wav" if chapter is not None else f"{project.title}.wav" return FileResponse(output_path, media_type="audio/wav", filename=filename) @router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project( project_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): project = crud.get_audiobook_project(db, project_id, current_user.id) if not project: raise HTTPException(status_code=404, detail="Project not found") project_dir = Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) if project_dir.exists(): import shutil shutil.rmtree(project_dir, ignore_errors=True) if project.source_path: source_file = Path(project.source_path) if source_file.exists(): source_file.unlink(missing_ok=True) crud.delete_audiobook_project(db, project_id, current_user.id)