feat: Implement batch cancellation for audiobook processing with enhanced frontend progress display.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}} 人)",
|
||||
|
||||
@@ -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}}명)",
|
||||
|
||||
@@ -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}} 个)",
|
||||
|
||||
@@ -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}} 個)",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user