From b7b6f5ef8e538d38613e2cb9f4d734b63c426036 Mon Sep 17 00:00:00 2001 From: bdim404 Date: Wed, 11 Mar 2026 14:22:35 +0800 Subject: [PATCH] feat: Implement batch cancellation for audiobook processing with enhanced frontend progress display. --- qwen3-tts-backend/api/audiobook.py | 15 ++++ qwen3-tts-backend/core/audiobook_service.py | 33 ++++++++- qwen3-tts-frontend/src/lib/api/audiobook.ts | 4 + .../src/locales/en-US/audiobook.json | 6 ++ .../src/locales/ja-JP/audiobook.json | 6 ++ .../src/locales/ko-KR/audiobook.json | 6 ++ .../src/locales/zh-CN/audiobook.json | 6 ++ .../src/locales/zh-TW/audiobook.json | 6 ++ qwen3-tts-frontend/src/pages/Audiobook.tsx | 74 +++++++++++++++++-- 9 files changed, 146 insertions(+), 10 deletions(-) diff --git a/qwen3-tts-backend/api/audiobook.py b/qwen3-tts-backend/api/audiobook.py index f33a6fd..71693c8 100644 --- a/qwen3-tts-backend/api/audiobook.py +++ b/qwen3-tts-backend/api/audiobook.py @@ -327,6 +327,21 @@ async def process_all_endpoint( return {"message": "Full processing started", "project_id": project_id} +@router.post("/projects/{project_id}/cancel-batch") +async def cancel_batch_endpoint( + 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") + + from core.audiobook_service import cancel_batch + cancel_batch(project_id) + return {"message": "Cancellation signalled", "project_id": project_id} + + @router.put("/projects/{project_id}/characters/{char_id}", response_model=AudiobookCharacterResponse) async def update_character( project_id: int, diff --git a/qwen3-tts-backend/core/audiobook_service.py b/qwen3-tts-backend/core/audiobook_service.py index 42bbde6..3afff63 100644 --- a/qwen3-tts-backend/core/audiobook_service.py +++ b/qwen3-tts-backend/core/audiobook_service.py @@ -14,6 +14,17 @@ from db.models import AudiobookProject, AudiobookCharacter, User logger = logging.getLogger(__name__) +# Cancellation events for batch operations, keyed by project_id +_cancel_events: dict[int, asyncio.Event] = {} + + +def cancel_batch(project_id: int) -> None: + """Signal cancellation for any running batch operation on this project.""" + ev = _cancel_events.get(project_id) + if ev: + ev.set() + logger.info(f"cancel_batch: project={project_id} cancellation signalled") + def _get_llm_service(user: User) -> LLMService: from core.security import decrypt_api_key @@ -570,12 +581,19 @@ async def parse_all_chapters(project_id: int, user: User, db: Session) -> None: if not pending: return + cancel_ev = asyncio.Event() + _cancel_events[project_id] = cancel_ev + max_concurrent = settings.AUDIOBOOK_PARSE_CONCURRENCY semaphore = asyncio.Semaphore(max_concurrent) logger.info(f"parse_all_chapters: project={project_id}, {len(pending)} chapters, concurrency={max_concurrent}") async def parse_with_limit(chapter): + if cancel_ev.is_set(): + return async with semaphore: + if cancel_ev.is_set(): + return task_db = SessionLocal() try: db_user = crud.get_user_by_id(task_db, user.id) @@ -586,7 +604,8 @@ async def parse_all_chapters(project_id: int, user: User, db: Session) -> None: task_db.close() await asyncio.gather(*[parse_with_limit(ch) for ch in pending]) - logger.info(f"parse_all_chapters: project={project_id} complete") + _cancel_events.pop(project_id, None) + logger.info(f"parse_all_chapters: project={project_id} {'cancelled' if cancel_ev.is_set() else 'complete'}") async def generate_all_chapters(project_id: int, user: User, db: Session) -> None: @@ -598,6 +617,11 @@ async def generate_all_chapters(project_id: int, user: User, db: Session) -> Non if not ready: return + cancel_ev = _cancel_events.get(project_id) + if not cancel_ev: + cancel_ev = asyncio.Event() + _cancel_events[project_id] = cancel_ev + crud.update_audiobook_project_status(db, project_id, "generating") max_concurrent = settings.AUDIOBOOK_GENERATE_CONCURRENCY @@ -605,7 +629,11 @@ async def generate_all_chapters(project_id: int, user: User, db: Session) -> Non logger.info(f"generate_all_chapters: project={project_id}, {len(ready)} chapters, concurrency={max_concurrent}") async def generate_with_limit(chapter): + if cancel_ev.is_set(): + return async with semaphore: + if cancel_ev.is_set(): + return task_db = SessionLocal() try: db_user = crud.get_user_by_id(task_db, user.id) @@ -629,7 +657,8 @@ async def generate_all_chapters(project_id: int, user: User, db: Session) -> Non finally: final_db.close() - logger.info(f"generate_all_chapters: project={project_id} complete") + _cancel_events.pop(project_id, None) + logger.info(f"generate_all_chapters: project={project_id} {'cancelled' if cancel_ev.is_set() else 'complete'}") async def process_all(project_id: int, user: User, db: Session) -> None: diff --git a/qwen3-tts-frontend/src/lib/api/audiobook.ts b/qwen3-tts-frontend/src/lib/api/audiobook.ts index 2607b7b..f098090 100644 --- a/qwen3-tts-frontend/src/lib/api/audiobook.ts +++ b/qwen3-tts-frontend/src/lib/api/audiobook.ts @@ -147,6 +147,10 @@ export const audiobookApi = { await apiClient.post(`/audiobook/projects/${projectId}/process-all`) }, + cancelBatch: async (projectId: number): Promise => { + await apiClient.post(`/audiobook/projects/${projectId}/cancel-batch`) + }, + deleteProject: async (id: number): Promise => { await apiClient.delete(`/audiobook/projects/${id}`) }, diff --git a/qwen3-tts-frontend/src/locales/en-US/audiobook.json b/qwen3-tts-frontend/src/locales/en-US/audiobook.json index 2779f86..d113778 100644 --- a/qwen3-tts-frontend/src/locales/en-US/audiobook.json +++ b/qwen3-tts-frontend/src/locales/en-US/audiobook.json @@ -12,6 +12,7 @@ "characters_ready": "Awaiting Character Review", "parsing": "Parsing Chapters", "ready": "Ready", + "processing": "Processing", "generating": "Generating", "done": "Done", "error": "Error" @@ -63,6 +64,11 @@ "deleteSuccess": "Project deleted", "allDoneToast": "\"{{title}}\" — all audio generation complete!", "segmentsProgress": "{{done}} / {{total}} segments done", + "chaptersProgress": "Chapters parsed: {{parsed}} / {{total}}", + "chaptersParsing": "{{count}} parsing", + "chaptersError": "{{count}} error", + "cancelBatch": "✖ Cancel Batch", + "cancelledToast": "Cancel signal sent. Running tasks will finish then stop.", "characters": { "title": "Characters ({{count}})", diff --git a/qwen3-tts-frontend/src/locales/ja-JP/audiobook.json b/qwen3-tts-frontend/src/locales/ja-JP/audiobook.json index 6b3f0b1..a47994a 100644 --- a/qwen3-tts-frontend/src/locales/ja-JP/audiobook.json +++ b/qwen3-tts-frontend/src/locales/ja-JP/audiobook.json @@ -12,6 +12,7 @@ "characters_ready": "キャラクター確認待ち", "parsing": "章を解析中", "ready": "生成待ち", + "processing": "処理中", "generating": "生成中", "done": "完了", "error": "エラー" @@ -63,6 +64,11 @@ "deleteSuccess": "プロジェクトを削除しました", "allDoneToast": "「{{title}}」の音声生成がすべて完了しました!", "segmentsProgress": "{{done}} / {{total}} セグメント完了", + "chaptersProgress": "章解析:{{parsed}} / {{total}} 章", + "chaptersParsing": "{{count}} 解析中", + "chaptersError": "{{count}} エラー", + "cancelBatch": "✖ バッチキャンセル", + "cancelledToast": "キャンセルシグナルを送信しました。実行中のタスクは完了後停止します。", "characters": { "title": "キャラクター({{count}} 人)", diff --git a/qwen3-tts-frontend/src/locales/ko-KR/audiobook.json b/qwen3-tts-frontend/src/locales/ko-KR/audiobook.json index cec6aac..1e6fb01 100644 --- a/qwen3-tts-frontend/src/locales/ko-KR/audiobook.json +++ b/qwen3-tts-frontend/src/locales/ko-KR/audiobook.json @@ -12,6 +12,7 @@ "characters_ready": "캐릭터 확인 대기", "parsing": "챕터 파싱 중", "ready": "생성 대기", + "processing": "처리 중", "generating": "생성 중", "done": "완료", "error": "오류" @@ -63,6 +64,11 @@ "deleteSuccess": "프로젝트가 삭제되었습니다", "allDoneToast": "「{{title}}」 음성 생성이 모두 완료되었습니다!", "segmentsProgress": "{{done}} / {{total}} 세그먼트 완료", + "chaptersProgress": "챕터 파싱: {{parsed}} / {{total}} 챕터", + "chaptersParsing": "{{count}} 파싱 중", + "chaptersError": "{{count}} 오류", + "cancelBatch": "✖ 일괄 취소", + "cancelledToast": "취소 신호가 전송되었습니다. 실행 중인 작업은 완료 후 중지됩니다.", "characters": { "title": "캐릭터 목록 ({{count}}명)", diff --git a/qwen3-tts-frontend/src/locales/zh-CN/audiobook.json b/qwen3-tts-frontend/src/locales/zh-CN/audiobook.json index d698cca..718a2c9 100644 --- a/qwen3-tts-frontend/src/locales/zh-CN/audiobook.json +++ b/qwen3-tts-frontend/src/locales/zh-CN/audiobook.json @@ -12,6 +12,7 @@ "characters_ready": "角色待确认", "parsing": "解析章节", "ready": "待生成", + "processing": "处理中", "generating": "生成中", "done": "已完成", "error": "出错" @@ -63,6 +64,11 @@ "deleteSuccess": "项目已删除", "allDoneToast": "「{{title}}」音频全部生成完成!", "segmentsProgress": "{{done}} / {{total}} 片段完成", + "chaptersProgress": "章节解析:{{parsed}} / {{total}} 章", + "chaptersParsing": "{{count}} 解析中", + "chaptersError": "{{count}} 出错", + "cancelBatch": "✖ 取消批量操作", + "cancelledToast": "已发送取消信号,当前正在运行的任务会完成后停止", "characters": { "title": "角色列表({{count}} 个)", diff --git a/qwen3-tts-frontend/src/locales/zh-TW/audiobook.json b/qwen3-tts-frontend/src/locales/zh-TW/audiobook.json index fdee34a..e058501 100644 --- a/qwen3-tts-frontend/src/locales/zh-TW/audiobook.json +++ b/qwen3-tts-frontend/src/locales/zh-TW/audiobook.json @@ -12,6 +12,7 @@ "characters_ready": "角色待確認", "parsing": "解析章節", "ready": "待生成", + "processing": "處理中", "generating": "生成中", "done": "已完成", "error": "出錯" @@ -63,6 +64,11 @@ "deleteSuccess": "專案已刪除", "allDoneToast": "「{{title}}」音訊全部生成完成!", "segmentsProgress": "{{done}} / {{total}} 片段完成", + "chaptersProgress": "章節解析:{{parsed}} / {{total}} 章", + "chaptersParsing": "{{count}} 解析中", + "chaptersError": "{{count}} 出錯", + "cancelBatch": "✖ 取消批量操作", + "cancelledToast": "已發送取消信號,當前正在執行的任務會完成後停止", "characters": { "title": "角色列表({{count}} 個)", diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index 5fab0a0..5aa9172 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -33,6 +33,7 @@ const STATUS_COLORS: Record = { analyzing: 'default', characters_ready: 'default', parsing: 'default', + processing: 'default', ready: 'default', generating: 'default', done: 'outline', @@ -554,6 +555,18 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } } + const handleCancelBatch = async () => { + try { + await audiobookApi.cancelBatch(project.id) + toast.success(t('projectCard.cancelledToast')) + setIsPolling(false) + setGeneratingChapterIndices(new Set()) + setTimeout(() => { onRefresh(); fetchDetail(); fetchSegments() }, 1000) + } catch (e: any) { + toast.error(formatApiError(e)) + } + } + const handleDownload = async (chapterIndex?: number) => { setLoadingAction(true) try { @@ -621,6 +634,26 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr const totalCount = segments.length const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0 + // Chapter parsing progress + const chaptersParsed = detail?.chapters.filter(c => ['ready', 'done'].includes(c.status) || (segments.some(s => s.chapter_index === c.chapter_index))).length ?? 0 + const chaptersParsing = detail?.chapters.filter(c => c.status === 'parsing').length ?? 0 + const chaptersError = detail?.chapters.filter(c => c.status === 'error').length ?? 0 + const chaptersTotal = detail?.chapters.length ?? 0 + const chapterProgress = chaptersTotal > 0 ? Math.round((chaptersParsed / chaptersTotal) * 100) : 0 + const hasGenerating = segments.some(s => s.status === 'generating') + + // Frontend display status override + const displayStatus = (() => { + if (['ready', 'generating', 'done'].includes(status)) { + if (chaptersParsing > 0 && hasGenerating) return 'processing' + if (chaptersParsing > 0) return 'parsing' + if (hasGenerating) return 'generating' + if (totalCount > 0 && doneCount === totalCount && chaptersTotal > 0 && chaptersParsing === 0 && chaptersError === 0) return 'done' + if (status === 'done' && (chaptersError > 0 || (chaptersTotal > 0 && chaptersParsed < chaptersTotal))) return 'ready' + } + return status + })() + return (
@@ -629,8 +662,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr {project.title}
- - {t(`status.${status}`, { defaultValue: status })} + + {t(`status.${displayStatus}`, { defaultValue: displayStatus })} + )}
)}