WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

157 lines
4.4 KiB
TypeScript
Raw 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 { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { readFile, access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
export const dynamic = 'force-dynamic'
// 视频流式传输API(允许未登录用户访问,仅用于视频播放)
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
}
const material = await prisma.material.findUnique({
where: { id },
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
// 只允许视频类型
if (material.materialType !== '视频') {
return NextResponse.json(
{ error: '此API仅支持视频类型素材' },
{ status: 403 }
)
}
if (!material.zipFilePath) {
return NextResponse.json(
{ error: '未找到素材文件,请重新上传' },
{ status: 404 }
)
}
// 根据文件扩展名确定Content-Type
const originalExt = path.extname(material.zipFileName || material.zipFilePath || '').toLowerCase()
const videoTypes: { [key: string]: string } = {
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
'.flv': 'video/x-flv',
'.mkv': 'video/x-matroska',
'.webm': 'video/webm',
}
const contentType = videoTypes[originalExt] || 'video/mp4'
const range = req.headers.get('range')
if (isS3Url(material.zipFilePath)) {
const s3Object = await getS3ObjectStream(material.zipFilePath, range)
const bodyStream = toWebStream(s3Object.body)
const headers: Record<string, string> = {
'Content-Type': s3Object.contentType || contentType,
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
'Cache-Control': 'public, max-age=3600',
}
if (range && s3Object.contentRange) {
headers['Content-Range'] = s3Object.contentRange
}
if (!range && s3Object.contentLength) {
headers['Content-Length'] = s3Object.contentLength.toString()
}
const status = range ? 206 : 200
return new NextResponse(bodyStream as any, {
status,
headers,
})
}
// Legacy local file support
let filePath = material.zipFilePath
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath)
}
console.log('Streaming legacy video file:', filePath)
try {
await access(filePath)
} catch (error) {
console.error('File not found:', filePath)
return NextResponse.json(
{ error: '文件不存在,可能已被删除' },
{ status: 404 }
)
}
const fileBuffer = await readFile(filePath)
if (range) {
const fileSize = fileBuffer.length
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
const chunkSize = end - start + 1
const chunk = fileBuffer.slice(start, end + 1)
return new NextResponse(chunk as any, {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Cache-Control': 'public, max-age=3600',
},
})
}
return new NextResponse(fileBuffer as any, {
headers: {
'Content-Type': contentType,
'Content-Length': fileBuffer.length.toString(),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600',
},
})
} catch (error) {
console.error('Stream error:', error)
return NextResponse.json(
{ error: '流式传输失败: ' + (error instanceof Error ? error.message : '未知错误') },
{ status: 500 }
)
}
}
function toWebStream(body: any) {
if (!body) {
throw new Error('S3 object body is empty')
}
if (typeof (body as any).transformToWebStream === 'function') {
return (body as any).transformToWebStream()
}
if (body instanceof Readable) {
return Readable.toWeb(body)
}
return body as ReadableStream<any>
}