247 lines
6.3 KiB
TypeScript
247 lines
6.3 KiB
TypeScript
/**
|
||
* 视频归档服务
|
||
* 功能:下载临时视频URL,上传到S3,更新数据库记录
|
||
*/
|
||
|
||
import { db } from "@/lib/db";
|
||
import { message } from "@/lib/db/schema";
|
||
import { eq } from "drizzle-orm";
|
||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||
import { createLogger } from "@/lib/observability/logger";
|
||
import {
|
||
toDbS3UploadConfig,
|
||
uploadFile,
|
||
uploadFileWithS3Config,
|
||
} from "@/lib/storage/service";
|
||
import type { VideoMetadata } from "@/types/ai";
|
||
|
||
const logger = createLogger("video-archiver");
|
||
|
||
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 {
|
||
logger.info("archive started", { messageId, taskId });
|
||
|
||
// 1. 更新状态为"归档中"
|
||
await this.updateVideoStatus(messageId, "archiving");
|
||
|
||
// 2. 下载视频
|
||
logger.info("download started", { messageId, 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(使用项目统一存储服务)
|
||
logger.info("upload started", {
|
||
messageId,
|
||
filename,
|
||
bytes: videoBuffer.length,
|
||
});
|
||
const permanentUrl = await this.uploadVideoToS3(videoBuffer, filename);
|
||
|
||
// 5. 更新数据库
|
||
await this.updateVideoUrls(messageId, {
|
||
permanentUrl,
|
||
temporaryUrl,
|
||
fileSize: videoBuffer.length,
|
||
...metadata,
|
||
});
|
||
|
||
logger.info("archive completed", {
|
||
messageId,
|
||
permanentUrl,
|
||
bytes: videoBuffer.length,
|
||
});
|
||
return permanentUrl;
|
||
} catch (error) {
|
||
logger.error("archive failed", error, { messageId, taskId });
|
||
|
||
// 更新状态为"失败"
|
||
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) {
|
||
logger.warn("download attempt failed", {
|
||
attempt: i + 1,
|
||
retries,
|
||
url,
|
||
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> {
|
||
const defaultStorageConfig = await getDefaultStorageConfig().catch(
|
||
() => undefined
|
||
);
|
||
const defaultS3Config = toDbS3UploadConfig(defaultStorageConfig);
|
||
|
||
if (defaultS3Config) {
|
||
const uploaded = await uploadFileWithS3Config(
|
||
filename,
|
||
buffer as any,
|
||
defaultS3Config
|
||
);
|
||
logger.info("upload completed with default storage", {
|
||
filename,
|
||
url: uploaded.url,
|
||
storageConfigName: defaultStorageConfig?.name,
|
||
});
|
||
return uploaded.url;
|
||
}
|
||
|
||
logger.warn("default storage unavailable, using fallback uploader", {
|
||
filename,
|
||
});
|
||
const fallback = await uploadFile(filename, buffer as any);
|
||
logger.info("upload completed with fallback storage", {
|
||
filename,
|
||
url: fallback.url,
|
||
});
|
||
return fallback.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;
|
||
temporaryUrl: 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) || {};
|
||
|
||
const existingParts = Array.isArray(existing?.parts) ? existing.parts : [];
|
||
const nextParts = existingParts.map((part: any) => {
|
||
if (!part || part.type !== "text" || typeof part.text !== "string") {
|
||
return part;
|
||
}
|
||
return {
|
||
...part,
|
||
text: part.text.split(data.temporaryUrl).join(data.permanentUrl),
|
||
};
|
||
});
|
||
|
||
await db
|
||
.update(message)
|
||
.set({
|
||
parts: nextParts as any,
|
||
permanentVideoUrl: data.permanentUrl,
|
||
videoStorageStatus: "archived",
|
||
archivedAt: new Date(),
|
||
videoMetadata: {
|
||
...existingMetadata,
|
||
temporaryVideoUrl: data.temporaryUrl,
|
||
permanentVideoUrl: data.permanentUrl,
|
||
storageStatus: "archived",
|
||
archivedAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
fileSize: data.fileSize,
|
||
duration: data.duration,
|
||
resolution: data.resolution,
|
||
ratio: data.ratio,
|
||
} as any,
|
||
})
|
||
.where(eq(message.id, messageId));
|
||
}
|
||
}
|
||
|
||
// 单例模式
|
||
export const videoArchiver = new VideoArchiver();
|