From 1193d63e683e13f774cb37ff9697d3a6431d3681 Mon Sep 17 00:00:00 2001 From: bdim404 Date: Sun, 15 Mar 2026 01:12:33 +0800 Subject: [PATCH] feat: enhance parseAllChapters API to support force parsing and update Audiobook component for AI mode handling --- qwen3-tts-backend/api/audiobook.py | 13 +++- qwen3-tts-backend/core/audiobook_service.py | 67 +++++++++++++++++++++ qwen3-tts-frontend/src/lib/api/audiobook.ts | 9 ++- qwen3-tts-frontend/src/pages/Audiobook.tsx | 3 +- 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/qwen3-tts-backend/api/audiobook.py b/qwen3-tts-backend/api/audiobook.py index 825089a..18e01f4 100644 --- a/qwen3-tts-backend/api/audiobook.py +++ b/qwen3-tts-backend/api/audiobook.py @@ -629,6 +629,7 @@ async def parse_chapter( async def parse_all_chapters_endpoint( project_id: int, only_errors: bool = False, + force: bool = False, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -639,13 +640,19 @@ async def parse_all_chapters_endpoint( raise HTTPException(status_code=400, detail=f"Project must be in 'ready' state, current: {project.status}") from db.crud import get_system_setting - if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): - raise HTTPException(status_code=400, detail="LLM config not set") + if project.source_type != "ai_generated": + if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): + raise HTTPException(status_code=400, detail="LLM config not set") from core.audiobook_service import parse_all_chapters from core.database import SessionLocal - statuses = ("error",) if only_errors else ("pending", "error") + if only_errors: + statuses = ("error",) + elif force: + statuses = ("pending", "error", "ready", "done") + else: + statuses = ("pending", "error") async def run(): async_db = SessionLocal() diff --git a/qwen3-tts-backend/core/audiobook_service.py b/qwen3-tts-backend/core/audiobook_service.py index 2bfbe86..28414bf 100644 --- a/qwen3-tts-backend/core/audiobook_service.py +++ b/qwen3-tts-backend/core/audiobook_service.py @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) _LINE_RE = re.compile(r'^【(.+?)】(.*)$') _EMO_RE = re.compile(r'(([^)]+))\s*$') +_EMO_PREFIX_RE = re.compile(r'^(([^)]+))\s*') def _parse_emo(raw: str) -> tuple[Optional[str], Optional[float]]: @@ -225,6 +226,14 @@ def parse_ai_script(script_text: str, char_map: dict) -> list[dict]: emo_text, emo_alpha = et, ea content = content[:emo_m.start()].strip() + if emo_text is None: + emo_m = _EMO_PREFIX_RE.match(content) + if emo_m: + et, ea = _parse_emo(emo_m.group(1)) + if et is not None: + emo_text, emo_alpha = et, ea + content = content[emo_m.end():].strip() + if content.startswith('"') and content.endswith('"'): content = content[1:-1].strip() elif content.startswith('"') and content.endswith('"'): @@ -238,6 +247,14 @@ def parse_ai_script(script_text: str, char_map: dict) -> list[dict]: emo_text, emo_alpha = et, ea content = content[:emo_m.start()].strip() + if emo_text is None: + emo_m = _EMO_PREFIX_RE.match(content) + if emo_m: + et, ea = _parse_emo(emo_m.group(1)) + if et is not None: + emo_text, emo_alpha = et, ea + content = content[emo_m.end():].strip() + character = speaker results.append({ @@ -937,16 +954,66 @@ def identify_chapters(project_id: int, db, project) -> None: logger.info(f"Project {project_id} chapters identified: {real_idx} chapters") +async def _parse_ai_chapter(project_id: int, chapter_id: int, chapter, user: User, db, key: str) -> None: + try: + characters = crud.list_audiobook_characters(db, project_id) + char_map: dict[str, AudiobookCharacter] = {c.name: c for c in characters} + + label = chapter.title or f"第 {chapter.chapter_index + 1} 章" + ps.append_line(key, f"[{label}] 重新解析 AI 剧本 ({len(chapter.source_text or '')} 字)") + + crud.delete_audiobook_segments_for_chapter(db, project_id, chapter.chapter_index) + + segments_dir = Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id) / "segments" + if segments_dir.exists(): + chapter_prefix = f"ch{chapter.chapter_index:03d}_" + for f in segments_dir.glob(f"{chapter_prefix}*.wav"): + f.unlink(missing_ok=True) + + segments_data = parse_ai_script(chapter.source_text or "", char_map) + + seg_counter = 0 + for seg in segments_data: + seg_text = seg.get("text", "").strip() + if not seg_text: + continue + char = char_map.get(seg.get("character", "旁白")) or char_map.get("旁白") 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, + emo_text=seg.get("emo_text"), emo_alpha=seg.get("emo_alpha"), + ) + seg_counter += 1 + + crud.update_audiobook_chapter_status(db, chapter_id, "ready") + ps.append_line(key, f"\n[完成] 共 {seg_counter} 段") + ps.mark_done(key) + logger.info(f"AI chapter {chapter_id} reparsed: {seg_counter} segments") + except Exception as e: + logger.error(f"_parse_ai_chapter {chapter_id} failed: {e}", exc_info=True) + ps.append_line(key, f"\n[错误] {e}") + ps.mark_done(key) + crud.update_audiobook_chapter_status(db, chapter_id, "error", error_message=str(e)) + + async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) -> None: chapter = crud.get_audiobook_chapter(db, chapter_id) if not chapter: return + project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first() + is_ai_mode = project and project.source_type == "ai_generated" + key = f"ch_{chapter_id}" ps.reset(key) try: crud.update_audiobook_chapter_status(db, chapter_id, "parsing") + if is_ai_mode: + return await _parse_ai_chapter(project_id, chapter_id, chapter, user, db, key) + llm = _get_llm_service(db) _llm_model = crud.get_system_setting(db, "llm_model") _user_id = user.id diff --git a/qwen3-tts-frontend/src/lib/api/audiobook.ts b/qwen3-tts-frontend/src/lib/api/audiobook.ts index 665f562..30d09a3 100644 --- a/qwen3-tts-frontend/src/lib/api/audiobook.ts +++ b/qwen3-tts-frontend/src/lib/api/audiobook.ts @@ -249,9 +249,12 @@ export const audiobookApi = { await apiClient.post(`/audiobook/projects/${projectId}/characters/${charId}/regenerate-preview`) }, - parseAllChapters: async (projectId: number, onlyErrors?: boolean): Promise => { - const params = onlyErrors ? '?only_errors=true' : '' - await apiClient.post(`/audiobook/projects/${projectId}/parse-all${params}`) + parseAllChapters: async (projectId: number, onlyErrors?: boolean, force?: boolean): Promise => { + const params = new URLSearchParams() + if (onlyErrors) params.set('only_errors', 'true') + if (force) params.set('force', 'true') + const qs = params.toString() ? `?${params.toString()}` : '' + await apiClient.post(`/audiobook/projects/${projectId}/parse-all${qs}`) }, processAll: async (projectId: number): Promise => { diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index db9a2f0..527f851 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -2085,7 +2085,8 @@ export default function Audiobook() { setLoadingAction(true) setIsPolling(true) try { - await audiobookApi.parseAllChapters(selectedProject.id) + const isAI = selectedProject.source_type === 'ai_generated' + await audiobookApi.parseAllChapters(selectedProject.id, false, isAI) toast.success(t('projectCard.chapters.parseAllStarted')) fetchProjects() fetchDetail()