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 } ) } // 只允许视频和音频类型 const isVideo = material.materialType === '视频' || material.materialKind === 'VIDEO' const isAudio = material.materialType === '音频' || material.materialKind === 'AUDIO' if (!isVideo && !isAudio) { 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 audioTypes: { [key: string]: string } = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.wma': 'audio/x-ms-wma', } const defaultType = isAudio ? 'audio/mpeg' : 'video/mp4' const contentType = videoTypes[originalExt] || audioTypes[originalExt] || defaultType 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 = { 'Content-Type': s3Object.contentType || contentType, 'Accept-Ranges': s3Object.acceptRanges || 'bytes', 'Cache-Control': 'public, max-age=3600', // CORS headers for audio visualization 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Range', 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', } 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) // CORS headers for audio visualization const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Range', 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', } 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', ...corsHeaders, }, }) } return new NextResponse(fileBuffer as any, { headers: { 'Content-Type': contentType, 'Content-Length': fileBuffer.length.toString(), 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600', ...corsHeaders, }, }) } 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 }