feat: add segment tracking to audiobook projects and update UI to display progress

This commit is contained in:
2026-03-13 16:00:31 +08:00
parent d1503b08cb
commit cdb9d2ebb8
4 changed files with 59 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import func, case
from sqlalchemy.orm import Session
from api.auth import get_current_user
@@ -42,7 +43,7 @@ async def require_nsfw(current_user: User = Depends(get_current_user), db: Sessi
return current_user
def _project_to_response(project) -> AudiobookProjectResponse:
def _project_to_response(project, segment_total: int = 0, segment_done: int = 0) -> AudiobookProjectResponse:
return AudiobookProjectResponse(
id=project.id,
user_id=project.user_id,
@@ -54,6 +55,8 @@ def _project_to_response(project) -> AudiobookProjectResponse:
script_config=getattr(project, 'script_config', None),
created_at=project.created_at,
updated_at=project.updated_at,
segment_total=segment_total,
segment_done=segment_done,
)
@@ -160,7 +163,14 @@ async def list_projects(
db: Session = Depends(get_db),
):
projects = crud.list_audiobook_projects(db, current_user.id, skip=skip, limit=limit)
return [_project_to_response(p) for p in projects]
project_ids = [p.id for p in projects]
counts = db.query(
AudiobookSegment.project_id,
func.count(AudiobookSegment.id).label('total'),
func.sum(case((AudiobookSegment.status == 'done', 1), else_=0)).label('done'),
).filter(AudiobookSegment.project_id.in_(project_ids)).group_by(AudiobookSegment.project_id).all()
count_map = {r.project_id: (int(r.total), int(r.done)) for r in counts}
return [_project_to_response(p, *count_map.get(p.id, (0, 0))) for p in projects]
@router.post("/projects/generate-synopsis")

View File

@@ -45,6 +45,8 @@ class AudiobookProjectResponse(BaseModel):
script_config: Optional[Dict[str, Any]] = None
created_at: datetime
updated_at: datetime
segment_total: int = 0
segment_done: int = 0
model_config = ConfigDict(from_attributes=True)

View File

@@ -36,6 +36,8 @@ export interface AudiobookProject {
script_config?: Record<string, unknown>
created_at: string
updated_at: string
segment_total: number
segment_done: number
}
export interface AudiobookCharacter {

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame } from 'lucide-react'
import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -1062,25 +1062,48 @@ function ProjectListSidebar({
) : projects.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground">{t('noProjects')}</div>
) : (
projects.map(p => (
<button
key={p.id}
onClick={() => onSelect(p.id)}
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${
selectedId === p.id
? 'bg-background border-border shadow-sm'
: 'bg-background/50 border-border/40'
}`}
>
<div className="flex items-start gap-2">
<Book className="h-3.5 w-3.5 shrink-0 mt-0.5 text-muted-foreground" />
<span className="text-sm font-medium leading-snug break-words min-w-0 flex-1">{p.title}</span>
</div>
<Badge variant={(STATUS_COLORS[p.status] || 'secondary') as any} className="text-[10px] h-4 px-1 self-start ml-5">
{t(`status.${p.status}`, { defaultValue: p.status })}
</Badge>
</button>
))
projects.map(p => {
const isNsfw = p.source_type === 'ai_generated' && !!p.script_config?.nsfw_mode
const ProjectIcon = p.source_type === 'epub'
? BookOpen
: isNsfw
? Flame
: p.source_type === 'ai_generated'
? Wand2
: Book
const iconClass = isNsfw
? 'h-3.5 w-3.5 shrink-0 mt-0.5 text-orange-500'
: p.source_type === 'ai_generated'
? 'h-3.5 w-3.5 shrink-0 mt-0.5 text-violet-500'
: 'h-3.5 w-3.5 shrink-0 mt-0.5 text-muted-foreground'
const showProgress = p.segment_total > 0 && ['processing', 'generating', 'done'].includes(p.status)
return (
<button
key={p.id}
onClick={() => onSelect(p.id)}
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${
selectedId === p.id
? 'bg-background border-border shadow-sm'
: 'bg-background/50 border-border/40'
}`}
>
<div className="flex items-start gap-2">
<ProjectIcon className={iconClass} />
<span className="text-sm font-medium leading-snug break-words min-w-0 flex-1">{p.title}</span>
</div>
<div className="flex items-center gap-1.5 ml-5">
<Badge variant={(STATUS_COLORS[p.status] || 'secondary') as any} className="text-[10px] h-4 px-1">
{t(`status.${p.status}`, { defaultValue: p.status })}
</Badge>
{showProgress && (
<span className="text-[10px] text-muted-foreground tabular-nums">
{p.segment_done}/{p.segment_total}
</span>
)}
</div>
</button>
)
})
)}
</div>
</>