// Summary status badge — admin only. Hidden when transcript not yet completed. const SummaryBadge = ({ status, error, lang }) => { if (!status) return null; const t = lang === 'zh'; if (status === 'pending' || status === 'running') { return {t ? '摘要中' : 'Summarising'}; } if (status === 'done') { return {t ? '已摘要' : 'Summarised'}; } if (status === 'failed') { const fallback = t ? '摘要失敗(未記錄錯誤訊息)' : 'Summary task failed (no error message recorded)'; let tip = error && error.length > 0 ? error : fallback; if (tip.length > 200) tip = tip.slice(0, 200) + '…'; return ( {t ? '摘要失敗' : 'Summary failed'} ); } return null; }; // Transcription Queue Tab — admin queue rows + max_concurrent input + drag reorder const QueueTab = ({ lang }) => { const t = lang === 'zh'; const { isMobile } = useViewport(); const [queue, setQueue] = React.useState({ pending: [], running: [], completed: [], failed: [], cancelled: [] }); const [settings, setSettings] = React.useState({ max_concurrent_transcriptions: 1 }); const [maxLocal, setMaxLocal] = React.useState(''); const [maxError, setMaxError] = React.useState(''); const [error, setError] = React.useState(null); const [confirmOpen, setConfirmOpen] = React.useState(false); const [confirmTarget, setConfirmTarget] = React.useState(null); const [confirmLoading, setConfirmLoading] = React.useState(false); const [actionError, setActionError] = React.useState({}); // {row_id: msg} const [draggingId, setDraggingId] = React.useState(null); const [dragInFlight, setDragInFlight] = React.useState(false); const [pendingOverride, setPendingOverride] = React.useState(null); // optimistic order array of row ids const [activeTab, setActiveTab] = React.useState('active'); // 'active' | 'completed' | 'closed' const debounceRef = React.useRef(null); // Polling React.useEffect(() => { let cancelled = false; const fetchAll = async () => { try { const [qRes, sRes] = await Promise.all([ apiFetch(`/admin/queue`), apiFetch(`/admin/settings`), ]); if (!qRes.ok || !sRes.ok) throw new Error(`HTTP ${qRes.status}/${sRes.status}`); const q = await qRes.json(); const s = await sRes.json(); if (cancelled) return; setQueue(q); setSettings(s); setMaxLocal(prev => prev === '' ? String(s.max_concurrent_transcriptions) : prev); setError(null); } catch (err) { if (!cancelled) setError(err.message || String(err)); } }; fetchAll(); const id = setInterval(fetchAll, 5000); return () => { cancelled = true; clearInterval(id); }; }, []); const refetch = async () => { try { const qRes = await apiFetch(`/admin/queue`); if (qRes.ok) setQueue(await qRes.json()); } catch {} }; const [toast, setToast] = React.useState(null); const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(null), 4000); }; const regenerateSummary = async (row) => { clearActionErr(row.id); try { const res = await apiFetch( `/admin/episodes/${row.episode_id}/regenerate-summary`, { method: 'POST' } ); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(row.id, body.detail || `HTTP ${res.status}`); return; } // Optimistic UI: flip to summarising; refetch picks up real state on next poll. row.ai_summary_status = 'pending'; await refetch(); } catch (e) { setActionErr(row.id, e.message || String(e)); } }; const [backfillBusy, setBackfillBusy] = React.useState(false); const runBackfill = async () => { if (backfillBusy) return; setBackfillBusy(true); try { const res = await apiFetch(`/admin/episodes/backfill-summary`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({})); showToast((t ? '批次補摘要失敗:' : 'Backfill failed: ') + (body.detail || `HTTP ${res.status}`)); return; } const data = await res.json(); showToast(t ? `已排入 ${data.enqueued_count} 集` : `Queued ${data.enqueued_count} episodes`); await refetch(); } catch (e) { showToast((t ? '批次補摘要失敗:' : 'Backfill failed: ') + (e.message || String(e))); } finally { setBackfillBusy(false); } }; const setActionErr = (id, msg) => setActionError(e => ({ ...e, [id]: msg })); const clearActionErr = (id) => setActionError(e => { const c = { ...e }; delete c[id]; return c; }); // ── Cancel pending ── const cancelPending = async (row) => { clearActionErr(row.id); try { const res = await apiFetch(`/admin/queue/${row.id}/cancel`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(row.id, body.detail || `HTTP ${res.status}`); return; } await refetch(); } catch (e) { setActionErr(row.id, e.message || String(e)); } }; // ── Force-cancel running ── const openForceCancel = (row) => { setConfirmTarget(row); setConfirmOpen(true); }; const closeConfirm = () => { if (!confirmLoading) { setConfirmOpen(false); setConfirmTarget(null); } }; const confirmForceCancel = async () => { if (!confirmTarget) return; setConfirmLoading(true); clearActionErr(confirmTarget.id); try { const res = await apiFetch(`/admin/queue/${confirmTarget.id}/cancel?force=true`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(confirmTarget.id, body.detail || `HTTP ${res.status}`); } else { await refetch(); } } catch (e) { setActionErr(confirmTarget.id, e.message || String(e)); } finally { setConfirmLoading(false); setConfirmOpen(false); setConfirmTarget(null); } }; // ── Retry / Ignore / Unignore ── const retryRow = async (row) => { clearActionErr(row.id); try { const res = await apiFetch(`/episodes/${row.episode_id}/transcribe`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(row.id, body.detail || `HTTP ${res.status}`); } } catch (e) { setActionErr(row.id, e.message || String(e)); } }; const ignoreRow = async (row) => { clearActionErr(row.id); try { const res = await apiFetch(`/admin/queue/${row.id}/ignore`, { method: 'POST' }); if (!res.ok) setActionErr(row.id, `HTTP ${res.status}`); } catch (e) { setActionErr(row.id, e.message || String(e)); } }; const unignoreRow = async (row) => { clearActionErr(row.id); try { const res = await apiFetch(`/admin/queue/${row.id}/unignore`, { method: 'POST' }); if (!res.ok) setActionErr(row.id, `HTTP ${res.status}`); } catch (e) { setActionErr(row.id, e.message || String(e)); } }; // ── max_concurrent input + debounce 500ms ── const onMaxChange = (e) => { const val = e.target.value; setMaxLocal(val); setMaxError(''); const num = parseInt(val, 10); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(async () => { if (Number.isNaN(num)) return; try { const res = await apiFetch(`/admin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ max_concurrent_transcriptions: num }), }); if (res.status === 422) { const body = await res.json().catch(() => ({})); setMaxError(typeof body.detail === 'string' ? body.detail : (t ? '數值無效(範圍 1–3)' : 'Invalid value (range 1–3)')); setMaxLocal(String(settings.max_concurrent_transcriptions)); return; } if (!res.ok) { setMaxError(`HTTP ${res.status}`); return; } const data = await res.json(); setSettings(data); setMaxLocal(String(data.max_concurrent_transcriptions)); } catch (err) { setMaxError(err.message || String(err)); } }, 500); }; const maxNum = parseInt(maxLocal, 10); const showMaxWarning = !Number.isNaN(maxNum) && (maxNum > 3 || maxNum < 1); // ── Drag reorder ── // Compute display order for pending: prefer pendingOverride array of ids if set const pendingDisplay = React.useMemo(() => { if (!pendingOverride) return queue.pending; const byId = Object.fromEntries(queue.pending.map(r => [r.id, r])); const ordered = pendingOverride.map(id => byId[id]).filter(Boolean); // include any new pending rows not in override (newly enqueued) const seen = new Set(pendingOverride); queue.pending.forEach(r => { if (!seen.has(r.id)) ordered.push(r); }); return ordered; }, [queue.pending, pendingOverride]); // ── Mobile arrow-button reorder ── const moveRow = async (row, direction) => { if (dragInFlight) return; const order = pendingDisplay; const i = order.findIndex(r => r.id === row.id); if (i < 0) return; const targetIdx = direction === 'up' ? i - 1 : i + 1; if (targetIdx < 0 || targetIdx >= order.length) return; const target = order[targetIdx]; clearActionErr(row.id); // optimistic reorder const previousOverride = pendingOverride; const newOrder = order.map(r => r.id); newOrder.splice(i, 1); newOrder.splice(targetIdx, 0, row.id); setPendingOverride(newOrder); setDragInFlight(true); try { const res = await apiFetch(`/admin/queue/${row.id}/position`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ position: target.position }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(row.id, body.detail || `HTTP ${res.status}`); setPendingOverride(previousOverride); return; } await refetch(); setPendingOverride(null); } catch (err) { setActionErr(row.id, err.message || String(err)); setPendingOverride(previousOverride); } finally { setDragInFlight(false); } }; const onDragStart = (e, row) => { if (dragInFlight) { e.preventDefault(); return; } e.dataTransfer.setData('text/plain', row.id); e.dataTransfer.effectAllowed = 'move'; setDraggingId(row.id); }; const onDragEnd = () => { setDraggingId(null); }; const onDragOverPending = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const onDropPending = async (e, targetRow) => { e.preventDefault(); if (dragInFlight) return; const sourceId = e.dataTransfer.getData('text/plain'); if (!sourceId || sourceId === targetRow.id) { setDraggingId(null); return; } const sourceRow = queue.pending.find(r => r.id === sourceId); if (!sourceRow) { setDraggingId(null); return; } // optimistic reorder: move sourceId to targetRow's index const currentOrder = pendingDisplay.map(r => r.id); const fromIdx = currentOrder.indexOf(sourceId); const toIdx = currentOrder.indexOf(targetRow.id); if (fromIdx === -1 || toIdx === -1) { setDraggingId(null); return; } const newOrder = [...currentOrder]; newOrder.splice(fromIdx, 1); newOrder.splice(toIdx, 0, sourceId); const previousOverride = pendingOverride; setPendingOverride(newOrder); setDraggingId(null); setDragInFlight(true); try { const res = await apiFetch(`/admin/queue/${sourceId}/position`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ position: targetRow.position }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(sourceId, body.detail || `HTTP ${res.status}`); setPendingOverride(previousOverride); return; } await refetch(); setPendingOverride(null); } catch (err) { setActionErr(sourceId, err.message || String(err)); setPendingOverride(previousOverride); } finally { setDragInFlight(false); } }; // ── Row rendering ── const formatTs = (iso) => { if (!iso) return '—'; const ms = new Date(iso).getTime(); if (Number.isNaN(ms)) return '—'; return formatRelativeTime(ms, lang); }; const Row = ({ row, status, canMoveUp, canMoveDown, position }) => { const isPending = status === 'pending'; const isRunning = status === 'running'; const isFailed = status === 'failed'; const ignored = row.ignored; const dragging = draggingId === row.id; const errMsg = actionError[row.id]; const dragEnabled = isPending && !dragInFlight && !isMobile; return (
onDragStart(e, row) : undefined} onDragEnd={dragEnabled ? onDragEnd : undefined} onDragOver={dragEnabled ? onDragOverPending : undefined} onDrop={dragEnabled ? (e) => onDropPending(e, row) : undefined} style={{ background: ignored ? TOKEN.bg : TOKEN.surface, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '12px 14px', display: 'flex', gap: isMobile ? 8 : 12, flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'flex-start', opacity: ignored ? 0.55 : (dragging ? 0.4 : 1), cursor: dragEnabled ? 'grab' : (isPending && dragInFlight && !isMobile ? 'wait' : 'default'), transition: 'opacity 0.15s', }} > {isPending && typeof position === 'number' && ( {position} )} {isPending && !isMobile && ( ⋮⋮ )}
{row.episode_title || row.episode_id.slice(0, 8)} {row.show_title || ''} {status} {ignored && {t ? '已忽略' : 'Ignored'}} {status === 'completed' && } {status === 'completed' && row.ai_summary_status === 'failed' && ( regenerateSummary(row)}> {t ? '重跑摘要' : 'Regenerate'} )}
{t ? '排隊:' : 'Enqueued: '}{formatTs(row.enqueued_at)} {row.started_at && {t ? '開始:' : 'Started: '}{formatTs(row.started_at)}} {row.finished_at && {t ? '完成:' : 'Finished: '}{formatTs(row.finished_at)}}
{row.error_message && (
{row.error_message}
)} {row.celery_task_id && (
{t ? 'Celery task id' : 'Celery task id'} {row.celery_task_id}
)} {errMsg && (
{errMsg}
)}
{isPending && isMobile && ( <> moveRow(row, 'up')} disabled={!canMoveUp}>{''} moveRow(row, 'down')} disabled={!canMoveDown}>{''} )} {isPending && cancelPending(row)}>{t ? '取消' : 'Cancel'}} {isRunning && openForceCancel(row)}>{t ? '強制取消' : 'Force Cancel'}} {isFailed && !ignored && ( <> retryRow(row)}>{t ? '重試' : 'Retry'} ignoreRow(row)}>{t ? '忽略' : 'Ignore'} )} {ignored && unignoreRow(row)}>{t ? '取消忽略' : 'Unignore'}}
); }; const Section = ({ title, rows, status }) => (
{title} ({rows.length})
{rows.length === 0 ? (
{t ? '空' : 'Empty'}
) : (
{rows.map((r, i) => ( 0 && !dragInFlight} canMoveDown={status === 'pending' && i < rows.length - 1 && !dragInFlight} /> ))}
)}
); return (
{/* Header: max_concurrent input */}
{showMaxWarning && (
{t ? '上限 3,受 worker concurrency 限制' : 'Max 3, limited by worker concurrency'}
)} {maxError && (
{maxError}
)}
{t ? `目前生效值:${settings.max_concurrent_transcriptions}` : `Currently in effect: ${settings.max_concurrent_transcriptions}`} {dragInFlight && {t ? '排序處理中…' : 'Reordering…'}}
{backfillBusy ? (t ? '排入中…' : 'Queueing…') : (t ? '批次補摘要' : 'Backfill Summaries')}
{(() => { const eligible = (queue.completed || []).filter(r => r.ai_summary_status === 'pending' || r.ai_summary_status === 'failed').length; if (eligible === 0) return null; return (
{t ? `有 ${eligible} 集待生成摘要,可點上方「批次補摘要」` : `${eligible} episodes awaiting summary — use Backfill Summaries above`}
); })()} {toast && (
{toast}
)} {error && (
{error}
)} {/* Sub-tab switcher */} {(() => { const counts = { active: (queue.pending?.length || 0) + (queue.running?.length || 0), completed: queue.completed?.length || 0, closed: (queue.failed?.length || 0) + (queue.cancelled?.length || 0), }; const tabs = [ { key: 'active', label: t ? '進行中' : 'Active' }, { key: 'completed', label: t ? '已完成' : 'Completed' }, { key: 'closed', label: t ? '已結束' : 'Closed' }, ]; return (
{tabs.map(tab => { const selected = activeTab === tab.key; return ( ); })}
); })()} {activeTab === 'active' && ( (() => { const runningRows = queue.running || []; const pendingRows = pendingDisplay || []; const total = runningRows.length + pendingRows.length; if (total === 0) { return (
{t ? '空' : 'Empty'}
); } return (
0 ? onDragOverPending : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 8, pointerEvents: (dragInFlight) ? 'none' : 'auto' }} > {runningRows.map(r => ( ))} {pendingRows.map((r, i) => ( 0 && !dragInFlight} canMoveDown={i < pendingRows.length - 1 && !dragInFlight} /> ))}
); })() )} {activeTab === 'completed' && ( queue.completed.length === 0 ? (
{t ? '空' : 'Empty'}
) : (
{queue.completed.map(r => )}
) )} {activeTab === 'closed' && ( <>
)}
); }; Object.assign(window, { QueueTab });