183 lines
5.3 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 { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { findValidMaterialShareToken } from '@/lib/material-share-token'
import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
import { isPublicShareEnabled } from '@/lib/public-share-settings'
import { 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')
const shareToken = searchParams.get('shareToken') || searchParams.get('token') || ''
if (!id) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
}
const material = await prisma.material.findFirst({
where: {
id,
deletedAt: null,
},
select: {
id: true,
zipFilePath: true,
zipFileName: true,
materialType: true,
materialKind: true,
},
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
const session = await getServerSession(authOptions)
if (!session?.user) {
if (!(await isPublicShareEnabled())) {
return NextResponse.json(
{ error: '公开分享功能已关闭' },
{ status: 403 }
)
}
const validShareToken = shareToken
? await findValidMaterialShareToken(shareToken, material.id)
: null
if (!validShareToken) {
return NextResponse.json(
{ error: '请先登录或使用有效分享链接' },
{ status: 401 }
)
}
}
// 只允许视频和音频类型
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<string, string> = {
'Content-Type': s3Object.contentType || contentType,
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
'Cache-Control': 'private, max-age=300',
}
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 }
)
}
return createLocalFileStreamResponse(filePath, {
contentType,
range,
headers: {
'Cache-Control': 'private, max-age=300',
},
})
} 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>
}