flnxi-Wu/lib/video-transcode-service.ts

353 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { tmpdir } from 'os'
import { prisma } from '@/lib/prisma'
import { processVideoTranscode } from '@/lib/video-processor'
import { transcodeQueue } from '@/lib/video-transcode-queue'
const DEFAULT_MAX_ATTEMPTS = 3
const DEFAULT_LOCK_TIMEOUT_MS = 30 * 60 * 1000
const DEFAULT_RETRY_DELAY_MS = 5 * 1000
const MAX_RETRY_DELAY_MS = 30 * 1000
const RECOVERY_BATCH_SIZE = 20
type VideoTranscodeJobDelegate = {
upsert: (...args: any[]) => Promise<any>
findMany: (...args: any[]) => Promise<any[]>
update: (...args: any[]) => Promise<any>
}
function getVideoTranscodeJobDelegate(): VideoTranscodeJobDelegate | null {
const delegate = (prisma as any).videoTranscodeJob
if (
delegate &&
typeof delegate.upsert === 'function' &&
typeof delegate.findMany === 'function' &&
typeof delegate.update === 'function'
) {
return delegate
}
return null
}
function requireVideoTranscodeJobDelegate(): VideoTranscodeJobDelegate {
const delegate = getVideoTranscodeJobDelegate()
if (!delegate) {
throw new Error('Prisma Client 缺少 videoTranscodeJob,请先执行数据库迁移并重新生成 Prisma Client')
}
return delegate
}
function getMaxAttempts() {
const retries = parseInt(process.env.VIDEO_TRANSCODE_MAX_RETRIES || '2', 10)
return Math.max(1, retries + 1)
}
function getLockTimeoutMs() {
return parseInt(
process.env.VIDEO_TRANSCODE_LOCK_TIMEOUT_MS || String(DEFAULT_LOCK_TIMEOUT_MS),
10
)
}
function getRetryDelayMs(attempt: number) {
const baseDelay = parseInt(
process.env.VIDEO_TRANSCODE_RETRY_BASE_DELAY_MS || String(DEFAULT_RETRY_DELAY_MS),
10
)
const safeBaseDelay = Number.isFinite(baseDelay) && baseDelay > 0
? baseDelay
: DEFAULT_RETRY_DELAY_MS
return Math.min(safeBaseDelay * Math.pow(2, Math.max(0, attempt - 1)), MAX_RETRY_DELAY_MS)
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
return '未知错误'
}
function isVideoTranscodeSchemaMissing(error: unknown) {
const code = (error as any)?.code
const table = String((error as any)?.meta?.table || '')
const message = getErrorMessage(error)
return (
(code === 'P2021' && (table.includes('video_transcode_jobs') || message.includes('video_transcode_jobs'))) ||
(code === 'P2022' && message.includes('video_transcode_jobs')) ||
(code === 'P2010' && message.includes('video_transcode_jobs'))
)
}
async function runVideoTranscodeTask(
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean
) {
const maxAttempts = getMaxAttempts()
const videoTranscodeJob = requireVideoTranscodeJobDelegate()
const job = await videoTranscodeJob.update({
where: { materialId },
data: {
status: 'processing',
lockedAt: new Date(),
startedAt: new Date(),
finishedAt: null,
errorMessage: null,
attempts: { increment: 1 },
maxAttempts,
},
})
try {
console.log(`[Transcode] Starting persistent task for material ${materialId} (attempt ${job.attempts}/${maxAttempts})`)
await prisma.material.update({
where: { id: materialId },
data: { videoProcessingStatus: 'processing' },
})
const result = await processVideoTranscode({
inputPath: videoSourceUrl,
outputDir: tmpdir(),
materialId,
needsCoverImage,
})
const updateData: any = {
videoProcessingStatus: 'done',
videoPreview540Url: result.preview540Url,
videoPreviewClipUrl: result.clipUrl,
videoDuration: result.duration,
}
if (needsCoverImage && result.coverUrl) {
updateData.videoCoverUrl = result.coverUrl
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { previewUrl: true },
})
if (material?.previewUrl?.startsWith('data:image')) {
updateData.previewUrl = result.coverUrl
}
}
await prisma.material.update({
where: { id: materialId },
data: updateData,
})
await videoTranscodeJob.update({
where: { materialId },
data: {
status: 'done',
lockedAt: null,
finishedAt: new Date(),
errorMessage: null,
},
})
console.log(`[Transcode] Persistent task completed for material ${materialId}`)
} catch (error) {
const errorMessage = getErrorMessage(error)
const nextAttempts = Number(job.attempts || 0)
const shouldRetry = nextAttempts < maxAttempts
console.error(`[Transcode] Persistent task failed for material ${materialId}:`, error)
await videoTranscodeJob.update({
where: { materialId },
data: {
status: shouldRetry ? 'queued' : 'failed',
lockedAt: null,
finishedAt: shouldRetry ? null : new Date(),
errorMessage,
},
})
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: shouldRetry ? 'pending' : 'failed',
},
})
if (shouldRetry) {
const retryDelay = getRetryDelayMs(nextAttempts)
console.warn(`[Transcode] Task re-queued for material ${materialId} (${nextAttempts}/${maxAttempts}), retrying in ${retryDelay}ms`)
setTimeout(() => {
transcodeQueue.add(materialId, videoSourceUrl, needsCoverImage, runVideoTranscodeTask).catch(
(retryError) => {
console.error(`[Transcode] Retried task failed for material ${materialId}:`, retryError)
}
)
}, retryDelay)
}
throw error
}
}
export async function enqueueVideoTranscodeJob(params: {
materialId: string
videoSourceUrl: string
needsCoverImage: boolean
forceRetry?: boolean
}) {
const { materialId, videoSourceUrl, needsCoverImage, forceRetry = false } = params
const maxAttempts = getMaxAttempts()
const videoTranscodeJob = requireVideoTranscodeJobDelegate()
await videoTranscodeJob.upsert({
where: { materialId },
create: {
materialId,
videoSourceUrl,
needsCoverImage,
status: 'queued',
attempts: 0,
maxAttempts,
errorMessage: null,
},
update: {
videoSourceUrl,
needsCoverImage,
status: 'queued',
lockedAt: null,
startedAt: forceRetry ? null : undefined,
finishedAt: null,
errorMessage: null,
attempts: forceRetry ? 0 : undefined,
maxAttempts,
},
})
await prisma.material.update({
where: { id: materialId },
data: { videoProcessingStatus: 'pending' },
})
transcodeQueue.add(materialId, videoSourceUrl, needsCoverImage, runVideoTranscodeTask).catch(
(error) => {
console.error(`[Transcode] Queued task failed for material ${materialId}:`, error)
}
)
}
export async function recoverStaleVideoTranscodeJobs() {
const staleBefore = new Date(Date.now() - getLockTimeoutMs())
const videoTranscodeJob = getVideoTranscodeJobDelegate()
if (!videoTranscodeJob) {
console.warn('[Transcode] 跳过启动恢复:Prisma Client 缺少 videoTranscodeJob,请执行数据库迁移并重新生成 Prisma Client')
return { recovered: 0, hasMore: false, skipped: true }
}
let staleJobs: any[] = []
let orphanMaterials: Array<{
id: string
videoSourceUrl: string | null
videoCoverUrl: string | null
}> = []
try {
staleJobs = await videoTranscodeJob.findMany({
where: {
OR: [
{ status: 'queued' },
{ status: 'processing', lockedAt: null },
{ status: 'processing', lockedAt: { lt: staleBefore } },
],
material: {
videoSourceUrl: { not: null },
videoProcessingStatus: { in: ['pending', 'processing'] },
},
},
include: {
material: {
select: {
id: true,
videoSourceUrl: true,
videoCoverUrl: true,
},
},
},
take: RECOVERY_BATCH_SIZE,
orderBy: { updatedAt: 'asc' },
})
orphanMaterials = await prisma.$queryRaw<Array<{
id: string
videoSourceUrl: string | null
videoCoverUrl: string | null
}>>`
SELECT m.id, m."videoSourceUrl", m."videoCoverUrl"
FROM "materials" m
LEFT JOIN "video_transcode_jobs" j ON j."materialId" = m.id
WHERE (m."materialKind" = 'VIDEO' OR m."materialType" = '视频')
AND m."videoSourceUrl" IS NOT NULL
AND m."videoProcessingStatus" IN ('pending', 'processing')
AND j.id IS NULL
ORDER BY m."updatedAt" ASC
LIMIT ${RECOVERY_BATCH_SIZE}
`
} catch (error) {
if (isVideoTranscodeSchemaMissing(error)) {
console.warn('[Transcode] 跳过启动恢复:数据库缺少 video_transcode_jobs 表,请执行数据库迁移')
return { recovered: 0, hasMore: false, skipped: true }
}
throw error
}
let recovered = 0
for (const job of staleJobs) {
const sourceUrl = job.material?.videoSourceUrl || job.videoSourceUrl
if (!sourceUrl) continue
await videoTranscodeJob.update({
where: { materialId: job.materialId },
data: {
status: 'queued',
lockedAt: null,
errorMessage: null,
},
})
await prisma.material.update({
where: { id: job.materialId },
data: { videoProcessingStatus: 'pending' },
})
await enqueueVideoTranscodeJob({
materialId: job.materialId,
videoSourceUrl: sourceUrl,
needsCoverImage: !job.material?.videoCoverUrl,
})
recovered++
}
for (const material of orphanMaterials) {
if (!material.videoSourceUrl) continue
await enqueueVideoTranscodeJob({
materialId: material.id,
videoSourceUrl: material.videoSourceUrl,
needsCoverImage: !material.videoCoverUrl,
})
recovered++
}
if (recovered > 0) {
console.log(`[Transcode] Recovered ${recovered} queued/stale video transcode job(s)`)
}
const hasMore = staleJobs.length >= RECOVERY_BATCH_SIZE || orphanMaterials.length >= RECOVERY_BATCH_SIZE
if (hasMore) {
setTimeout(() => {
recoverStaleVideoTranscodeJobs().catch((error) => {
console.error('[Transcode] Continued recovery failed:', error)
})
}, 1000)
}
return { recovered, hasMore }
}