183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
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>
|
||
}
|
||
|