// 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 });