feat: 将视频转写重构为纯文字异步语音识别
This commit is contained in:
@@ -338,3 +338,8 @@ export default {
|
||||
validateFileSize,
|
||||
formatFileSize,
|
||||
};
|
||||
|
||||
/** Upload derived ASR audio without changing bucket policy. */
|
||||
export async function uploadTranscriptionAudio(path: string, buffer: Buffer) {
|
||||
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, { "Content-Type": "audio/mpeg" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { extractAudio } from "../../app/api/media";
|
||||
import { getFileStream, getFileUrl, uploadTranscriptionAudio } from "../minio";
|
||||
import { prisma } from "../prisma";
|
||||
import { AsrError, MAX_DURATION_MS, validateMedia } from "./dashscope";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Whole-file transcription: download to disk, extract once, upload only audio.
|
||||
export async function prepareAudio(videoId: string): Promise<string | null> {
|
||||
const video = await prisma.video.findUniqueOrThrow({
|
||||
where: { aweme_id: videoId },
|
||||
});
|
||||
if (video.duration_ms > MAX_DURATION_MS)
|
||||
throw new AsrError("视频超过 ASR 的 12 小时时长限制");
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "douyin-asr-"));
|
||||
try {
|
||||
const input = path.join(dir, "video.mp4");
|
||||
await pipeline(
|
||||
await getFileStream(video.video_url),
|
||||
createWriteStream(input),
|
||||
{ signal: AbortSignal.timeout(60 * 60 * 1000) },
|
||||
);
|
||||
const { stdout } = await execFileAsync(
|
||||
process.env.FFPROBE_PATH || "ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=index:format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
input,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
const probe = JSON.parse(stdout) as {
|
||||
streams?: { index: number }[];
|
||||
format?: { duration?: string };
|
||||
};
|
||||
if (!Array.isArray(probe.streams)) throw new AsrError("无法读取视频音轨");
|
||||
if (!probe.streams.length) return null;
|
||||
const duration = Number(probe.format?.duration) * 1000;
|
||||
if (!Number.isFinite(duration) || duration <= 0)
|
||||
throw new AsrError("无法读取音频时长");
|
||||
if (duration > MAX_DURATION_MS)
|
||||
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
|
||||
const audio = await extractAudio(input, { format: "mp3", bitrateKbps: 64 });
|
||||
if (!audio) throw new AsrError("未找到 ffmpeg,请安装或配置 FFMPEG_PATH");
|
||||
validateMedia(duration, audio.buffer.length);
|
||||
const objectPath = `transcription-audio/${encodeURIComponent(videoId)}/${Date.now()}.mp3`;
|
||||
await uploadTranscriptionAudio(objectPath, audio.buffer);
|
||||
const url = getFileUrl(objectPath);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new AsrError("请配置公网可访问的 MINIO_PUBLIC_DOMAIN");
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new AsrError("音频 URL 协议无效");
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new AsrError(`音频公共地址不可访问 (HTTP ${response.status})`);
|
||||
return url;
|
||||
} catch (error) {
|
||||
if (error instanceof AsrError) throw error;
|
||||
throw new AsrError(
|
||||
"音频准备失败,请检查 MinIO、ffmpeg、ffprobe 和文件可访问性后重试",
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
export class AsrError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public retryable = false,
|
||||
public definitiveRejection = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const MAX_DURATION_MS = 12 * 60 * 60 * 1000;
|
||||
export const MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
export function validateMedia(durationMs: number, size: number) {
|
||||
if (durationMs > MAX_DURATION_MS)
|
||||
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
|
||||
if (!Number.isFinite(size) || size <= 0)
|
||||
throw new AsrError("音频文件为空或大小未知");
|
||||
if (size > MAX_BYTES) throw new AsrError("音频超过 ASR 的 2 GB 大小限制");
|
||||
}
|
||||
export function getAsrConfig() {
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY?.trim();
|
||||
if (!apiKey) throw new AsrError("未配置 DASHSCOPE_API_KEY,请配置后重试");
|
||||
const baseURL = (
|
||||
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1"
|
||||
).replace(/\/+$/, "");
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseURL);
|
||||
} catch {
|
||||
throw new AsrError("DASHSCOPE_BASE_URL 不是有效地址");
|
||||
}
|
||||
if (
|
||||
!["https:", "http:"].includes(url.protocol) ||
|
||||
!url.pathname.endsWith("/api/v1")
|
||||
) {
|
||||
throw new AsrError("DASHSCOPE_BASE_URL 应为原生 API 地址,以 /api/v1 结尾");
|
||||
}
|
||||
const model = process.env.DASHSCOPE_ASR_MODEL || "qwen3-asr-flash-filetrans";
|
||||
if (!/^qwen3-asr-flash-filetrans(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
|
||||
throw new AsrError(
|
||||
"DASHSCOPE_ASR_MODEL 必须是 qwen3-asr-flash-filetrans 或其日期版本",
|
||||
);
|
||||
}
|
||||
return { apiKey, baseURL, model };
|
||||
}
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
function object(value: unknown): ObjectValue {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw new AsrError("ASR 返回格式异常");
|
||||
return value as ObjectValue;
|
||||
}
|
||||
function safeCode(value: unknown) {
|
||||
return typeof value === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(value)
|
||||
? value
|
||||
: "UNKNOWN";
|
||||
}
|
||||
async function request(url: string, init: RequestInit = {}): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
throw new AsrError("ASR 网络请求失败或超时", true);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const raw = await response.json().catch(() => null);
|
||||
const body = raw && typeof raw === "object" ? (raw as ObjectValue) : {};
|
||||
throw new AsrError(
|
||||
`ASR HTTP ${response.status} (${safeCode(body.code)})`,
|
||||
response.status === 429 || response.status >= 500,
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 429,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new AsrError("ASR 返回非 JSON 数据", true);
|
||||
}
|
||||
}
|
||||
export async function submitTranscription(fileURL: string): Promise<string> {
|
||||
const { apiKey, baseURL, model } = getAsrConfig();
|
||||
const data = object(
|
||||
await request(`${baseURL}/services/audio/asr/transcription`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: { file_url: fileURL },
|
||||
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const output = object(data.output);
|
||||
if (typeof output.task_id !== "string" || !output.task_id)
|
||||
throw new AsrError("ASR 未返回任务 ID,提交结果不确定");
|
||||
return output.task_id;
|
||||
}
|
||||
export type AsrTask =
|
||||
| { status: "PENDING" | "RUNNING" }
|
||||
| { status: "SUCCEEDED"; resultURL: string };
|
||||
export async function queryTranscription(taskId: string): Promise<AsrTask> {
|
||||
const { apiKey, baseURL } = getAsrConfig();
|
||||
const data = object(
|
||||
await request(`${baseURL}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}),
|
||||
);
|
||||
const output = object(data.output);
|
||||
if (output.task_status === "PENDING" || output.task_status === "RUNNING")
|
||||
return { status: output.task_status };
|
||||
if (output.task_status === "SUCCEEDED") {
|
||||
const result = object(output.result);
|
||||
if (typeof result.transcription_url !== "string")
|
||||
throw new AsrError("ASR 缺少结果下载地址");
|
||||
return { status: "SUCCEEDED", resultURL: result.transcription_url };
|
||||
}
|
||||
throw new AsrError(
|
||||
`ASR 任务失败 (${safeCode(output.code || output.task_status)})`,
|
||||
);
|
||||
}
|
||||
export function extractTranscript(value: unknown): string[] {
|
||||
const data = object(value);
|
||||
if (!Array.isArray(data.transcripts))
|
||||
throw new AsrError("ASR 缺少 transcripts,不能作为空文字保存");
|
||||
return data.transcripts.flatMap((entry: unknown) => {
|
||||
const track = object(entry);
|
||||
if (track.sentences !== undefined && !Array.isArray(track.sentences))
|
||||
throw new AsrError("ASR 句子格式异常");
|
||||
const sentences = ((track.sentences ?? []) as unknown[])
|
||||
.map((sentence) => {
|
||||
const text = object(sentence).text;
|
||||
if (typeof text !== "string") throw new AsrError("ASR 句子缺少文字");
|
||||
return text.trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (sentences.length) return sentences;
|
||||
if (typeof track.text !== "string") throw new AsrError("ASR 音轨缺少文字");
|
||||
return track.text.trim() ? [track.text.trim()] : [];
|
||||
});
|
||||
}
|
||||
export async function downloadTranscript(url: string): Promise<string[]> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new AsrError("ASR 结果地址无效");
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new AsrError("ASR 结果地址协议无效");
|
||||
// Result storage is not the API host: never forward the API key.
|
||||
return extractTranscript(await request(url));
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Prisma, type TranscriptionTask } from "@prisma/client";
|
||||
import { prisma } from "../prisma";
|
||||
import { getAsrConfig } from "./dashscope";
|
||||
import { isActive, transcriptionState } from "./types";
|
||||
|
||||
function configurationError(): string | null {
|
||||
try {
|
||||
getAsrConfig();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
}
|
||||
export async function enqueueTranscription(videoId: string, force = false) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const videos = await tx.$queryRaw<
|
||||
{ aweme_id: string }[]
|
||||
>`SELECT aweme_id FROM "Video" WHERE aweme_id = ${videoId} FOR UPDATE`;
|
||||
if (!videos.length) return null;
|
||||
const [transcript, task] = await Promise.all([
|
||||
tx.videoTranscript.findUnique({ where: { videoId } }),
|
||||
tx.transcriptionTask.findUnique({ where: { videoId } }),
|
||||
]);
|
||||
if ((task && isActive(task.status)) || (!force && transcript))
|
||||
return transcriptionState(transcript, task);
|
||||
const lastError = configurationError();
|
||||
const data = {
|
||||
status: lastError ? "FAILED" : "QUEUED",
|
||||
lastError,
|
||||
providerTaskId: null,
|
||||
submittingAt: null,
|
||||
submittedAt: null,
|
||||
leaseUntil: null,
|
||||
leaseToken: null,
|
||||
attempts: 0,
|
||||
nextPollAt: new Date(),
|
||||
};
|
||||
const saved = await tx.transcriptionTask.upsert({
|
||||
where: { videoId },
|
||||
create: { videoId, ...data },
|
||||
update: data,
|
||||
});
|
||||
return transcriptionState(transcript, saved);
|
||||
});
|
||||
}
|
||||
export async function enqueueMissingTranscriptions() {
|
||||
const error = configurationError();
|
||||
if (error) throw new Error(error);
|
||||
// One durable, atomic batch. Completed historical transcripts are never requeued.
|
||||
return prisma.$executeRaw`
|
||||
INSERT INTO "TranscriptionTask" ("videoId", status, "updatedAt")
|
||||
SELECT v.aweme_id, 'QUEUED', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') FROM "Video" v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "VideoTranscript" t WHERE t."videoId" = v.aweme_id)
|
||||
ON CONFLICT ("videoId") DO UPDATE SET status = 'QUEUED', "providerTaskId" = NULL,
|
||||
"submittingAt" = NULL, "submittedAt" = NULL, "leaseUntil" = NULL, "leaseToken" = NULL,
|
||||
attempts = 0, "lastError" = NULL, "nextPollAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
WHERE "TranscriptionTask".status = 'FAILED'
|
||||
`;
|
||||
}
|
||||
export async function readTranscription(videoId: string) {
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { aweme_id: videoId },
|
||||
include: { transcript: true, transcriptionTask: true },
|
||||
});
|
||||
return video
|
||||
? transcriptionState(video.transcript, video.transcriptionTask)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function claimTask(): Promise<TranscriptionTask | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Serialize admission across all worker processes, including PM2 restarts.
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74196321)::text`;
|
||||
const running = await tx.transcriptionTask.count({
|
||||
where: { status: "PROCESSING" },
|
||||
});
|
||||
const eligible =
|
||||
running < 2
|
||||
? Prisma.sql`status IN ('QUEUED', 'PROCESSING')`
|
||||
: Prisma.sql`status = 'PROCESSING'`;
|
||||
const rows = await tx.$queryRaw<TranscriptionTask[]>`
|
||||
SELECT * FROM "TranscriptionTask" WHERE ${eligible}
|
||||
AND "nextPollAt" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AND ("leaseUntil" IS NULL OR "leaseUntil" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
|
||||
ORDER BY CASE WHEN status = 'PROCESSING' THEN 0 ELSE 1 END, "nextPollAt", "createdAt"
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
`;
|
||||
if (!rows.length) return null;
|
||||
return tx.transcriptionTask.update({
|
||||
where: { videoId: rows[0].videoId },
|
||||
data: {
|
||||
status: "PROCESSING",
|
||||
leaseToken: randomUUID(),
|
||||
leaseUntil: new Date(Date.now() + 180_000),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function updateOwnedTask(
|
||||
task: TranscriptionTask,
|
||||
data: Prisma.TranscriptionTaskUpdateManyMutationInput,
|
||||
) {
|
||||
const result = await prisma.transcriptionTask.updateMany({
|
||||
where: {
|
||||
videoId: task.videoId,
|
||||
leaseToken: task.leaseToken,
|
||||
status: "PROCESSING",
|
||||
leaseUntil: { gt: new Date() },
|
||||
},
|
||||
data,
|
||||
});
|
||||
if (!result.count) throw new Error("转写任务租约已失效");
|
||||
}
|
||||
export async function completeTask(
|
||||
task: TranscriptionTask,
|
||||
transcript: string[],
|
||||
) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.transcriptionTask.updateMany({
|
||||
where: {
|
||||
videoId: task.videoId,
|
||||
leaseToken: task.leaseToken,
|
||||
status: "PROCESSING",
|
||||
leaseUntil: { gt: new Date() },
|
||||
},
|
||||
data: {
|
||||
status: "SUCCEEDED",
|
||||
lastError: null,
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
attempts: 0,
|
||||
},
|
||||
});
|
||||
if (!result.count) throw new Error("转写任务租约已失效");
|
||||
await tx.videoTranscript.upsert({
|
||||
where: { videoId: task.videoId },
|
||||
create: { videoId: task.videoId, transcript },
|
||||
update: { transcript },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type TranscriptionStatus =
|
||||
| "PENDING"
|
||||
| "QUEUED"
|
||||
| "PROCESSING"
|
||||
| "SUCCEEDED"
|
||||
| "FAILED";
|
||||
export type Transcript = { transcript: string[] };
|
||||
export type TranscriptionState = {
|
||||
status: TranscriptionStatus;
|
||||
error: string | null;
|
||||
transcript: string[] | null;
|
||||
};
|
||||
export const statusLabels: Record<TranscriptionStatus, string> = {
|
||||
PENDING: "待转写",
|
||||
QUEUED: "排队中",
|
||||
PROCESSING: "处理中",
|
||||
SUCCEEDED: "已完成",
|
||||
FAILED: "失败",
|
||||
};
|
||||
export function isActive(status: string) {
|
||||
return status === "QUEUED" || status === "PROCESSING";
|
||||
}
|
||||
export function transcriptionState(
|
||||
transcript: Transcript | null,
|
||||
task: { status: string; lastError: string | null } | null,
|
||||
): TranscriptionState {
|
||||
return {
|
||||
status: (task?.status ??
|
||||
(transcript ? "SUCCEEDED" : "PENDING")) as TranscriptionStatus,
|
||||
error: task?.lastError ?? null,
|
||||
transcript: transcript?.transcript ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { TranscriptionTask } from "@prisma/client";
|
||||
import { prepareAudio } from "./audio";
|
||||
import {
|
||||
AsrError,
|
||||
downloadTranscript,
|
||||
getAsrConfig,
|
||||
queryTranscription,
|
||||
submitTranscription,
|
||||
} from "./dashscope";
|
||||
import { claimTask, completeTask, updateOwnedTask } from "./tasks";
|
||||
|
||||
export const defaultServices = {
|
||||
prepareAudio,
|
||||
submitTranscription,
|
||||
queryTranscription,
|
||||
downloadTranscript,
|
||||
};
|
||||
export async function processTask(
|
||||
task: TranscriptionTask,
|
||||
services = defaultServices,
|
||||
) {
|
||||
let submitting = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
void updateOwnedTask(task, {
|
||||
leaseUntil: new Date(Date.now() + 180_000),
|
||||
}).catch(() => {});
|
||||
}, 30_000);
|
||||
try {
|
||||
getAsrConfig();
|
||||
if (!task.providerTaskId) {
|
||||
if (task.submittingAt)
|
||||
throw new AsrError(
|
||||
"上次提交结果不确定,请核对百炼任务后手动重试,避免重复计费",
|
||||
);
|
||||
const url = await services.prepareAudio(task.videoId);
|
||||
if (url === null) {
|
||||
await completeTask(task, []);
|
||||
return;
|
||||
}
|
||||
await updateOwnedTask(task, { submittingAt: new Date() });
|
||||
submitting = true;
|
||||
const providerTaskId = await services.submitTranscription(url);
|
||||
await updateOwnedTask(task, {
|
||||
providerTaskId,
|
||||
submittedAt: new Date(),
|
||||
nextPollAt: new Date(Date.now() + 10_000),
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
lastError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Provider result URLs expire after 24 hours; do not poll forever.
|
||||
if (
|
||||
task.submittedAt &&
|
||||
Date.now() - task.submittedAt.getTime() > 24 * 60 * 60 * 1000
|
||||
) {
|
||||
throw new AsrError("ASR 任务超过 24 小时,请手动重试");
|
||||
}
|
||||
const result = await services.queryTranscription(task.providerTaskId);
|
||||
if (result.status === "SUCCEEDED") {
|
||||
const transcript = await services.downloadTranscript(result.resultURL);
|
||||
await completeTask(task, transcript);
|
||||
} else {
|
||||
await updateOwnedTask(task, {
|
||||
nextPollAt: new Date(Date.now() + 10_000),
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
lastError: null,
|
||||
attempts: 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Unknown exceptions (e.g. database outage) leave the lease and submitting marker
|
||||
// intact. Recovery can query a saved ID, but must never blindly submit again.
|
||||
if (!(error instanceof AsrError)) throw error;
|
||||
const retry = !submitting && !!task.providerTaskId && error.retryable;
|
||||
await updateOwnedTask(task, {
|
||||
status: retry ? "PROCESSING" : "FAILED",
|
||||
lastError:
|
||||
submitting && !error.definitiveRejection
|
||||
? `${error.message};提交结果不确定,请核对百炼任务后手动重试`
|
||||
: error.message,
|
||||
attempts: { increment: 1 },
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
nextPollAt: new Date(
|
||||
Date.now() +
|
||||
Math.min(300_000, 10_000 * 2 ** Math.min(task.attempts, 5)),
|
||||
),
|
||||
});
|
||||
console.warn(`[ASR] ${task.videoId}: ${retry ? "等待重试" : "失败"}`);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}
|
||||
export async function workerTick(services = defaultServices) {
|
||||
const tasks: TranscriptionTask[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const task = await claimTask();
|
||||
if (task) tasks.push(task);
|
||||
}
|
||||
await Promise.all(
|
||||
tasks.map(async (task) => {
|
||||
try {
|
||||
await processTask(task, services);
|
||||
} catch {
|
||||
console.error(`[ASR] ${task.videoId}: 执行中断,租约到期后恢复`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user