feat(audiobook): implement audiobook project management features
This commit is contained in:
315
qwen3-tts-backend/api/audiobook.py
Normal file
315
qwen3-tts-backend/api/audiobook.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
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)
|
||||||
@@ -14,8 +14,9 @@ from core.security import (
|
|||||||
decode_access_token
|
decode_access_token
|
||||||
)
|
)
|
||||||
from db.database import get_db
|
from db.database import get_db
|
||||||
from db.crud import get_user_by_username, get_user_by_email, create_user, change_user_password, update_user_aliyun_key, get_user_preferences, update_user_preferences, can_user_use_local_model
|
from db.crud import get_user_by_username, get_user_by_email, create_user, change_user_password, update_user_aliyun_key, get_user_preferences, update_user_preferences, can_user_use_local_model, update_user_llm_config
|
||||||
from schemas.user import User, UserCreate, Token, PasswordChange, AliyunKeyUpdate, AliyunKeyVerifyResponse, UserPreferences, UserPreferencesResponse
|
from schemas.user import User, UserCreate, Token, PasswordChange, AliyunKeyUpdate, AliyunKeyVerifyResponse, UserPreferences, UserPreferencesResponse
|
||||||
|
from schemas.audiobook import LLMConfigUpdate, LLMConfigResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||||
|
|
||||||
@@ -285,3 +286,47 @@ async def update_preferences(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"message": "Preferences updated successfully"}
|
return {"message": "Preferences updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/llm-config")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def set_llm_config(
|
||||||
|
request: Request,
|
||||||
|
config: LLMConfigUpdate,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
from core.security import encrypt_api_key
|
||||||
|
encrypted_key = encrypt_api_key(config.api_key.strip())
|
||||||
|
update_user_llm_config(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
llm_api_key=encrypted_key,
|
||||||
|
llm_base_url=config.base_url.strip(),
|
||||||
|
llm_model=config.model.strip(),
|
||||||
|
)
|
||||||
|
return {"message": "LLM config updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llm-config", response_model=LLMConfigResponse)
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def get_llm_config(
|
||||||
|
request: Request,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
return LLMConfigResponse(
|
||||||
|
base_url=current_user.llm_base_url,
|
||||||
|
model=current_user.llm_model,
|
||||||
|
has_key=bool(current_user.llm_api_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/llm-config")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def delete_llm_config(
|
||||||
|
request: Request,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
update_user_llm_config(db, user_id=current_user.id, clear=True)
|
||||||
|
return {"message": "LLM config deleted"}
|
||||||
|
|||||||
299
qwen3-tts-backend/core/audiobook_service.py
Normal file
299
qwen3-tts-backend/core/audiobook_service.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from core.llm_service import LLMService
|
||||||
|
from db import crud
|
||||||
|
from db.models import AudiobookProject, AudiobookCharacter, User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_llm_service(user: User) -> LLMService:
|
||||||
|
from core.security import decrypt_api_key
|
||||||
|
if not user.llm_api_key or not user.llm_base_url or not user.llm_model:
|
||||||
|
raise ValueError("LLM config not set. Please configure LLM API key, base URL, and model.")
|
||||||
|
api_key = decrypt_api_key(user.llm_api_key)
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("Failed to decrypt LLM API key.")
|
||||||
|
return LLMService(base_url=user.llm_base_url, api_key=api_key, model=user.llm_model)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_epub_text(file_path: str) -> str:
|
||||||
|
try:
|
||||||
|
import ebooklib
|
||||||
|
from ebooklib import epub
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
class TextExtractor(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.parts = []
|
||||||
|
self._skip = False
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag in ("script", "style"):
|
||||||
|
self._skip = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag in ("script", "style"):
|
||||||
|
self._skip = False
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if not self._skip:
|
||||||
|
text = data.strip()
|
||||||
|
if text:
|
||||||
|
self.parts.append(text)
|
||||||
|
|
||||||
|
book = epub.read_epub(file_path)
|
||||||
|
chapters = []
|
||||||
|
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||||
|
extractor = TextExtractor()
|
||||||
|
extractor.feed(item.get_content().decode("utf-8", errors="ignore"))
|
||||||
|
chapter_text = "\n".join(extractor.parts)
|
||||||
|
if chapter_text.strip():
|
||||||
|
chapters.append(chapter_text)
|
||||||
|
return "\n\n".join(chapters)
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("ebooklib not installed. Run: pip install EbookLib")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_into_chapters(text: str) -> list[str]:
|
||||||
|
chapter_pattern = re.compile(r'(?:第[零一二三四五六七八九十百千\d]+[章节回]|Chapter\s+\d+)', re.IGNORECASE)
|
||||||
|
matches = list(chapter_pattern.finditer(text))
|
||||||
|
if not matches:
|
||||||
|
return [text]
|
||||||
|
chapters = []
|
||||||
|
for i, match in enumerate(matches):
|
||||||
|
start = match.start()
|
||||||
|
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||||
|
chapters.append(text[start:end])
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_project(project_id: int, user: User, db: Session) -> None:
|
||||||
|
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
crud.update_audiobook_project_status(db, project_id, "analyzing")
|
||||||
|
|
||||||
|
llm = _get_llm_service(user)
|
||||||
|
|
||||||
|
if project.source_type == "epub" and project.source_path:
|
||||||
|
text = _extract_epub_text(project.source_path)
|
||||||
|
project.source_text = text
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
text = project.source_text or ""
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
raise ValueError("No text content found in project.")
|
||||||
|
|
||||||
|
characters_data = await llm.extract_characters(text)
|
||||||
|
|
||||||
|
has_narrator = any(c.get("name") == "narrator" for c in characters_data)
|
||||||
|
if not has_narrator:
|
||||||
|
characters_data.insert(0, {
|
||||||
|
"name": "narrator",
|
||||||
|
"description": "旁白叙述者",
|
||||||
|
"instruct": "中性声音,语速平稳,叙述感强"
|
||||||
|
})
|
||||||
|
|
||||||
|
crud.delete_audiobook_segments(db, project_id)
|
||||||
|
crud.delete_audiobook_characters(db, project_id)
|
||||||
|
|
||||||
|
char_map: dict[str, AudiobookCharacter] = {}
|
||||||
|
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||||
|
|
||||||
|
for char_data in characters_data:
|
||||||
|
name = char_data.get("name", "narrator")
|
||||||
|
instruct = char_data.get("instruct", "")
|
||||||
|
description = char_data.get("description", "")
|
||||||
|
|
||||||
|
voice_design = crud.create_voice_design(
|
||||||
|
db=db,
|
||||||
|
user_id=user.id,
|
||||||
|
name=f"[有声书] {project.title} - {name}",
|
||||||
|
instruct=instruct,
|
||||||
|
backend_type=backend_type,
|
||||||
|
preview_text=description[:100] if description else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
char = crud.create_audiobook_character(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
instruct=instruct,
|
||||||
|
voice_design_id=voice_design.id,
|
||||||
|
)
|
||||||
|
char_map[name] = char
|
||||||
|
|
||||||
|
chapters = _split_into_chapters(text)
|
||||||
|
character_names = [c.get("name") for c in characters_data]
|
||||||
|
|
||||||
|
for chapter_idx, chapter_text in enumerate(chapters):
|
||||||
|
if not chapter_text.strip():
|
||||||
|
continue
|
||||||
|
segments_data = await llm.parse_chapter_segments(chapter_text, character_names)
|
||||||
|
for seg_idx, seg in enumerate(segments_data):
|
||||||
|
char_name = seg.get("character", "narrator")
|
||||||
|
seg_text = seg.get("text", "").strip()
|
||||||
|
if not seg_text:
|
||||||
|
continue
|
||||||
|
char = char_map.get(char_name) or char_map.get("narrator")
|
||||||
|
if char is None:
|
||||||
|
continue
|
||||||
|
crud.create_audiobook_segment(
|
||||||
|
db=db,
|
||||||
|
project_id=project_id,
|
||||||
|
character_id=char.id,
|
||||||
|
text=seg_text,
|
||||||
|
chapter_index=chapter_idx,
|
||||||
|
segment_index=seg_idx,
|
||||||
|
)
|
||||||
|
|
||||||
|
crud.update_audiobook_project_status(db, project_id, "ready")
|
||||||
|
logger.info(f"Project {project_id} analysis complete: {len(char_map)} characters, {len(chapters)} chapters")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Analysis failed for project {project_id}: {e}", exc_info=True)
|
||||||
|
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_project(project_id: int, user: User, db: Session) -> None:
|
||||||
|
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
crud.update_audiobook_project_status(db, project_id, "generating")
|
||||||
|
|
||||||
|
segments = crud.list_audiobook_segments(db, project_id)
|
||||||
|
if not segments:
|
||||||
|
crud.update_audiobook_project_status(db, project_id, "done")
|
||||||
|
return
|
||||||
|
|
||||||
|
output_base = Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) / "segments"
|
||||||
|
output_base.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
from core.tts_service import TTSServiceFactory
|
||||||
|
from core.security import decrypt_api_key
|
||||||
|
|
||||||
|
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||||
|
|
||||||
|
user_api_key = None
|
||||||
|
if backend_type == "aliyun" and user.aliyun_api_key:
|
||||||
|
user_api_key = decrypt_api_key(user.aliyun_api_key)
|
||||||
|
|
||||||
|
backend = await TTSServiceFactory.get_backend(backend_type, user_api_key)
|
||||||
|
|
||||||
|
for seg in segments:
|
||||||
|
try:
|
||||||
|
crud.update_audiobook_segment_status(db, seg.id, "generating")
|
||||||
|
|
||||||
|
char = crud.get_audiobook_character(db, seg.character_id)
|
||||||
|
if not char or not char.voice_design_id:
|
||||||
|
crud.update_audiobook_segment_status(db, seg.id, "error")
|
||||||
|
continue
|
||||||
|
|
||||||
|
design = crud.get_voice_design(db, char.voice_design_id, user.id)
|
||||||
|
if not design:
|
||||||
|
crud.update_audiobook_segment_status(db, seg.id, "error")
|
||||||
|
continue
|
||||||
|
|
||||||
|
audio_filename = f"ch{seg.chapter_index:03d}_seg{seg.segment_index:04d}.wav"
|
||||||
|
audio_path = output_base / audio_filename
|
||||||
|
|
||||||
|
if backend_type == "aliyun":
|
||||||
|
if design.aliyun_voice_id:
|
||||||
|
audio_bytes, _ = await backend.generate_voice_design(
|
||||||
|
{"text": seg.text, "language": "zh"},
|
||||||
|
saved_voice_id=design.aliyun_voice_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
audio_bytes, _ = await backend.generate_voice_design({
|
||||||
|
"text": seg.text,
|
||||||
|
"language": "zh",
|
||||||
|
"instruct": design.instruct,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
if design.voice_cache_id:
|
||||||
|
from core.cache_manager import VoiceCacheManager
|
||||||
|
cache_manager = await VoiceCacheManager.get_instance()
|
||||||
|
cache_result = await cache_manager.get_cache_by_id(design.voice_cache_id, db)
|
||||||
|
x_vector = cache_result['data'] if cache_result else None
|
||||||
|
if x_vector:
|
||||||
|
audio_bytes, _ = await backend.generate_voice_clone(
|
||||||
|
{
|
||||||
|
"text": seg.text,
|
||||||
|
"language": "Auto",
|
||||||
|
"max_new_tokens": 2048,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"top_k": 10,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"repetition_penalty": 1.05,
|
||||||
|
},
|
||||||
|
x_vector=x_vector
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
audio_bytes, _ = await backend.generate_voice_design({
|
||||||
|
"text": seg.text,
|
||||||
|
"language": "Auto",
|
||||||
|
"instruct": design.instruct,
|
||||||
|
"max_new_tokens": 2048,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"top_k": 10,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"repetition_penalty": 1.05,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
audio_bytes, _ = await backend.generate_voice_design({
|
||||||
|
"text": seg.text,
|
||||||
|
"language": "Auto",
|
||||||
|
"instruct": design.instruct,
|
||||||
|
"max_new_tokens": 2048,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"top_k": 10,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"repetition_penalty": 1.05,
|
||||||
|
})
|
||||||
|
|
||||||
|
with open(audio_path, "wb") as f:
|
||||||
|
f.write(audio_bytes)
|
||||||
|
|
||||||
|
crud.update_audiobook_segment_status(db, seg.id, "done", audio_path=str(audio_path))
|
||||||
|
logger.info(f"Segment {seg.id} generated: {audio_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Segment {seg.id} generation failed: {e}", exc_info=True)
|
||||||
|
crud.update_audiobook_segment_status(db, seg.id, "error")
|
||||||
|
|
||||||
|
crud.update_audiobook_project_status(db, project_id, "done")
|
||||||
|
logger.info(f"Project {project_id} generation complete")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Generation failed for project {project_id}: {e}", exc_info=True)
|
||||||
|
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def merge_audio_files(audio_paths: list[str], output_path: str) -> None:
|
||||||
|
from pydub import AudioSegment
|
||||||
|
|
||||||
|
combined = None
|
||||||
|
silence = AudioSegment.silent(duration=300)
|
||||||
|
for path in audio_paths:
|
||||||
|
if not Path(path).exists():
|
||||||
|
continue
|
||||||
|
seg = AudioSegment.from_file(path)
|
||||||
|
combined = combined + silence + seg if combined else seg
|
||||||
|
|
||||||
|
if combined:
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
combined.export(output_path, format="mp3")
|
||||||
70
qwen3-tts-backend/core/llm_service.py
Normal file
70
qwen3-tts-backend/core/llm_service.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMService:
|
||||||
|
def __init__(self, base_url: str, api_key: str, model: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
async def chat(self, system_prompt: str, user_message: str) -> str:
|
||||||
|
url = f"{self.base_url}/chat/completions"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_message},
|
||||||
|
],
|
||||||
|
"temperature": 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"LLM API error {resp.status_code}: {resp.text}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
async def chat_json(self, system_prompt: str, user_message: str) -> Any:
|
||||||
|
raw = await self.chat(system_prompt, user_message)
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
lines = raw.split("\n")
|
||||||
|
raw = "\n".join(lines[1:-1]) if len(lines) > 2 else raw
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
async def extract_characters(self, text: str) -> list[Dict]:
|
||||||
|
system_prompt = (
|
||||||
|
"你是一个专业的小说分析助手。请分析给定的小说文本,提取所有出现的角色(包括旁白narrator)。"
|
||||||
|
"只输出JSON,格式如下,不要有其他文字:\n"
|
||||||
|
'{"characters": [{"name": "narrator", "description": "第三人称叙述者", "instruct": "中年男声,语速平稳"}, ...]}'
|
||||||
|
)
|
||||||
|
user_message = f"请分析以下小说文本并提取角色:\n\n{text[:30000]}"
|
||||||
|
result = await self.chat_json(system_prompt, user_message)
|
||||||
|
return result.get("characters", [])
|
||||||
|
|
||||||
|
async def parse_chapter_segments(self, chapter_text: str, character_names: list[str]) -> list[Dict]:
|
||||||
|
names_str = "、".join(character_names)
|
||||||
|
system_prompt = (
|
||||||
|
"你是一个专业的有声书制作助手。请将给定的章节文本解析为对话片段列表。"
|
||||||
|
f"已知角色列表(必须从中选择):{names_str}。"
|
||||||
|
"所有非对话的叙述文字归属于narrator角色。"
|
||||||
|
"只输出JSON数组,不要有其他文字,格式如下:\n"
|
||||||
|
'[{"character": "narrator", "text": "叙述文字"}, {"character": "角色名", "text": "对话内容"}, ...]'
|
||||||
|
)
|
||||||
|
user_message = f"请解析以下章节文本:\n\n{chapter_text}"
|
||||||
|
result = await self.chat_json(system_prompt, user_message)
|
||||||
|
if isinstance(result, list):
|
||||||
|
return result
|
||||||
|
return []
|
||||||
@@ -3,7 +3,7 @@ from typing import Optional, List, Dict, Any
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign
|
from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookCharacter, AudiobookSegment
|
||||||
|
|
||||||
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
||||||
return db.query(User).filter(User.username == username).first()
|
return db.query(User).filter(User.username == username).first()
|
||||||
@@ -355,3 +355,200 @@ def update_voice_design_usage(db: Session, design_id: int, user_id: int) -> Opti
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(design)
|
db.refresh(design)
|
||||||
return design
|
return design
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_llm_config(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
llm_api_key: Optional[str] = None,
|
||||||
|
llm_base_url: Optional[str] = None,
|
||||||
|
llm_model: Optional[str] = None,
|
||||||
|
clear: bool = False
|
||||||
|
) -> Optional[User]:
|
||||||
|
user = get_user_by_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if clear:
|
||||||
|
user.llm_api_key = None
|
||||||
|
user.llm_base_url = None
|
||||||
|
user.llm_model = None
|
||||||
|
else:
|
||||||
|
if llm_api_key is not None:
|
||||||
|
user.llm_api_key = llm_api_key
|
||||||
|
if llm_base_url is not None:
|
||||||
|
user.llm_base_url = llm_base_url
|
||||||
|
if llm_model is not None:
|
||||||
|
user.llm_model = llm_model
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_audiobook_project(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
title: str,
|
||||||
|
source_type: str,
|
||||||
|
source_text: Optional[str] = None,
|
||||||
|
source_path: Optional[str] = None,
|
||||||
|
llm_model: Optional[str] = None,
|
||||||
|
) -> AudiobookProject:
|
||||||
|
project = AudiobookProject(
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
source_type=source_type,
|
||||||
|
source_text=source_text,
|
||||||
|
source_path=source_path,
|
||||||
|
llm_model=llm_model,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def get_audiobook_project(db: Session, project_id: int, user_id: int) -> Optional[AudiobookProject]:
|
||||||
|
return db.query(AudiobookProject).filter(
|
||||||
|
AudiobookProject.id == project_id,
|
||||||
|
AudiobookProject.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def list_audiobook_projects(db: Session, user_id: int, skip: int = 0, limit: int = 50) -> List[AudiobookProject]:
|
||||||
|
return db.query(AudiobookProject).filter(
|
||||||
|
AudiobookProject.user_id == user_id
|
||||||
|
).order_by(AudiobookProject.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def update_audiobook_project_status(
|
||||||
|
db: Session,
|
||||||
|
project_id: int,
|
||||||
|
status: str,
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
) -> Optional[AudiobookProject]:
|
||||||
|
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
project.status = status
|
||||||
|
if error_message is not None:
|
||||||
|
project.error_message = error_message
|
||||||
|
project.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def delete_audiobook_project(db: Session, project_id: int, user_id: int) -> bool:
|
||||||
|
project = get_audiobook_project(db, project_id, user_id)
|
||||||
|
if not project:
|
||||||
|
return False
|
||||||
|
db.delete(project)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def create_audiobook_character(
|
||||||
|
db: Session,
|
||||||
|
project_id: int,
|
||||||
|
name: str,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
instruct: Optional[str] = None,
|
||||||
|
voice_design_id: Optional[int] = None,
|
||||||
|
) -> AudiobookCharacter:
|
||||||
|
char = AudiobookCharacter(
|
||||||
|
project_id=project_id,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
instruct=instruct,
|
||||||
|
voice_design_id=voice_design_id,
|
||||||
|
)
|
||||||
|
db.add(char)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(char)
|
||||||
|
return char
|
||||||
|
|
||||||
|
|
||||||
|
def get_audiobook_character(db: Session, char_id: int) -> Optional[AudiobookCharacter]:
|
||||||
|
return db.query(AudiobookCharacter).filter(AudiobookCharacter.id == char_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def list_audiobook_characters(db: Session, project_id: int) -> List[AudiobookCharacter]:
|
||||||
|
return db.query(AudiobookCharacter).filter(
|
||||||
|
AudiobookCharacter.project_id == project_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
def update_audiobook_character_voice(
|
||||||
|
db: Session,
|
||||||
|
char_id: int,
|
||||||
|
voice_design_id: int
|
||||||
|
) -> Optional[AudiobookCharacter]:
|
||||||
|
char = db.query(AudiobookCharacter).filter(AudiobookCharacter.id == char_id).first()
|
||||||
|
if not char:
|
||||||
|
return None
|
||||||
|
char.voice_design_id = voice_design_id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(char)
|
||||||
|
return char
|
||||||
|
|
||||||
|
|
||||||
|
def create_audiobook_segment(
|
||||||
|
db: Session,
|
||||||
|
project_id: int,
|
||||||
|
character_id: int,
|
||||||
|
text: str,
|
||||||
|
chapter_index: int = 0,
|
||||||
|
segment_index: int = 0,
|
||||||
|
) -> AudiobookSegment:
|
||||||
|
seg = AudiobookSegment(
|
||||||
|
project_id=project_id,
|
||||||
|
character_id=character_id,
|
||||||
|
text=text,
|
||||||
|
chapter_index=chapter_index,
|
||||||
|
segment_index=segment_index,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(seg)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(seg)
|
||||||
|
return seg
|
||||||
|
|
||||||
|
|
||||||
|
def list_audiobook_segments(
|
||||||
|
db: Session,
|
||||||
|
project_id: int,
|
||||||
|
chapter_index: Optional[int] = None
|
||||||
|
) -> List[AudiobookSegment]:
|
||||||
|
query = db.query(AudiobookSegment).filter(AudiobookSegment.project_id == project_id)
|
||||||
|
if chapter_index is not None:
|
||||||
|
query = query.filter(AudiobookSegment.chapter_index == chapter_index)
|
||||||
|
return query.order_by(AudiobookSegment.chapter_index, AudiobookSegment.segment_index).all()
|
||||||
|
|
||||||
|
|
||||||
|
def update_audiobook_segment_status(
|
||||||
|
db: Session,
|
||||||
|
segment_id: int,
|
||||||
|
status: str,
|
||||||
|
audio_path: Optional[str] = None
|
||||||
|
) -> Optional[AudiobookSegment]:
|
||||||
|
seg = db.query(AudiobookSegment).filter(AudiobookSegment.id == segment_id).first()
|
||||||
|
if not seg:
|
||||||
|
return None
|
||||||
|
seg.status = status
|
||||||
|
if audio_path is not None:
|
||||||
|
seg.audio_path = audio_path
|
||||||
|
db.commit()
|
||||||
|
db.refresh(seg)
|
||||||
|
return seg
|
||||||
|
|
||||||
|
|
||||||
|
def delete_audiobook_segments(db: Session, project_id: int) -> None:
|
||||||
|
db.query(AudiobookSegment).filter(AudiobookSegment.project_id == project_id).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_audiobook_characters(db: Session, project_id: int) -> None:
|
||||||
|
db.query(AudiobookCharacter).filter(AudiobookCharacter.project_id == project_id).delete()
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ class JobStatus(str, Enum):
|
|||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
class AudiobookStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
ANALYZING = "analyzing"
|
||||||
|
READY = "ready"
|
||||||
|
GENERATING = "generating"
|
||||||
|
DONE = "done"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
class SegmentStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
GENERATING = "generating"
|
||||||
|
DONE = "done"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -21,6 +35,9 @@ class User(Base):
|
|||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
is_superuser = Column(Boolean, default=False, nullable=False)
|
is_superuser = Column(Boolean, default=False, nullable=False)
|
||||||
aliyun_api_key = Column(Text, nullable=True)
|
aliyun_api_key = Column(Text, nullable=True)
|
||||||
|
llm_api_key = Column(Text, nullable=True)
|
||||||
|
llm_base_url = Column(String(500), nullable=True)
|
||||||
|
llm_model = Column(String(200), nullable=True)
|
||||||
can_use_local_model = Column(Boolean, default=False, nullable=False)
|
can_use_local_model = Column(Boolean, default=False, nullable=False)
|
||||||
user_preferences = Column(JSON, nullable=True, default=lambda: {"default_backend": "aliyun", "onboarding_completed": False})
|
user_preferences = Column(JSON, nullable=True, default=lambda: {"default_backend": "aliyun", "onboarding_completed": False})
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
@@ -29,6 +46,7 @@ class User(Base):
|
|||||||
jobs = relationship("Job", back_populates="user", cascade="all, delete-orphan")
|
jobs = relationship("Job", back_populates="user", cascade="all, delete-orphan")
|
||||||
voice_caches = relationship("VoiceCache", back_populates="user", cascade="all, delete-orphan")
|
voice_caches = relationship("VoiceCache", back_populates="user", cascade="all, delete-orphan")
|
||||||
voice_designs = relationship("VoiceDesign", back_populates="user", cascade="all, delete-orphan")
|
voice_designs = relationship("VoiceDesign", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
audiobook_projects = relationship("AudiobookProject", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
class Job(Base):
|
class Job(Base):
|
||||||
__tablename__ = "jobs"
|
__tablename__ = "jobs"
|
||||||
@@ -104,3 +122,58 @@ class VoiceDesign(Base):
|
|||||||
Index('idx_user_backend', 'user_id', 'backend_type'),
|
Index('idx_user_backend', 'user_id', 'backend_type'),
|
||||||
Index('idx_user_active', 'user_id', 'is_active'),
|
Index('idx_user_active', 'user_id', 'is_active'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookProject(Base):
|
||||||
|
__tablename__ = "audiobook_projects"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
source_type = Column(String(10), nullable=False)
|
||||||
|
source_path = Column(String(500), nullable=True)
|
||||||
|
source_text = Column(Text, nullable=True)
|
||||||
|
status = Column(String(20), default="pending", nullable=False, index=True)
|
||||||
|
llm_model = Column(String(200), nullable=True)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="audiobook_projects")
|
||||||
|
characters = relationship("AudiobookCharacter", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
segments = relationship("AudiobookSegment", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookCharacter(Base):
|
||||||
|
__tablename__ = "audiobook_characters"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("audiobook_projects.id"), nullable=False, index=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
instruct = Column(Text, nullable=True)
|
||||||
|
voice_design_id = Column(Integer, ForeignKey("voice_designs.id"), nullable=True)
|
||||||
|
|
||||||
|
project = relationship("AudiobookProject", back_populates="characters")
|
||||||
|
voice_design = relationship("VoiceDesign")
|
||||||
|
segments = relationship("AudiobookSegment", back_populates="character")
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookSegment(Base):
|
||||||
|
__tablename__ = "audiobook_segments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("audiobook_projects.id"), nullable=False, index=True)
|
||||||
|
chapter_index = Column(Integer, nullable=False, default=0)
|
||||||
|
segment_index = Column(Integer, nullable=False)
|
||||||
|
character_id = Column(Integer, ForeignKey("audiobook_characters.id"), nullable=False)
|
||||||
|
text = Column(Text, nullable=False)
|
||||||
|
audio_path = Column(String(500), nullable=True)
|
||||||
|
status = Column(String(20), default="pending", nullable=False)
|
||||||
|
|
||||||
|
project = relationship("AudiobookProject", back_populates="segments")
|
||||||
|
character = relationship("AudiobookCharacter", back_populates="segments")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_project_chapter', 'project_id', 'chapter_index', 'segment_index'),
|
||||||
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from core.config import settings
|
|||||||
from core.database import init_db
|
from core.database import init_db
|
||||||
from core.model_manager import ModelManager
|
from core.model_manager import ModelManager
|
||||||
from core.cleanup import run_scheduled_cleanup
|
from core.cleanup import run_scheduled_cleanup
|
||||||
from api import auth, jobs, tts, users, voice_designs
|
from api import auth, jobs, tts, users, voice_designs, audiobook
|
||||||
from api.auth import get_current_user
|
from api.auth import get_current_user
|
||||||
from schemas.user import User
|
from schemas.user import User
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
@@ -133,6 +133,7 @@ app.include_router(jobs.router)
|
|||||||
app.include_router(tts.router)
|
app.include_router(tts.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(voice_designs.router)
|
app.include_router(voice_designs.router)
|
||||||
|
app.include_router(audiobook.router)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ pytest-cov==4.1.0
|
|||||||
pytest-asyncio==0.23.0
|
pytest-asyncio==0.23.0
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
|
EbookLib>=0.18
|
||||||
|
|||||||
68
qwen3-tts-backend/schemas/audiobook.py
Normal file
68
qwen3-tts-backend/schemas/audiobook.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookProjectCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
source_type: str
|
||||||
|
source_text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookProjectResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
title: str
|
||||||
|
source_type: str
|
||||||
|
status: str
|
||||||
|
llm_model: Optional[str] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookCharacterResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
project_id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
instruct: Optional[str] = None
|
||||||
|
voice_design_id: Optional[int] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookProjectDetail(AudiobookProjectResponse):
|
||||||
|
characters: List[AudiobookCharacterResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookCharacterUpdate(BaseModel):
|
||||||
|
voice_design_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class AudiobookSegmentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
project_id: int
|
||||||
|
chapter_index: int
|
||||||
|
segment_index: int
|
||||||
|
character_id: int
|
||||||
|
character_name: Optional[str] = None
|
||||||
|
text: str
|
||||||
|
audio_path: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConfigUpdate(BaseModel):
|
||||||
|
base_url: str
|
||||||
|
api_key: str
|
||||||
|
model: str
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConfigResponse(BaseModel):
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
has_key: bool
|
||||||
@@ -16,6 +16,7 @@ const Home = lazy(() => import('@/pages/Home'))
|
|||||||
const Settings = lazy(() => import('@/pages/Settings'))
|
const Settings = lazy(() => import('@/pages/Settings'))
|
||||||
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
||||||
const VoiceManagement = lazy(() => import('@/pages/VoiceManagement'))
|
const VoiceManagement = lazy(() => import('@/pages/VoiceManagement'))
|
||||||
|
const Audiobook = lazy(() => import('@/pages/Audiobook'))
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
@@ -109,6 +110,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/audiobook"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Audiobook />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</UserPreferencesProvider>
|
</UserPreferencesProvider>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Menu, LogOut, Users, Settings, Globe, Home, Mic } from 'lucide-react'
|
import { Menu, LogOut, Users, Settings, Globe, Home, Mic, BookOpen } from 'lucide-react'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -49,6 +49,12 @@ export function Navbar({ onToggleSidebar }: NavbarProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link to="/audiobook">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{user?.is_superuser && (
|
{user?.is_superuser && (
|
||||||
<Link to="/users">
|
<Link to="/users">
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|||||||
125
qwen3-tts-frontend/src/lib/api/audiobook.ts
Normal file
125
qwen3-tts-frontend/src/lib/api/audiobook.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import apiClient from '@/lib/api'
|
||||||
|
|
||||||
|
export interface AudiobookProject {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
title: string
|
||||||
|
source_type: string
|
||||||
|
status: string
|
||||||
|
llm_model?: string
|
||||||
|
error_message?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudiobookCharacter {
|
||||||
|
id: number
|
||||||
|
project_id: number
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
instruct?: string
|
||||||
|
voice_design_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudiobookProjectDetail extends AudiobookProject {
|
||||||
|
characters: AudiobookCharacter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudiobookSegment {
|
||||||
|
id: number
|
||||||
|
project_id: number
|
||||||
|
chapter_index: number
|
||||||
|
segment_index: number
|
||||||
|
character_id: number
|
||||||
|
character_name?: string
|
||||||
|
text: string
|
||||||
|
audio_path?: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMConfig {
|
||||||
|
base_url?: string
|
||||||
|
model?: string
|
||||||
|
has_key: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const audiobookApi = {
|
||||||
|
createProject: async (data: {
|
||||||
|
title: string
|
||||||
|
source_type: string
|
||||||
|
source_text?: string
|
||||||
|
}): Promise<AudiobookProject> => {
|
||||||
|
const response = await apiClient.post<AudiobookProject>('/audiobook/projects', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadEpub: async (title: string, file: File): Promise<AudiobookProject> => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('title', title)
|
||||||
|
formData.append('file', file)
|
||||||
|
const response = await apiClient.post<AudiobookProject>(
|
||||||
|
'/audiobook/projects/upload',
|
||||||
|
formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
listProjects: async (): Promise<AudiobookProject[]> => {
|
||||||
|
const response = await apiClient.get<AudiobookProject[]>('/audiobook/projects')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getProject: async (id: number): Promise<AudiobookProjectDetail> => {
|
||||||
|
const response = await apiClient.get<AudiobookProjectDetail>(`/audiobook/projects/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
analyze: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.post(`/audiobook/projects/${id}/analyze`)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCharacterVoice: async (projectId: number, charId: number, voiceDesignId: number): Promise<AudiobookCharacter> => {
|
||||||
|
const response = await apiClient.put<AudiobookCharacter>(
|
||||||
|
`/audiobook/projects/${projectId}/characters/${charId}`,
|
||||||
|
{ voice_design_id: voiceDesignId }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
generate: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.post(`/audiobook/projects/${id}/generate`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getSegments: async (id: number, chapter?: number): Promise<AudiobookSegment[]> => {
|
||||||
|
const params = chapter !== undefined ? { chapter } : {}
|
||||||
|
const response = await apiClient.get<AudiobookSegment[]>(
|
||||||
|
`/audiobook/projects/${id}/segments`,
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getDownloadUrl: (id: number, chapter?: number): string => {
|
||||||
|
const base = import.meta.env.VITE_API_URL || ''
|
||||||
|
const chapterParam = chapter !== undefined ? `?chapter=${chapter}` : ''
|
||||||
|
return `${base}/audiobook/projects/${id}/download${chapterParam}`
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProject: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/audiobook/projects/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getLLMConfig: async (): Promise<LLMConfig> => {
|
||||||
|
const response = await apiClient.get<LLMConfig>('/auth/llm-config')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
setLLMConfig: async (config: { base_url: string; api_key: string; model: string }): Promise<void> => {
|
||||||
|
await apiClient.put('/auth/llm-config', config)
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteLLMConfig: async (): Promise<void> => {
|
||||||
|
await apiClient.delete('/auth/llm-config')
|
||||||
|
},
|
||||||
|
}
|
||||||
352
qwen3-tts-frontend/src/pages/Audiobook.tsx
Normal file
352
qwen3-tts-frontend/src/pages/Audiobook.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Navbar } from '@/components/Navbar'
|
||||||
|
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookSegment } from '@/lib/api/audiobook'
|
||||||
|
import { formatApiError } from '@/lib/api'
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: '待分析',
|
||||||
|
analyzing: '分析中',
|
||||||
|
ready: '待生成',
|
||||||
|
generating: '生成中',
|
||||||
|
done: '已完成',
|
||||||
|
error: '出错',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
pending: 'secondary',
|
||||||
|
analyzing: 'default',
|
||||||
|
ready: 'default',
|
||||||
|
generating: 'default',
|
||||||
|
done: 'default',
|
||||||
|
error: 'destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
||||||
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [model, setModel] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [existing, setExisting] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
audiobookApi.getLLMConfig().then(setExisting).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!baseUrl || !apiKey || !model) {
|
||||||
|
toast.error('请填写完整的 LLM 配置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await audiobookApi.setLLMConfig({ base_url: baseUrl, api_key: apiKey, model })
|
||||||
|
toast.success('LLM 配置已保存')
|
||||||
|
setApiKey('')
|
||||||
|
const updated = await audiobookApi.getLLMConfig()
|
||||||
|
setExisting(updated)
|
||||||
|
onSaved?.()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(formatApiError(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">LLM 配置</div>
|
||||||
|
{existing && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
当前: {existing.base_url || '未设置'} / {existing.model || '未设置'} / {existing.has_key ? '已配置密钥' : '未配置密钥'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input placeholder="Base URL (e.g. https://api.openai.com/v1)" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
|
||||||
|
<Input placeholder="API Key" type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
||||||
|
<Input placeholder="Model (e.g. gpt-4o)" value={model} onChange={e => setModel(e.target.value)} />
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={loading}>{loading ? '保存中...' : '保存配置'}</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [sourceType, setSourceType] = useState<'text' | 'epub'>('text')
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [epubFile, setEpubFile] = useState<File | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!title) { toast.error('请输入书名'); return }
|
||||||
|
if (sourceType === 'text' && !text) { toast.error('请输入文本内容'); return }
|
||||||
|
if (sourceType === 'epub' && !epubFile) { toast.error('请选择 epub 文件'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (sourceType === 'text') {
|
||||||
|
await audiobookApi.createProject({ title, source_type: 'text', source_text: text })
|
||||||
|
} else {
|
||||||
|
await audiobookApi.uploadEpub(title, epubFile!)
|
||||||
|
}
|
||||||
|
toast.success('项目已创建')
|
||||||
|
setTitle(''); setText(''); setEpubFile(null)
|
||||||
|
onCreated()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(formatApiError(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">新建有声书项目</div>
|
||||||
|
<Input placeholder="书名" value={title} onChange={e => setTitle(e.target.value)} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant={sourceType === 'text' ? 'default' : 'outline'} onClick={() => setSourceType('text')}>粘贴文本</Button>
|
||||||
|
<Button size="sm" variant={sourceType === 'epub' ? 'default' : 'outline'} onClick={() => setSourceType('epub')}>上传 epub</Button>
|
||||||
|
</div>
|
||||||
|
{sourceType === 'text' && (
|
||||||
|
<Textarea placeholder="粘贴小说文本..." rows={6} value={text} onChange={e => setText(e.target.value)} />
|
||||||
|
)}
|
||||||
|
{sourceType === 'epub' && (
|
||||||
|
<Input type="file" accept=".epub" onChange={e => setEpubFile(e.target.files?.[0] || null)} />
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={handleCreate} disabled={loading}>{loading ? '创建中...' : '创建项目'}</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefresh: () => void }) {
|
||||||
|
const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null)
|
||||||
|
const [segments, setSegments] = useState<AudiobookSegment[]>([])
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [loadingAction, setLoadingAction] = useState(false)
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const d = await audiobookApi.getProject(project.id)
|
||||||
|
setDetail(d)
|
||||||
|
} catch {}
|
||||||
|
}, [project.id])
|
||||||
|
|
||||||
|
const fetchSegments = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await audiobookApi.getSegments(project.id)
|
||||||
|
setSegments(s)
|
||||||
|
} catch {}
|
||||||
|
}, [project.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) {
|
||||||
|
fetchDetail()
|
||||||
|
fetchSegments()
|
||||||
|
}
|
||||||
|
}, [expanded, fetchDetail, fetchSegments])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (['analyzing', 'generating'].includes(project.status)) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
onRefresh()
|
||||||
|
if (expanded) { fetchDetail(); fetchSegments() }
|
||||||
|
}, 3000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [project.status, expanded, onRefresh, fetchDetail, fetchSegments])
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
setLoadingAction(true)
|
||||||
|
try {
|
||||||
|
await audiobookApi.analyze(project.id)
|
||||||
|
toast.success('分析已开始')
|
||||||
|
onRefresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(formatApiError(e))
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setLoadingAction(true)
|
||||||
|
try {
|
||||||
|
await audiobookApi.generate(project.id)
|
||||||
|
toast.success('生成已开始')
|
||||||
|
onRefresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(formatApiError(e))
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm(`确认删除项目「${project.title}」及所有音频?`)) return
|
||||||
|
try {
|
||||||
|
await audiobookApi.deleteProject(project.id)
|
||||||
|
toast.success('项目已删除')
|
||||||
|
onRefresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(formatApiError(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doneCount = segments.filter(s => s.status === 'done').length
|
||||||
|
const totalCount = segments.length
|
||||||
|
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Book className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="font-medium truncate">{project.title}</span>
|
||||||
|
<Badge variant={(STATUS_COLORS[project.status] || 'secondary') as any} className="shrink-0">
|
||||||
|
{STATUS_LABELS[project.status] || project.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{project.status === 'pending' && (
|
||||||
|
<Button size="sm" variant="outline" onClick={handleAnalyze} disabled={loadingAction}>分析</Button>
|
||||||
|
)}
|
||||||
|
{project.status === 'ready' && (
|
||||||
|
<Button size="sm" onClick={handleGenerate} disabled={loadingAction}>生成音频</Button>
|
||||||
|
)}
|
||||||
|
{project.status === 'done' && (
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<a href={audiobookApi.getDownloadUrl(project.id)} download>
|
||||||
|
<Download className="h-3 w-3 mr-1" />下载
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => { setExpanded(!expanded) }}>
|
||||||
|
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" onClick={handleDelete}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.error_message && (
|
||||||
|
<div className="text-xs text-destructive bg-destructive/10 rounded p-2">{project.error_message}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{['generating', 'done'].includes(project.status) && totalCount > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">{doneCount}/{totalCount} 片段完成</div>
|
||||||
|
<Progress value={progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && detail && (
|
||||||
|
<div className="space-y-3 pt-2 border-t">
|
||||||
|
{detail.characters.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">角色列表</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{detail.characters.map(char => (
|
||||||
|
<div key={char.id} className="flex items-center justify-between text-sm border rounded px-2 py-1">
|
||||||
|
<span className="font-medium">{char.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[200px]">{char.instruct}</span>
|
||||||
|
{char.voice_design_id && (
|
||||||
|
<Badge variant="outline" className="text-xs">音色 #{char.voice_design_id}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{segments.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
片段列表 ({segments.length} 条)
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto space-y-1">
|
||||||
|
{segments.slice(0, 50).map(seg => (
|
||||||
|
<div key={seg.id} className="flex items-start gap-2 text-xs border rounded px-2 py-1">
|
||||||
|
<Badge variant="outline" className="shrink-0 text-xs">{seg.character_name || '?'}</Badge>
|
||||||
|
<span className="text-muted-foreground truncate">{seg.text}</span>
|
||||||
|
<Badge variant={seg.status === 'done' ? 'default' : 'secondary'} className="shrink-0 text-xs">
|
||||||
|
{seg.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{segments.length > 50 && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-1">... 还有 {segments.length - 50} 条</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Audiobook() {
|
||||||
|
const [projects, setProjects] = useState<AudiobookProject[]>([])
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [showLLM, setShowLLM] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await audiobookApi.listProjects()
|
||||||
|
setProjects(list)
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(formatApiError(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects()
|
||||||
|
}, [fetchProjects])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex-1 container max-w-3xl mx-auto px-4 py-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">有声书生成</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setShowLLM(!showLLM)}>LLM 配置</Button>
|
||||||
|
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />新建项目
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" onClick={fetchProjects}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLLM && <LLMConfigPanel onSaved={() => setShowLLM(false)} />}
|
||||||
|
{showCreate && <CreateProjectPanel onCreated={() => { setShowCreate(false); fetchProjects() }} />}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12">
|
||||||
|
<Book className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>暂无有声书项目</p>
|
||||||
|
<p className="text-sm mt-1">点击「新建项目」开始创建</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{projects.map(p => (
|
||||||
|
<ProjectCard key={p.id} project={p} onRefresh={fetchProjects} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user