feat: Implement batch cancellation for audiobook processing with enhanced frontend progress display.

This commit is contained in:
2026-03-11 14:22:35 +08:00
parent a0047d5c29
commit b7b6f5ef8e
9 changed files with 146 additions and 10 deletions

View File

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

View File

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

View File

@@ -147,6 +147,10 @@ export const audiobookApi = {
await apiClient.post(`/audiobook/projects/${projectId}/process-all`)
},
cancelBatch: async (projectId: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${projectId}/cancel-batch`)
},
deleteProject: async (id: number): Promise<void> => {
await apiClient.delete(`/audiobook/projects/${id}`)
},

View File

@@ -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}})",

View File

@@ -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}} 人)",

View File

@@ -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}}명)",

View File

@@ -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}} 个)",

View File

@@ -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}} 個)",

View File

@@ -33,6 +33,7 @@ const STATUS_COLORS: Record<string, string> = {
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 (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
@@ -629,8 +662,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
<span className="font-medium break-words">{project.title}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Badge variant={(STATUS_COLORS[status] || 'secondary') as any}>
{t(`status.${status}`, { defaultValue: status })}
<Badge variant={(STATUS_COLORS[displayStatus] || 'secondary') as any}>
{t(`status.${displayStatus}`, { defaultValue: displayStatus })}
</Badge>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
@@ -652,12 +685,37 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
<div className="text-xs text-destructive bg-destructive/10 rounded p-2">{project.error_message}</div>
)}
{totalCount > 0 && doneCount > 0 && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}
</div>
<Progress value={progress} />
{chaptersTotal > 0 && ['ready', 'generating', 'done'].includes(status) && (chaptersParsing > 0 || chaptersError > 0 || (totalCount > 0 && doneCount > 0)) && (
<div className="space-y-2">
{(chaptersParsing > 0 || chaptersError > 0 || chaptersParsed < chaptersTotal) && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span>📝</span>
<span>{t('projectCard.chaptersProgress', { parsed: chaptersParsed, total: chaptersTotal })}</span>
{chaptersParsing > 0 && (
<span className="text-primary">({t('projectCard.chaptersParsing', { count: chaptersParsing })})</span>
)}
{chaptersError > 0 && (
<span className="text-destructive">({t('projectCard.chaptersError', { count: chaptersError })})</span>
)}
</div>
<Progress value={chapterProgress} />
</div>
)}
{totalCount > 0 && doneCount > 0 && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span>🎵</span>
<span>{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}</span>
</div>
<Progress value={progress} />
</div>
)}
{(chaptersParsing > 0 || hasGenerating) && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={handleCancelBatch}>
{t('projectCard.cancelBatch')}
</Button>
)}
</div>
)}