feat(audiobook): implement chapter management with CRUD operations and enhance project detail responses

This commit is contained in:
2026-03-10 16:42:32 +08:00
parent 01b6f4633e
commit 3c30afc476
8 changed files with 393 additions and 156 deletions

View File

@@ -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: