feat: Add batch processing for audiobook chapters including parse, generate, and combined process actions.
This commit is contained in:
@@ -267,6 +267,66 @@ async def parse_chapter(
|
||||
return {"message": "Parsing started", "chapter_id": chapter_id}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/parse-all")
|
||||
async def parse_all_chapters_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")
|
||||
if project.status not in ("ready", "done", "error"):
|
||||
raise HTTPException(status_code=400, detail=f"Project must be in 'ready' state, current: {project.status}")
|
||||
|
||||
if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.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
|
||||
|
||||
async def run():
|
||||
async_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(async_db, current_user.id)
|
||||
await parse_all_chapters(project_id, db_user, async_db)
|
||||
finally:
|
||||
async_db.close()
|
||||
|
||||
asyncio.create_task(run())
|
||||
return {"message": "Batch parsing started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/process-all")
|
||||
async def process_all_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")
|
||||
if project.status not in ("ready", "generating", "done", "error"):
|
||||
raise HTTPException(status_code=400, detail=f"Project must be in 'ready' state, current: {project.status}")
|
||||
|
||||
if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.llm_model:
|
||||
raise HTTPException(status_code=400, detail="LLM config not set")
|
||||
|
||||
from core.audiobook_service import process_all
|
||||
from core.database import SessionLocal
|
||||
|
||||
async def run():
|
||||
async_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(async_db, current_user.id)
|
||||
await process_all(project_id, db_user, async_db)
|
||||
finally:
|
||||
async_db.close()
|
||||
|
||||
asyncio.create_task(run())
|
||||
return {"message": "Full processing started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/characters/{char_id}", response_model=AudiobookCharacterResponse)
|
||||
async def update_character(
|
||||
project_id: int,
|
||||
|
||||
@@ -44,6 +44,9 @@ class Settings(BaseSettings):
|
||||
|
||||
DEFAULT_BACKEND: str = Field(default="local")
|
||||
|
||||
AUDIOBOOK_PARSE_CONCURRENCY: int = Field(default=3)
|
||||
AUDIOBOOK_GENERATE_CONCURRENCY: int = Field(default=2)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
@@ -281,6 +281,8 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
ps.append_line(key, f"共 {len(chunks)} 块\n")
|
||||
|
||||
seg_counter = 0
|
||||
failed_chunks = 0
|
||||
last_error = ""
|
||||
for i, chunk in enumerate(chunks):
|
||||
ps.append_line(key, f"块 {i + 1}/{len(chunks)} → ")
|
||||
ps.append_line(key, "")
|
||||
@@ -293,6 +295,8 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
except Exception as e:
|
||||
logger.warning(f"Chapter {chapter_id} chunk {i} failed: {e}")
|
||||
ps.append_line(key, f"\n[回退] {e}")
|
||||
failed_chunks += 1
|
||||
last_error = str(e)
|
||||
narrator = char_map.get("narrator")
|
||||
if narrator:
|
||||
crud.create_audiobook_segment(
|
||||
@@ -319,8 +323,18 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
|
||||
ps.append_line(key, f"\n✓ {chunk_count} 段")
|
||||
|
||||
ps.append_line(key, f"\n[完成] 共 {seg_counter} 段")
|
||||
crud.update_audiobook_chapter_status(db, chapter_id, "ready")
|
||||
if failed_chunks == len(chunks):
|
||||
# All chunks failed — mark chapter as error, remove fallback segments
|
||||
crud.delete_audiobook_segments_for_chapter(db, project_id, chapter.chapter_index)
|
||||
error_msg = f"所有 {len(chunks)} 个块均解析失败: {last_error}"
|
||||
ps.append_line(key, f"\n[错误] {error_msg}")
|
||||
crud.update_audiobook_chapter_status(db, chapter_id, "error", error_message=error_msg)
|
||||
elif failed_chunks > 0:
|
||||
ps.append_line(key, f"\n[完成] 共 {seg_counter} 段({failed_chunks}/{len(chunks)} 块回退到旁白)")
|
||||
crud.update_audiobook_chapter_status(db, chapter_id, "ready")
|
||||
else:
|
||||
ps.append_line(key, f"\n[完成] 共 {seg_counter} 段")
|
||||
crud.update_audiobook_chapter_status(db, chapter_id, "ready")
|
||||
ps.mark_done(key)
|
||||
logger.info(f"Chapter {chapter_id} parsed: {seg_counter} segments")
|
||||
|
||||
@@ -545,3 +559,92 @@ def merge_audio_files(audio_paths: list[str], output_path: str) -> None:
|
||||
if combined:
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
combined.export(output_path, format="wav")
|
||||
|
||||
|
||||
async def parse_all_chapters(project_id: int, user: User, db: Session) -> None:
|
||||
"""Concurrently parse all pending/error/ready chapters using asyncio.Semaphore."""
|
||||
from core.database import SessionLocal
|
||||
|
||||
chapters = crud.list_audiobook_chapters(db, project_id)
|
||||
pending = [ch for ch in chapters if ch.status in ("pending", "error", "ready")]
|
||||
if not pending:
|
||||
return
|
||||
|
||||
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):
|
||||
async with semaphore:
|
||||
task_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(task_db, user.id)
|
||||
await parse_one_chapter(project_id, chapter.id, db_user, task_db)
|
||||
except Exception as e:
|
||||
logger.error(f"parse_all_chapters: chapter {chapter.id} failed: {e}", exc_info=True)
|
||||
finally:
|
||||
task_db.close()
|
||||
|
||||
await asyncio.gather(*[parse_with_limit(ch) for ch in pending])
|
||||
logger.info(f"parse_all_chapters: project={project_id} complete")
|
||||
|
||||
|
||||
async def generate_all_chapters(project_id: int, user: User, db: Session) -> None:
|
||||
"""Concurrently generate audio for all ready chapters using asyncio.Semaphore."""
|
||||
from core.database import SessionLocal
|
||||
|
||||
chapters = crud.list_audiobook_chapters(db, project_id)
|
||||
ready = [ch for ch in chapters if ch.status == "ready"]
|
||||
if not ready:
|
||||
return
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "generating")
|
||||
|
||||
max_concurrent = settings.AUDIOBOOK_GENERATE_CONCURRENCY
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
logger.info(f"generate_all_chapters: project={project_id}, {len(ready)} chapters, concurrency={max_concurrent}")
|
||||
|
||||
async def generate_with_limit(chapter):
|
||||
async with semaphore:
|
||||
task_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(task_db, user.id)
|
||||
await generate_project(project_id, db_user, task_db, chapter_index=chapter.chapter_index)
|
||||
except Exception as e:
|
||||
logger.error(f"generate_all_chapters: chapter {chapter.chapter_index} failed: {e}", exc_info=True)
|
||||
finally:
|
||||
task_db.close()
|
||||
|
||||
await asyncio.gather(*[generate_with_limit(ch) for ch in ready])
|
||||
|
||||
# Check final project status
|
||||
final_db = SessionLocal()
|
||||
try:
|
||||
all_segs = crud.list_audiobook_segments(final_db, project_id)
|
||||
all_done = all(s.status == "done" for s in all_segs) if all_segs else False
|
||||
if all_done:
|
||||
crud.update_audiobook_project_status(final_db, project_id, "done")
|
||||
else:
|
||||
crud.update_audiobook_project_status(final_db, project_id, "ready")
|
||||
finally:
|
||||
final_db.close()
|
||||
|
||||
logger.info(f"generate_all_chapters: project={project_id} complete")
|
||||
|
||||
|
||||
async def process_all(project_id: int, user: User, db: Session) -> None:
|
||||
"""Parse all pending chapters, then generate all ready chapters — both with concurrency."""
|
||||
from core.database import SessionLocal
|
||||
|
||||
# Phase 1: parse all pending chapters concurrently
|
||||
await parse_all_chapters(project_id, user, db)
|
||||
|
||||
# Phase 2: reload chapters and generate all ready ones concurrently
|
||||
phase2_db = SessionLocal()
|
||||
try:
|
||||
await generate_all_chapters(project_id, user, phase2_db)
|
||||
finally:
|
||||
phase2_db.close()
|
||||
|
||||
logger.info(f"process_all: project={project_id} complete")
|
||||
|
||||
|
||||
@@ -139,6 +139,14 @@ export const audiobookApi = {
|
||||
return `/audiobook/projects/${projectId}/segments/${segmentId}/audio`
|
||||
},
|
||||
|
||||
parseAllChapters: async (projectId: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/parse-all`)
|
||||
},
|
||||
|
||||
processAll: async (projectId: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/process-all`)
|
||||
},
|
||||
|
||||
deleteProject: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/audiobook/projects/${id}`)
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"reanalyzeConfirm": "Re-analyzing will clear all character and chapter data. Continue?",
|
||||
"analyzeStarted": "Analysis started",
|
||||
"generateAll": "Generate Full Book",
|
||||
"processAll": "⚡ Process All",
|
||||
"downloadAll": "Download Full Book",
|
||||
"deleteConfirm": "Delete project \"{{title}}\" and all its audio?",
|
||||
"deleteSuccess": "Project deleted",
|
||||
@@ -85,7 +86,9 @@
|
||||
|
||||
"chapters": {
|
||||
"title": "Chapters ({{count}} total)",
|
||||
"processAll": "Process All",
|
||||
"processAll": "⚡ Process All",
|
||||
"parseAll": "Batch Parse",
|
||||
"generateAll": "Batch Generate",
|
||||
"defaultTitle": "Chapter {{index}}",
|
||||
"parse": "Parse Chapter",
|
||||
"parsing": "Parsing",
|
||||
@@ -96,6 +99,7 @@
|
||||
"generateStarted": "Chapter {{index}} generation started",
|
||||
"generateAllStarted": "Full book generation started",
|
||||
"processAllStarted": "All tasks triggered",
|
||||
"parseAllStarted": "Batch parsing started",
|
||||
"doneBadge": "{{count}} segments done",
|
||||
"segmentProgress": "{{done}}/{{total}} segments"
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"reanalyzeConfirm": "再分析するとすべてのキャラクターと章のデータが削除されます。続けますか?",
|
||||
"analyzeStarted": "分析を開始しました",
|
||||
"generateAll": "全冊生成",
|
||||
"processAll": "⚡ 全冊一括処理",
|
||||
"downloadAll": "全冊ダウンロード",
|
||||
"deleteConfirm": "プロジェクト「{{title}}」とすべての音声を削除しますか?",
|
||||
"deleteSuccess": "プロジェクトを削除しました",
|
||||
@@ -85,7 +86,9 @@
|
||||
|
||||
"chapters": {
|
||||
"title": "章一覧(全 {{count}} 章)",
|
||||
"processAll": "すべて処理",
|
||||
"processAll": "⚡ すべて処理",
|
||||
"parseAll": "一括解析",
|
||||
"generateAll": "一括生成",
|
||||
"defaultTitle": "第 {{index}} 章",
|
||||
"parse": "この章を解析",
|
||||
"parsing": "解析中",
|
||||
@@ -96,6 +99,7 @@
|
||||
"generateStarted": "第 {{index}} 章の生成を開始しました",
|
||||
"generateAllStarted": "全冊生成を開始しました",
|
||||
"processAllStarted": "すべてのタスクを開始しました",
|
||||
"parseAllStarted": "一括解析を開始しました",
|
||||
"doneBadge": "{{count}} セグメント完了",
|
||||
"segmentProgress": "{{done}}/{{total}} セグメント"
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"reanalyzeConfirm": "재분석하면 모든 캐릭터와 챕터 데이터가 삭제됩니다. 계속하시겠습니까?",
|
||||
"analyzeStarted": "분석이 시작되었습니다",
|
||||
"generateAll": "전체 책 생성",
|
||||
"processAll": "⚡ 전체 일괄 처리",
|
||||
"downloadAll": "전체 책 다운로드",
|
||||
"deleteConfirm": "프로젝트 「{{title}}」와 모든 음성을 삭제하시겠습니까?",
|
||||
"deleteSuccess": "프로젝트가 삭제되었습니다",
|
||||
@@ -85,7 +86,9 @@
|
||||
|
||||
"chapters": {
|
||||
"title": "챕터 목록 (총 {{count}}챕터)",
|
||||
"processAll": "전체 처리",
|
||||
"processAll": "⚡ 전체 처리",
|
||||
"parseAll": "일괄 파싱",
|
||||
"generateAll": "일괄 생성",
|
||||
"defaultTitle": "제 {{index}} 장",
|
||||
"parse": "이 챕터 파싱",
|
||||
"parsing": "파싱 중",
|
||||
@@ -96,6 +99,7 @@
|
||||
"generateStarted": "제 {{index}} 장 생성이 시작되었습니다",
|
||||
"generateAllStarted": "전체 책 생성이 시작되었습니다",
|
||||
"processAllStarted": "모든 작업이 시작되었습니다",
|
||||
"parseAllStarted": "일괄 파싱이 시작되었습니다",
|
||||
"doneBadge": "{{count}}개 세그먼트 완료",
|
||||
"segmentProgress": "{{done}}/{{total}} 세그먼트"
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"reanalyzeConfirm": "重新分析将清除所有角色和章节数据,确定继续?",
|
||||
"analyzeStarted": "分析已开始",
|
||||
"generateAll": "生成全书",
|
||||
"processAll": "⚡ 全书一键处理",
|
||||
"downloadAll": "下载全书",
|
||||
"deleteConfirm": "确认删除项目「{{title}}」及所有音频?",
|
||||
"deleteSuccess": "项目已删除",
|
||||
@@ -85,7 +86,9 @@
|
||||
|
||||
"chapters": {
|
||||
"title": "章节列表(共 {{count}} 章)",
|
||||
"processAll": "一键全部处理",
|
||||
"processAll": "⚡ 全部处理",
|
||||
"parseAll": "批量解析",
|
||||
"generateAll": "批量生成",
|
||||
"defaultTitle": "第 {{index}} 章",
|
||||
"parse": "解析此章",
|
||||
"parsing": "解析中",
|
||||
@@ -96,6 +99,7 @@
|
||||
"generateStarted": "第 {{index}} 章生成已开始",
|
||||
"generateAllStarted": "全书生成已开始",
|
||||
"processAllStarted": "全部任务已触发",
|
||||
"parseAllStarted": "批量解析已开始",
|
||||
"doneBadge": "已完成 {{count}} 段",
|
||||
"segmentProgress": "{{done}}/{{total}} 段"
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"reanalyzeConfirm": "重新分析將清除所有角色和章節資料,確定繼續?",
|
||||
"analyzeStarted": "分析已開始",
|
||||
"generateAll": "生成全書",
|
||||
"processAll": "⚡ 全書一鍵處理",
|
||||
"downloadAll": "下載全書",
|
||||
"deleteConfirm": "確認刪除專案「{{title}}」及所有音訊?",
|
||||
"deleteSuccess": "專案已刪除",
|
||||
@@ -85,7 +86,9 @@
|
||||
|
||||
"chapters": {
|
||||
"title": "章節列表(共 {{count}} 章)",
|
||||
"processAll": "一鍵全部處理",
|
||||
"processAll": "⚡ 全部處理",
|
||||
"parseAll": "批量解析",
|
||||
"generateAll": "批量生成",
|
||||
"defaultTitle": "第 {{index}} 章",
|
||||
"parse": "解析此章",
|
||||
"parsing": "解析中",
|
||||
@@ -96,6 +99,7 @@
|
||||
"generateStarted": "第 {{index}} 章生成已開始",
|
||||
"generateAllStarted": "全書生成已開始",
|
||||
"processAllStarted": "全部任務已觸發",
|
||||
"parseAllStarted": "批量解析已開始",
|
||||
"doneBadge": "已完成 {{count}} 段",
|
||||
"segmentProgress": "{{done}}/{{total}} 段"
|
||||
},
|
||||
|
||||
@@ -495,6 +495,43 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
}
|
||||
}
|
||||
|
||||
const handleParseAll = async () => {
|
||||
setLoadingAction(true)
|
||||
setIsPolling(true)
|
||||
try {
|
||||
await audiobookApi.parseAllChapters(project.id)
|
||||
toast.success(t('projectCard.chapters.parseAllStarted'))
|
||||
onRefresh()
|
||||
fetchDetail()
|
||||
} catch (e: any) {
|
||||
setIsPolling(false)
|
||||
toast.error(formatApiError(e))
|
||||
} finally {
|
||||
setLoadingAction(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateAll = async () => {
|
||||
if (!detail) return
|
||||
setLoadingAction(true)
|
||||
const ready = detail.chapters.filter(c => c.status === 'ready')
|
||||
if (ready.length > 0) {
|
||||
setGeneratingChapterIndices(prev => new Set([...prev, ...ready.map(c => c.chapter_index)]))
|
||||
}
|
||||
setIsPolling(true)
|
||||
try {
|
||||
await audiobookApi.generate(project.id)
|
||||
toast.success(t('projectCard.chapters.generateAllStarted'))
|
||||
onRefresh()
|
||||
fetchSegments()
|
||||
} catch (e: any) {
|
||||
setIsPolling(false)
|
||||
toast.error(formatApiError(e))
|
||||
} finally {
|
||||
setLoadingAction(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProcessAll = async () => {
|
||||
if (!detail) return
|
||||
setLoadingAction(true)
|
||||
@@ -504,11 +541,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
}
|
||||
setIsPolling(true)
|
||||
try {
|
||||
const pending = detail.chapters.filter(c => c.status === 'pending' || c.status === 'error')
|
||||
await Promise.all([
|
||||
...pending.map(c => audiobookApi.parseChapter(project.id, c.id)),
|
||||
...ready.map(c => audiobookApi.generate(project.id, c.chapter_index)),
|
||||
])
|
||||
await audiobookApi.processAll(project.id)
|
||||
toast.success(t('projectCard.chapters.processAllStarted'))
|
||||
onRefresh()
|
||||
fetchDetail()
|
||||
@@ -630,7 +663,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1 border-t">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{!isActive && (
|
||||
{!isActive && status !== 'characters_ready' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={status === 'pending' ? 'default' : 'outline'}
|
||||
@@ -642,8 +675,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
</Button>
|
||||
)}
|
||||
{status === 'ready' && (
|
||||
<Button size="sm" className="h-7 text-xs px-2" onClick={() => handleGenerate()} disabled={loadingAction}>
|
||||
{t('projectCard.generateAll')}
|
||||
<Button size="sm" className="h-7 text-xs px-2" onClick={handleProcessAll} disabled={loadingAction}>
|
||||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
{t('projectCard.processAll')}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
@@ -756,16 +790,40 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
{chaptersCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
||||
{t('projectCard.chapters.title', { count: detail.chapters.length })}
|
||||
</button>
|
||||
{detail.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2 self-start sm:self-auto"
|
||||
disabled={loadingAction}
|
||||
onClick={handleProcessAll}
|
||||
>
|
||||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 self-start sm:self-auto flex-wrap">
|
||||
{detail.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs px-2"
|
||||
disabled={loadingAction}
|
||||
onClick={handleParseAll}
|
||||
>
|
||||
{t('projectCard.chapters.parseAll')}
|
||||
</Button>
|
||||
)}
|
||||
{detail.chapters.some(c => c.status === 'ready') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs px-2"
|
||||
disabled={loadingAction}
|
||||
onClick={handleGenerateAll}
|
||||
>
|
||||
{t('projectCard.chapters.generateAll')}
|
||||
</Button>
|
||||
)}
|
||||
{detail.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail.chapters.some(c => c.status === 'ready') && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
disabled={loadingAction}
|
||||
onClick={handleProcessAll}
|
||||
>
|
||||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!chaptersCollapsed && <div className="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{detail.chapters.map(ch => {
|
||||
@@ -805,12 +863,17 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
</div>
|
||||
)}
|
||||
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={() => {
|
||||
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
||||
handleGenerate(ch.chapter_index)
|
||||
}}>
|
||||
{t('projectCard.chapters.generate')}
|
||||
</Button>
|
||||
<>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={() => {
|
||||
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
||||
handleGenerate(ch.chapter_index)
|
||||
}}>
|
||||
{t('projectCard.chapters.generate')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-xs px-2 text-muted-foreground" disabled={loadingAction} onClick={() => handleParseChapter(ch.id, ch.title)}>
|
||||
{t('projectCard.chapters.reparse')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -829,9 +892,14 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
</>
|
||||
)}
|
||||
{ch.status === 'error' && (
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={() => handleParseChapter(ch.id, ch.title)}>
|
||||
{t('projectCard.chapters.reparse')}
|
||||
</Button>
|
||||
<>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={() => handleParseChapter(ch.id, ch.title)}>
|
||||
{t('projectCard.chapters.reparse')}
|
||||
</Button>
|
||||
{ch.error_message && (
|
||||
<span className="text-xs text-destructive/80 truncate max-w-[200px]" title={ch.error_message}>{ch.error_message}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{ch.status === 'parsing' && (
|
||||
|
||||
Reference in New Issue
Block a user