190 lines
4.9 KiB
TypeScript
190 lines
4.9 KiB
TypeScript
/**
|
||
* 视频归档服务
|
||
* 功能:下载临时视频URL,上传到S3,更新数据库记录
|
||
*/
|
||
|
||
import { db } from "@/lib/db";
|
||
import { message } from "@/lib/db/schema";
|
||
import { eq } from "drizzle-orm";
|
||
import { uploadFile } from "@/lib/storage/service";
|
||
import type { VideoMetadata } from "@/types/ai";
|
||
|
||
interface VideoArchiveOptions {
|
||
messageId: string;
|
||
temporaryUrl: string;
|
||
taskId: string;
|
||
metadata?: {
|
||
duration?: number;
|
||
resolution?: string;
|
||
ratio?: string;
|
||
};
|
||
}
|
||
|
||
export class VideoArchiver {
|
||
/**
|
||
* 归档视频:下载 → 上传S3 → 更新数据库
|
||
*/
|
||
async archiveVideo(options: VideoArchiveOptions): Promise<string> {
|
||
const { messageId, temporaryUrl, taskId, metadata } = options;
|
||
|
||
try {
|
||
console.log(`[VideoArchiver] 📹 开始归档视频:${messageId}`);
|
||
|
||
// 1. 更新状态为"归档中"
|
||
await this.updateVideoStatus(messageId, "archiving");
|
||
|
||
// 2. 下载视频
|
||
console.log(`[VideoArchiver] ⬇️ 下载视频:${temporaryUrl}`);
|
||
const videoBuffer = await this.downloadVideo(temporaryUrl);
|
||
|
||
if (!videoBuffer) {
|
||
throw new Error("Failed to download video from temporary URL");
|
||
}
|
||
|
||
// 3. 生成文件名
|
||
const filename = this.generateVideoFilename(messageId, taskId);
|
||
|
||
// 4. 上传到S3(使用项目统一存储服务)
|
||
console.log(`[VideoArchiver] ⬆️ 上传视频到S3:${filename}`);
|
||
const permanentUrl = await this.uploadVideoToS3(videoBuffer, filename);
|
||
|
||
// 5. 更新数据库
|
||
await this.updateVideoUrls(messageId, {
|
||
permanentUrl,
|
||
fileSize: videoBuffer.length,
|
||
...metadata,
|
||
});
|
||
|
||
console.log(`[VideoArchiver] ✅ 视频归档完成:${permanentUrl}`);
|
||
return permanentUrl;
|
||
} catch (error) {
|
||
console.error(`[VideoArchiver] ❌ 归档失败:`, error);
|
||
|
||
// 更新状态为"失败"
|
||
await this.updateVideoStatus(messageId, "failed");
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下载视频(带重试机制)
|
||
*/
|
||
private async downloadVideo(
|
||
url: string,
|
||
retries = 3
|
||
): Promise<Buffer | null> {
|
||
for (let i = 0; i < retries; i++) {
|
||
try {
|
||
const response = await fetch(url, {
|
||
headers: {
|
||
"User-Agent": "AI-Chatbot-Video-Archiver/1.0",
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
return Buffer.from(arrayBuffer);
|
||
} catch (error) {
|
||
console.error(
|
||
`[VideoArchiver] 下载失败 (尝试 ${i + 1}/${retries}):`,
|
||
error
|
||
);
|
||
|
||
if (i === retries - 1) {
|
||
return null;
|
||
}
|
||
|
||
// 等待后重试(指数退避)
|
||
await new Promise((resolve) =>
|
||
setTimeout(resolve, Math.pow(2, i) * 1000)
|
||
);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 上传视频到S3(使用项目统一存储服务)
|
||
*/
|
||
private async uploadVideoToS3(
|
||
buffer: Buffer,
|
||
filename: string
|
||
): Promise<string> {
|
||
// 直接使用项目已有的 uploadFile 方法
|
||
// 支持:数据库配置的S3 / 环境变量S3 / Vercel Blob
|
||
const result = await uploadFile(filename, buffer as any);
|
||
|
||
console.log(`[VideoArchiver] 视频已上传到S3:${result.url}`);
|
||
|
||
return result.url;
|
||
}
|
||
|
||
/**
|
||
* 生成视频文件名
|
||
* 项目的 uploadFile 会自动生成 uploads/timestamp-random.ext 格式
|
||
*/
|
||
private generateVideoFilename(messageId: string, taskId: string): string {
|
||
// 使用唯一标识作为文件名
|
||
return `video-${messageId}-${taskId}.mp4`;
|
||
}
|
||
|
||
/**
|
||
* 更新视频状态
|
||
*/
|
||
private async updateVideoStatus(
|
||
messageId: string,
|
||
status: "temporary" | "archiving" | "archived" | "failed"
|
||
) {
|
||
await db
|
||
.update(message)
|
||
.set({ videoStorageStatus: status })
|
||
.where(eq(message.id, messageId));
|
||
}
|
||
|
||
/**
|
||
* 更新视频URL和元数据
|
||
*/
|
||
private async updateVideoUrls(
|
||
messageId: string,
|
||
data: {
|
||
permanentUrl: string;
|
||
fileSize: number;
|
||
duration?: number;
|
||
resolution?: string;
|
||
ratio?: string;
|
||
}
|
||
) {
|
||
// 获取现有的 videoMetadata
|
||
const existing = await db.query.message.findFirst({
|
||
where: eq(message.id, messageId),
|
||
});
|
||
|
||
const existingMetadata = (existing?.videoMetadata as VideoMetadata) || {};
|
||
|
||
await db
|
||
.update(message)
|
||
.set({
|
||
permanentVideoUrl: data.permanentUrl,
|
||
videoStorageStatus: "archived",
|
||
archivedAt: new Date(),
|
||
videoMetadata: {
|
||
...existingMetadata,
|
||
fileSize: data.fileSize,
|
||
duration: data.duration,
|
||
resolution: data.resolution,
|
||
ratio: data.ratio,
|
||
} as any,
|
||
})
|
||
.where(eq(message.id, messageId));
|
||
}
|
||
}
|
||
|
||
// 单例模式
|
||
export const videoArchiver = new VideoArchiver();
|
||
|