936 lines
34 KiB
Python
936 lines
34 KiB
Python
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.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) -> 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,
|
||
)
|
||
|
||
|
||
def _project_to_detail(project, db: Session) -> AudiobookProjectDetail:
|
||
characters = [
|
||
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,
|
||
use_indextts2=c.use_indextts2 or False,
|
||
)
|
||
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=current_user.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=current_user.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)
|
||
return [_project_to_response(p) 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.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}章")
|
||
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}/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
|
||
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.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}章")
|
||
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")
|
||
|
||
if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.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")
|
||
|
||
if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.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}")
|
||
|
||
if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.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}")
|
||
|
||
if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.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 AudiobookCharacterResponse(
|
||
id=char.id,
|
||
project_id=char.project_id,
|
||
name=char.name,
|
||
gender=char.gender,
|
||
description=char.description,
|
||
instruct=char.instruct,
|
||
voice_design_id=char.voice_design_id,
|
||
use_indextts2=char.use_indextts2 or False,
|
||
)
|
||
|
||
|
||
@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"
|
||
)
|
||
|
||
if not Path(output_path).exists():
|
||
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)
|