feat(audiobook): implement chapter management with CRUD operations and enhance project detail responses
This commit is contained in:
@@ -17,6 +17,7 @@ from schemas.audiobook import (
|
||||
AudiobookProjectResponse,
|
||||
AudiobookProjectDetail,
|
||||
AudiobookCharacterResponse,
|
||||
AudiobookChapterResponse,
|
||||
AudiobookCharacterEdit,
|
||||
AudiobookSegmentResponse,
|
||||
AudiobookGenerateRequest,
|
||||
@@ -53,11 +54,17 @@ def _project_to_detail(project, db: Session) -> AudiobookProjectDetail:
|
||||
)
|
||||
for c in (project.characters or [])
|
||||
]
|
||||
from db.models import AudiobookSegment
|
||||
chapter_indices = db.query(AudiobookSegment.chapter_index).filter(
|
||||
AudiobookSegment.project_id == project.id
|
||||
).distinct().all()
|
||||
chapter_count = len(chapter_indices)
|
||||
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,
|
||||
@@ -69,7 +76,7 @@ def _project_to_detail(project, db: Session) -> AudiobookProjectDetail:
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
characters=characters,
|
||||
chapter_count=chapter_count,
|
||||
chapters=chapters,
|
||||
)
|
||||
|
||||
|
||||
@@ -193,22 +200,67 @@ async def confirm_characters(
|
||||
if project.status != "characters_ready":
|
||||
raise HTTPException(status_code=400, detail="Project must be in 'characters_ready' state to confirm characters")
|
||||
|
||||
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 identify_chapters
|
||||
try:
|
||||
identify_chapters(project_id, db, project)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
from core.audiobook_service import parse_chapters as _parse
|
||||
return {"message": "Chapters identified", "project_id": project_id}
|
||||
|
||||
|
||||
@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_parsing():
|
||||
async def run():
|
||||
async_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(async_db, current_user.id)
|
||||
await _parse(project_id, db_user, async_db)
|
||||
await parse_one_chapter(project_id, chapter_id, db_user, async_db)
|
||||
finally:
|
||||
async_db.close()
|
||||
|
||||
asyncio.create_task(run_parsing())
|
||||
return {"message": "Chapter parsing started", "project_id": project_id}
|
||||
asyncio.create_task(run())
|
||||
return {"message": "Parsing started", "chapter_id": chapter_id}
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/characters/{char_id}", response_model=AudiobookCharacterResponse)
|
||||
|
||||
@@ -80,6 +80,19 @@ def _extract_epub_chapters(file_path: str) -> list[str]:
|
||||
raise RuntimeError("ebooklib not installed. Run: pip install EbookLib")
|
||||
|
||||
|
||||
def _sample_full_text(text: str, n_samples: int = 8, sample_size: int = 3000) -> list[str]:
|
||||
if len(text) <= 30000:
|
||||
return [text]
|
||||
segment_size = len(text) // n_samples
|
||||
samples = []
|
||||
for i in range(n_samples):
|
||||
start = i * segment_size
|
||||
boundary = text.find("。", start, start + 200)
|
||||
actual_start = boundary + 1 if boundary != -1 else start
|
||||
samples.append(text[actual_start:actual_start + sample_size])
|
||||
return samples
|
||||
|
||||
|
||||
def _chunk_chapter(text: str, max_chars: int = 4000) -> list[str]:
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
@@ -139,13 +152,22 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None:
|
||||
if not text.strip():
|
||||
raise ValueError("No text content found in project.")
|
||||
|
||||
ps.append_line(project_id, f"\n[LLM] 模型:{user.llm_model},正在分析角色...\n")
|
||||
samples = _sample_full_text(text)
|
||||
n = len(samples)
|
||||
ps.append_line(project_id, f"\n[LLM] 模型:{user.llm_model},共 {n} 个采样段,正在分析角色...\n")
|
||||
ps.append_line(project_id, "")
|
||||
|
||||
def on_token(token: str) -> None:
|
||||
ps.append_token(project_id, token)
|
||||
|
||||
characters_data = await llm.extract_characters(text, on_token=on_token)
|
||||
def on_sample(i: int, total: int) -> None:
|
||||
if i < total - 1:
|
||||
ps.append_line(project_id, f"\n[LLM] 采样段 {i + 1}/{total} 完成,继续分析...\n")
|
||||
else:
|
||||
ps.append_line(project_id, f"\n[LLM] 全部 {total} 个采样段完成,正在合并角色列表...\n")
|
||||
ps.append_line(project_id, "")
|
||||
|
||||
characters_data = await llm.extract_characters(samples, on_token=on_token, on_sample=on_sample)
|
||||
|
||||
has_narrator = any(c.get("name") == "narrator" for c in characters_data)
|
||||
if not has_narrator:
|
||||
@@ -196,17 +218,44 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None:
|
||||
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||
|
||||
|
||||
async def parse_chapters(project_id: int, user: User, db: Session) -> None:
|
||||
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||
if not project:
|
||||
def _get_chapter_title(text: str) -> str:
|
||||
first_line = text.strip().split('\n')[0].strip()
|
||||
return first_line[:80] if len(first_line) <= 80 else first_line[:77] + '...'
|
||||
|
||||
|
||||
def identify_chapters(project_id: int, db, project) -> None:
|
||||
if project.source_type == "epub" and project.source_path:
|
||||
texts = _extract_epub_chapters(project.source_path)
|
||||
else:
|
||||
texts = _split_into_chapters(project.source_text or "")
|
||||
|
||||
crud.delete_audiobook_chapters(db, project_id)
|
||||
crud.delete_audiobook_segments(db, project_id)
|
||||
|
||||
real_idx = 0
|
||||
for text in texts:
|
||||
if text.strip():
|
||||
crud.create_audiobook_chapter(
|
||||
db, project_id, real_idx, text.strip(),
|
||||
title=_get_chapter_title(text),
|
||||
)
|
||||
real_idx += 1
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "ready")
|
||||
logger.info(f"Project {project_id} chapters identified: {real_idx} chapters")
|
||||
|
||||
|
||||
async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) -> None:
|
||||
from db.models import AudiobookChapter as ChapterModel
|
||||
chapter = crud.get_audiobook_chapter(db, chapter_id)
|
||||
if not chapter:
|
||||
return
|
||||
|
||||
ps.reset(project_id)
|
||||
try:
|
||||
crud.update_audiobook_project_status(db, project_id, "parsing")
|
||||
crud.update_audiobook_chapter_status(db, chapter_id, "parsing")
|
||||
|
||||
llm = _get_llm_service(user)
|
||||
|
||||
characters = crud.list_audiobook_characters(db, project_id)
|
||||
if not characters:
|
||||
raise ValueError("No characters found. Please analyze the project first.")
|
||||
@@ -214,86 +263,63 @@ async def parse_chapters(project_id: int, user: User, db: Session) -> None:
|
||||
char_map: dict[str, AudiobookCharacter] = {c.name: c for c in characters}
|
||||
character_names = list(char_map.keys())
|
||||
|
||||
text = project.source_text or ""
|
||||
if not text.strip():
|
||||
raise ValueError("No text content found in project.")
|
||||
label = chapter.title or f"第 {chapter.chapter_index + 1} 章"
|
||||
ps.append_line(project_id, f"[{label}] 开始解析 ({len(chapter.source_text)} 字)")
|
||||
|
||||
if project.source_type == "epub" and project.source_path:
|
||||
chapters = _extract_epub_chapters(project.source_path)
|
||||
else:
|
||||
chapters = _split_into_chapters(text)
|
||||
crud.delete_audiobook_segments_for_chapter(db, project_id, chapter.chapter_index)
|
||||
|
||||
non_empty = [(i, t) for i, t in enumerate(chapters) if t.strip()]
|
||||
ps.append_line(project_id, f"[解析] 共 {len(non_empty)} 章,角色:{', '.join(character_names)}\n")
|
||||
chunks = _chunk_chapter(chapter.source_text, max_chars=4000)
|
||||
ps.append_line(project_id, f"共 {len(chunks)} 块\n")
|
||||
|
||||
crud.delete_audiobook_segments(db, project_id)
|
||||
seg_counter = 0
|
||||
for i, chunk in enumerate(chunks):
|
||||
ps.append_line(project_id, f"块 {i + 1}/{len(chunks)} → ")
|
||||
ps.append_line(project_id, "")
|
||||
|
||||
seg_counters: dict[int, int] = {}
|
||||
for chapter_idx, chapter_text in non_empty:
|
||||
chunks = _chunk_chapter(chapter_text, max_chars=4000)
|
||||
logger.info(f"Chapter {chapter_idx}: {len(chapter_text)} chars → {len(chunks)} chunk(s)")
|
||||
ps.append_line(project_id, f"[第 {chapter_idx + 1} 章] {len(chapter_text)} 字,{len(chunks)} 块")
|
||||
def on_token(token: str) -> None:
|
||||
ps.append_token(project_id, token)
|
||||
|
||||
for chunk_i, chunk in enumerate(chunks):
|
||||
ps.append_line(project_id, f" 块 {chunk_i + 1}/{len(chunks)} → ")
|
||||
ps.append_line(project_id, "")
|
||||
|
||||
def on_token(token: str) -> None:
|
||||
ps.append_token(project_id, token)
|
||||
|
||||
try:
|
||||
segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Chapter {chapter_idx} chunk LLM parse failed, fallback to narrator: {e}")
|
||||
ps.append_line(project_id, f"\n [回退] LLM 失败,整块归属 narrator")
|
||||
narrator = char_map.get("narrator")
|
||||
if narrator:
|
||||
idx = seg_counters.get(chapter_idx, 0)
|
||||
crud.create_audiobook_segment(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
character_id=narrator.id,
|
||||
text=chunk.strip(),
|
||||
chapter_index=chapter_idx,
|
||||
segment_index=idx,
|
||||
)
|
||||
seg_counters[chapter_idx] = idx + 1
|
||||
continue
|
||||
|
||||
chunk_seg_count = 0
|
||||
for seg in 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
|
||||
idx = seg_counters.get(chapter_idx, 0)
|
||||
try:
|
||||
segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Chapter {chapter_id} chunk {i} failed: {e}")
|
||||
ps.append_line(project_id, f"\n[回退] {e}")
|
||||
narrator = char_map.get("narrator")
|
||||
if narrator:
|
||||
crud.create_audiobook_segment(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
character_id=char.id,
|
||||
text=seg_text,
|
||||
chapter_index=chapter_idx,
|
||||
segment_index=idx,
|
||||
db, project_id, narrator.id, chunk.strip(),
|
||||
chapter.chapter_index, seg_counter,
|
||||
)
|
||||
seg_counters[chapter_idx] = idx + 1
|
||||
chunk_seg_count += 1
|
||||
seg_counter += 1
|
||||
continue
|
||||
|
||||
ps.append_line(project_id, f"\n [完成] 解析出 {chunk_seg_count} 段")
|
||||
chunk_count = 0
|
||||
for seg in segments_data:
|
||||
seg_text = seg.get("text", "").strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
char = char_map.get(seg.get("character", "narrator")) or char_map.get("narrator")
|
||||
if not char:
|
||||
continue
|
||||
crud.create_audiobook_segment(
|
||||
db, project_id, char.id, seg_text,
|
||||
chapter.chapter_index, seg_counter,
|
||||
)
|
||||
seg_counter += 1
|
||||
chunk_count += 1
|
||||
|
||||
total_segs = sum(seg_counters.values())
|
||||
ps.append_line(project_id, f"\n[完成] 全部解析完毕,共 {total_segs} 段")
|
||||
crud.update_audiobook_project_status(db, project_id, "ready")
|
||||
ps.append_line(project_id, f"\n✓ {chunk_count} 段")
|
||||
|
||||
ps.append_line(project_id, f"\n[完成] 共 {seg_counter} 段")
|
||||
crud.update_audiobook_chapter_status(db, chapter_id, "ready")
|
||||
ps.mark_done(project_id)
|
||||
logger.info(f"Project {project_id} chapter parsing complete: {len(chapters)} chapters")
|
||||
logger.info(f"Chapter {chapter_id} parsed: {seg_counter} segments")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chapter parsing failed for project {project_id}: {e}", exc_info=True)
|
||||
logger.error(f"parse_one_chapter {chapter_id} failed: {e}", exc_info=True)
|
||||
ps.append_line(project_id, f"\n[错误] {e}")
|
||||
ps.mark_done(project_id)
|
||||
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||
crud.update_audiobook_chapter_status(db, chapter_id, "error", error_message=str(e))
|
||||
|
||||
|
||||
async def _bootstrap_character_voices(segments, user, backend, backend_type: str, db: Session) -> None:
|
||||
|
||||
@@ -115,7 +115,7 @@ class LLMService:
|
||||
logger.error(f"JSON parse failed. Raw response (first 500 chars): {raw[:500]}")
|
||||
raise
|
||||
|
||||
async def extract_characters(self, text: str, on_token=None) -> list[Dict]:
|
||||
async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None) -> list[Dict]:
|
||||
system_prompt = (
|
||||
"你是一个专业的小说分析助手兼声音导演。请分析给定的小说文本,提取所有出现的角色(包括旁白narrator)。\n"
|
||||
"对每个角色,instruct字段必须是详细的声音导演说明,需覆盖以下六个维度,每个维度单独一句,用换行分隔:\n"
|
||||
@@ -128,9 +128,44 @@ class LLMService:
|
||||
"只输出JSON,格式如下,不要有其他文字:\n"
|
||||
'{"characters": [{"name": "narrator", "description": "第三人称叙述者", "instruct": "音色信息:...\\n身份背景:...\\n年龄设定:...\\n外貌特征:...\\n性格特质:...\\n叙事风格:..."}, ...]}'
|
||||
)
|
||||
user_message = f"请分析以下小说文本并提取角色:\n\n{text[:30000]}"
|
||||
result = await self.stream_chat_json(system_prompt, user_message, on_token)
|
||||
return result.get("characters", [])
|
||||
raw_all: list[Dict] = []
|
||||
for i, sample in enumerate(text_samples):
|
||||
logger.info(f"Extracting characters from sample {i+1}/{len(text_samples)}")
|
||||
user_message = f"请分析以下小说文本并提取角色:\n\n{sample}"
|
||||
try:
|
||||
result = await self.stream_chat_json(system_prompt, user_message, on_token)
|
||||
raw_all.extend(result.get("characters", []))
|
||||
except Exception as e:
|
||||
logger.warning(f"Character extraction failed for sample {i+1}: {e}")
|
||||
if on_sample:
|
||||
on_sample(i, len(text_samples))
|
||||
if len(text_samples) == 1:
|
||||
return raw_all
|
||||
return await self.merge_characters(raw_all)
|
||||
|
||||
async def merge_characters(self, raw_characters: list[Dict]) -> list[Dict]:
|
||||
system_prompt = (
|
||||
"你是一个专业的小说角色整合助手。你收到的是从同一本书不同段落中提取的角色列表,其中可能存在重复。\n"
|
||||
"请完成以下任务:\n"
|
||||
"1. 识别并合并重复角色:通过名字完全相同或高度相似(全名与简称、不同译写)来判断。\n"
|
||||
"2. 合并时保留最完整、最详细的 description 和 instruct 字段。\n"
|
||||
"3. narrator 角色只保留一个。\n"
|
||||
"4. 去除无意义的占位角色(name 为空或仅含标点)。\n"
|
||||
"只输出 JSON,不要有其他文字:\n"
|
||||
'{"characters": [{"name": "...", "description": "...", "instruct": "..."}, ...]}'
|
||||
)
|
||||
user_message = f"请整合以下角色列表:\n\n{json.dumps(raw_characters, ensure_ascii=False, indent=2)}"
|
||||
try:
|
||||
result = await self.chat_json(system_prompt, user_message)
|
||||
return result.get("characters", [])
|
||||
except Exception as e:
|
||||
logger.warning(f"Character merge failed, falling back to name-dedup: {e}")
|
||||
seen: dict[str, Dict] = {}
|
||||
for c in raw_characters:
|
||||
name = c.get("name", "")
|
||||
if name and name not in seen:
|
||||
seen[name] = c
|
||||
return list(seen.values())
|
||||
|
||||
async def parse_chapter_segments(self, chapter_text: str, character_names: list[str], on_token=None) -> list[Dict]:
|
||||
names_str = "、".join(character_names)
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookCharacter, AudiobookSegment
|
||||
from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookChapter, AudiobookCharacter, AudiobookSegment
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
@@ -449,6 +449,66 @@ def delete_audiobook_project(db: Session, project_id: int, user_id: int) -> bool
|
||||
return True
|
||||
|
||||
|
||||
def create_audiobook_chapter(
|
||||
db: Session,
|
||||
project_id: int,
|
||||
chapter_index: int,
|
||||
source_text: str,
|
||||
title: Optional[str] = None,
|
||||
) -> AudiobookChapter:
|
||||
chapter = AudiobookChapter(
|
||||
project_id=project_id,
|
||||
chapter_index=chapter_index,
|
||||
source_text=source_text,
|
||||
title=title,
|
||||
status="pending",
|
||||
)
|
||||
db.add(chapter)
|
||||
db.commit()
|
||||
db.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
def get_audiobook_chapter(db: Session, chapter_id: int) -> Optional[AudiobookChapter]:
|
||||
return db.query(AudiobookChapter).filter(AudiobookChapter.id == chapter_id).first()
|
||||
|
||||
|
||||
def list_audiobook_chapters(db: Session, project_id: int) -> List[AudiobookChapter]:
|
||||
return db.query(AudiobookChapter).filter(
|
||||
AudiobookChapter.project_id == project_id
|
||||
).order_by(AudiobookChapter.chapter_index).all()
|
||||
|
||||
|
||||
def update_audiobook_chapter_status(
|
||||
db: Session,
|
||||
chapter_id: int,
|
||||
status: str,
|
||||
error_message: Optional[str] = None,
|
||||
) -> Optional[AudiobookChapter]:
|
||||
chapter = db.query(AudiobookChapter).filter(AudiobookChapter.id == chapter_id).first()
|
||||
if not chapter:
|
||||
return None
|
||||
chapter.status = status
|
||||
if error_message is not None:
|
||||
chapter.error_message = error_message
|
||||
db.commit()
|
||||
db.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
def delete_audiobook_chapters(db: Session, project_id: int) -> None:
|
||||
db.query(AudiobookChapter).filter(AudiobookChapter.project_id == project_id).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_audiobook_segments_for_chapter(db: Session, project_id: int, chapter_index: int) -> None:
|
||||
db.query(AudiobookSegment).filter(
|
||||
AudiobookSegment.project_id == project_id,
|
||||
AudiobookSegment.chapter_index == chapter_index,
|
||||
).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
def create_audiobook_character(
|
||||
db: Session,
|
||||
project_id: int,
|
||||
|
||||
@@ -141,9 +141,28 @@ class AudiobookProject(Base):
|
||||
|
||||
user = relationship("User", back_populates="audiobook_projects")
|
||||
characters = relationship("AudiobookCharacter", back_populates="project", cascade="all, delete-orphan")
|
||||
chapters = relationship("AudiobookChapter", back_populates="project", cascade="all, delete-orphan", order_by="AudiobookChapter.chapter_index")
|
||||
segments = relationship("AudiobookSegment", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class AudiobookChapter(Base):
|
||||
__tablename__ = "audiobook_chapters"
|
||||
|
||||
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)
|
||||
title = Column(String(500), nullable=True)
|
||||
source_text = Column(Text, nullable=False)
|
||||
status = Column(String(20), default="pending", nullable=False)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
project = relationship("AudiobookProject", back_populates="chapters")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_chapter_project_idx', 'project_id', 'chapter_index'),
|
||||
)
|
||||
|
||||
|
||||
class AudiobookCharacter(Base):
|
||||
__tablename__ = "audiobook_characters"
|
||||
|
||||
|
||||
@@ -34,9 +34,20 @@ class AudiobookCharacterResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AudiobookChapterResponse(BaseModel):
|
||||
id: int
|
||||
project_id: int
|
||||
chapter_index: int
|
||||
title: Optional[str] = None
|
||||
status: str
|
||||
error_message: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AudiobookProjectDetail(AudiobookProjectResponse):
|
||||
characters: List[AudiobookCharacterResponse] = []
|
||||
chapter_count: int = 0
|
||||
chapters: List[AudiobookChapterResponse] = []
|
||||
|
||||
|
||||
class AudiobookGenerateRequest(BaseModel):
|
||||
|
||||
@@ -21,9 +21,18 @@ export interface AudiobookCharacter {
|
||||
voice_design_id?: number
|
||||
}
|
||||
|
||||
export interface AudiobookChapter {
|
||||
id: number
|
||||
project_id: number
|
||||
chapter_index: number
|
||||
title?: string
|
||||
status: string
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface AudiobookProjectDetail extends AudiobookProject {
|
||||
characters: AudiobookCharacter[]
|
||||
chapter_count: number
|
||||
chapters: AudiobookChapter[]
|
||||
}
|
||||
|
||||
export interface AudiobookSegment {
|
||||
@@ -96,6 +105,15 @@ export const audiobookApi = {
|
||||
await apiClient.post(`/audiobook/projects/${id}/confirm`)
|
||||
},
|
||||
|
||||
listChapters: async (id: number): Promise<AudiobookChapter[]> => {
|
||||
const response = await apiClient.get<AudiobookChapter[]>(`/audiobook/projects/${id}/chapters`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
parseChapter: async (projectId: number, chapterId: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/chapters/${chapterId}/parse`)
|
||||
},
|
||||
|
||||
generate: async (id: number, chapterIndex?: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${id}/generate`, {
|
||||
chapter_index: chapterIndex ?? null,
|
||||
|
||||
@@ -36,10 +36,9 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
const STEP_HINTS: Record<string, string> = {
|
||||
pending: '第 1 步:点击「分析」,LLM 将自动提取角色列表',
|
||||
analyzing: '第 1 步:LLM 正在提取角色,请稍候...',
|
||||
characters_ready: '第 2 步:确认角色信息,可编辑后点击「确认角色 · 解析章节」',
|
||||
parsing: '第 3 步:LLM 正在解析章节脚本,请稍候...',
|
||||
ready: '第 4 步:按章节逐章生成音频,或一次性生成全书',
|
||||
generating: '第 5 步:正在合成音频,已完成片段可立即播放',
|
||||
characters_ready: '第 2 步:确认角色信息,可编辑后点击「确认角色 · 识别章节」',
|
||||
ready: '第 3 步:逐章解析剧本(LLM),解析完的章节可立即生成音频',
|
||||
generating: '第 4 步:正在合成音频,已完成片段可立即播放',
|
||||
}
|
||||
|
||||
function SequentialPlayer({
|
||||
@@ -353,18 +352,21 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
prevStatusRef.current = project.status
|
||||
}, [project.status, project.title])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPolling) return
|
||||
if (['analyzing', 'parsing', 'generating'].includes(project.status)) return
|
||||
if (!segments.some(s => s.status === 'generating')) setIsPolling(false)
|
||||
}, [isPolling, project.status, segments])
|
||||
const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false
|
||||
|
||||
useEffect(() => {
|
||||
const shouldPoll = isPolling || ['analyzing', 'parsing', 'generating'].includes(project.status)
|
||||
if (!isPolling) return
|
||||
if (['analyzing', 'generating'].includes(project.status)) return
|
||||
if (hasParsingChapter) return
|
||||
if (!segments.some(s => s.status === 'generating')) setIsPolling(false)
|
||||
}, [isPolling, project.status, segments, hasParsingChapter])
|
||||
|
||||
useEffect(() => {
|
||||
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(project.status) || hasParsingChapter
|
||||
if (!shouldPoll) return
|
||||
const id = setInterval(() => { onRefresh(); fetchSegments() }, 1500)
|
||||
const id = setInterval(() => { onRefresh(); fetchSegments(); fetchDetail() }, 1500)
|
||||
return () => clearInterval(id)
|
||||
}, [isPolling, project.status, onRefresh, fetchSegments])
|
||||
}, [isPolling, project.status, hasParsingChapter, onRefresh, fetchSegments, fetchDetail])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
const s = project.status
|
||||
@@ -389,19 +391,28 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoadingAction(true)
|
||||
setIsPolling(true)
|
||||
try {
|
||||
await audiobookApi.confirmCharacters(project.id)
|
||||
toast.success('章节解析已开始')
|
||||
toast.success('章节已识别')
|
||||
onRefresh()
|
||||
fetchDetail()
|
||||
} catch (e: any) {
|
||||
setIsPolling(false)
|
||||
toast.error(formatApiError(e))
|
||||
} finally {
|
||||
setLoadingAction(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleParseChapter = async (chapterId: number, title?: string) => {
|
||||
try {
|
||||
await audiobookApi.parseChapter(project.id, chapterId)
|
||||
toast.success(title ? `「${title}」解析已开始` : '章节解析已开始')
|
||||
fetchDetail()
|
||||
} catch (e: any) {
|
||||
toast.error(formatApiError(e))
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerate = async (chapterIndex?: number) => {
|
||||
setLoadingAction(true)
|
||||
setIsPolling(true)
|
||||
@@ -472,19 +483,11 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
}
|
||||
|
||||
const status = project.status
|
||||
const isActive = ['analyzing', 'parsing', 'generating'].includes(status)
|
||||
const isActive = ['analyzing', 'generating'].includes(status)
|
||||
const doneCount = segments.filter(s => s.status === 'done').length
|
||||
const totalCount = segments.length
|
||||
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
|
||||
|
||||
const chapterMap = new Map<number, AudiobookSegment[]>()
|
||||
segments.forEach(s => {
|
||||
const arr = chapterMap.get(s.chapter_index) ?? []
|
||||
arr.push(s)
|
||||
chapterMap.set(s.chapter_index, arr)
|
||||
})
|
||||
const chapters = Array.from(chapterMap.entries()).sort(([a], [b]) => a - b)
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -531,8 +534,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{['analyzing', 'parsing'].includes(status) && (
|
||||
<LogStream projectId={project.id} active={['analyzing', 'parsing'].includes(status)} />
|
||||
{status === 'analyzing' && (
|
||||
<LogStream projectId={project.id} active={status === 'analyzing'} />
|
||||
)}
|
||||
|
||||
{project.error_message && (
|
||||
@@ -611,57 +614,70 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
onClick={handleConfirm}
|
||||
disabled={loadingAction || editingCharId !== null}
|
||||
>
|
||||
{loadingAction ? '解析中...' : '确认角色 · 解析章节'}
|
||||
{loadingAction ? '识别中...' : '确认角色 · 识别章节'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'ready' && chapters.length > 0 && (
|
||||
{detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status) && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
按章节生成(共 {chapters.length} 章)
|
||||
章节列表(共 {detail.chapters.length} 章)
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{chapters.map(([chIdx, chSegs]) => {
|
||||
<div className="space-y-2">
|
||||
{detail.chapters.map(ch => {
|
||||
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
|
||||
const chDone = chSegs.filter(s => s.status === 'done').length
|
||||
const chTotal = chSegs.length
|
||||
const chGenerating = chSegs.some(s => s.status === 'generating')
|
||||
const chAllDone = chDone === chTotal && chTotal > 0
|
||||
const chAllDone = chTotal > 0 && chDone === chTotal
|
||||
const chTitle = ch.title || `第 ${ch.chapter_index + 1} 章`
|
||||
return (
|
||||
<div key={chIdx} className="flex items-center justify-between border rounded px-2 py-1.5 text-sm">
|
||||
<span className="text-xs text-muted-foreground shrink-0">第 {chIdx + 1} 章</span>
|
||||
<span className="text-xs text-muted-foreground mx-2 flex-1">{chDone}/{chTotal} 段</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{chGenerating ? (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>生成中</span>
|
||||
</div>
|
||||
) : chAllDone ? (
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">已完成</Badge>
|
||||
<Button
|
||||
size="sm" variant="ghost" className="h-5 w-5 p-0"
|
||||
onClick={() => handleDownload(chIdx)}
|
||||
title="下载此章"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
<div key={ch.id} className="border rounded px-3 py-2 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm gap-2">
|
||||
<span className="text-xs font-medium truncate max-w-[55%]">{chTitle}</span>
|
||||
<div className="flex gap-1 items-center shrink-0">
|
||||
{ch.status === 'pending' && (
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => handleParseChapter(ch.id, ch.title)}>
|
||||
解析此章
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm" variant="outline" className="h-6 text-xs px-2"
|
||||
disabled={loadingAction}
|
||||
onClick={() => handleGenerate(chIdx)}
|
||||
>
|
||||
{loadingAction
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: '生成此章'
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
{ch.status === 'parsing' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>解析中</span>
|
||||
</div>
|
||||
)}
|
||||
{ch.status === 'ready' && !chGenerating && !chAllDone && (
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={() => handleGenerate(ch.chapter_index)}>
|
||||
生成此章
|
||||
</Button>
|
||||
)}
|
||||
{ch.status === 'ready' && chGenerating && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>{chDone}/{chTotal} 段</span>
|
||||
</div>
|
||||
)}
|
||||
{ch.status === 'ready' && chAllDone && (
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">已完成 {chDone} 段</Badge>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => handleDownload(ch.chapter_index)} title="下载此章">
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{ch.status === 'error' && (
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={() => handleParseChapter(ch.id, ch.title)}>
|
||||
重新解析
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ch.status === 'parsing' && (
|
||||
<LogStream projectId={project.id} active={ch.status === 'parsing'} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user