316 lines
11 KiB
Python
316 lines
11 KiB
Python
import logging
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form, status
|
|
from fastapi.responses import FileResponse
|
|
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
|
|
from schemas.audiobook import (
|
|
AudiobookProjectCreate,
|
|
AudiobookProjectResponse,
|
|
AudiobookProjectDetail,
|
|
AudiobookCharacterResponse,
|
|
AudiobookCharacterUpdate,
|
|
AudiobookSegmentResponse,
|
|
)
|
|
from core.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/audiobook", tags=["audiobook"])
|
|
|
|
|
|
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,
|
|
created_at=project.created_at,
|
|
updated_at=project.updated_at,
|
|
)
|
|
|
|
|
|
def _project_to_detail(project) -> AudiobookProjectDetail:
|
|
characters = [
|
|
AudiobookCharacterResponse(
|
|
id=c.id,
|
|
project_id=c.project_id,
|
|
name=c.name,
|
|
description=c.description,
|
|
instruct=c.instruct,
|
|
voice_design_id=c.voice_design_id,
|
|
)
|
|
for c in (project.characters 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,
|
|
)
|
|
|
|
|
|
@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.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)
|
|
|
|
|
|
@router.post("/projects/{project_id}/analyze")
|
|
async def analyze_project(
|
|
project_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
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"):
|
|
raise HTTPException(status_code=400, detail=f"Project is already {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. Please configure LLM API key first.")
|
|
|
|
from core.audiobook_service import analyze_project as _analyze
|
|
from core.database import SessionLocal
|
|
|
|
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)
|
|
finally:
|
|
async_db.close()
|
|
|
|
background_tasks.add_task(run_analysis)
|
|
return {"message": "Analysis started", "project_id": project_id}
|
|
|
|
|
|
@router.put("/projects/{project_id}/characters/{char_id}", response_model=AudiobookCharacterResponse)
|
|
async def update_character_voice(
|
|
project_id: int,
|
|
char_id: int,
|
|
data: AudiobookCharacterUpdate,
|
|
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")
|
|
|
|
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_voice(db, char_id, data.voice_design_id)
|
|
return AudiobookCharacterResponse(
|
|
id=char.id,
|
|
project_id=char.project_id,
|
|
name=char.name,
|
|
description=char.description,
|
|
instruct=char.instruct,
|
|
voice_design_id=char.voice_design_id,
|
|
)
|
|
|
|
|
|
@router.post("/projects/{project_id}/generate")
|
|
async def generate_project(
|
|
project_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
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", "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
|
|
|
|
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)
|
|
finally:
|
|
async_db.close()
|
|
|
|
background_tasks.add_task(run_generation)
|
|
return {"message": "Generation started", "project_id": project_id}
|
|
|
|
|
|
@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,
|
|
audio_path=seg.audio_path,
|
|
status=seg.status,
|
|
))
|
|
return result
|
|
|
|
|
|
@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}.mp3"
|
|
)
|
|
else:
|
|
output_path = str(
|
|
Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) / "full.mp3"
|
|
)
|
|
|
|
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}.mp3" if chapter is not None else f"{project.title}.mp3"
|
|
return FileResponse(output_path, media_type="audio/mpeg", 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)
|
|
|
|
crud.delete_audiobook_project(db, project_id, current_user.id)
|