gongxiangp2/scripts/restore-missing-audio-files.ts

264 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/**
* 恢复缺失的音频文件脚本
*
* 目标:将 S3 上的音频文件下载到本地 uploads 目录,并更新数据库
*
* 说明:
* - 处理所有音乐类型的素材
* - 检查本地文件是否存在,如果不存在则尝试从 S3 恢复
* - 由于原始的 S3 URL 可能已丢失,会尝试匹配 S3 上的所有音频文件
*
* ⚠️ 重要说明:
* - 本脚本仅从 S3 下载文件到本地,不会删除 S3 上的任何文件
* - S3 存储桶内的源文件将完整保留,确保迁移失败时源文件不丢失
* - 如果需要清理 S3 文件,请在确认迁移完全成功后再手动执行
*/
import { PrismaClient } from '@prisma/client'
import { S3Client, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { Readable } from 'stream'
import { mkdir, writeFile, stat as fsStat } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
const prisma = new PrismaClient()
// 注意:音频源文件在旧存储桶(jsxiangche)中,需要迁移到本地
// 新存储桶(gongxiangpan)只用于预览文件,不需要处理
// 旧存储桶配置(音频源文件所在位置)
// 注意:需要在运行脚本时设置环境变量 S3_ACCESS_KEY_ID_OLD 和 S3_SECRET_ACCESS_KEY_OLD
// 或者设置 S3_ACCESS_KEY_OLD 和 S3_SECRET_KEY_OLD
const OLD_S3_ENDPOINT = process.env.S3_ENDPOINT_OLD || 'https://s3.1080p.hk'
const OLD_S3_BUCKET = process.env.S3_BUCKET_OLD || 'jsxiangche'
const OLD_S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID_OLD || process.env.S3_ACCESS_KEY_OLD || ''
const OLD_S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY_OLD || process.env.S3_SECRET_KEY_OLD || ''
// S3 客户端配置(使用旧存储桶配置)
const s3Client = new S3Client({
endpoint: OLD_S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
credentials: {
accessKeyId: OLD_S3_ACCESS_KEY_ID,
secretAccessKey: OLD_S3_SECRET_ACCESS_KEY,
},
forcePathStyle: true,
requestHandler: {
requestTimeout: 300000, // 5分钟超时
connectionTimeout: 30000, // 30秒连接超时
},
maxAttempts: 5,
retryMode: 'adaptive',
})
const S3_BUCKET = OLD_S3_BUCKET
const S3_PUBLIC_DOMAIN = `https://s3.1080p.hk/${OLD_S3_BUCKET}`
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
/**
* 从 S3 下载整个对象为 Buffer
*/
async function downloadS3Object(key: string): Promise<Buffer> {
const cmd = new GetObjectCommand({
Bucket: S3_BUCKET,
Key: key,
})
const res = await s3Client.send(cmd)
const body = res.Body as Readable | undefined
if (!body) {
throw new Error('S3 对象 Body 为空')
}
const chunks: Buffer[] = []
for await (const chunk of body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
}
/**
* 列出 S3 上的所有音频文件
*/
async function listS3AudioFiles(): Promise<string[]> {
const audioKeys: string[] = []
let continuationToken: string | undefined = undefined
do {
const listCommand = new ListObjectsV2Command({
Bucket: S3_BUCKET,
Prefix: '由壹点AI/',
ContinuationToken: continuationToken,
MaxKeys: 1000,
})
const response = await s3Client.send(listCommand) as any
const audioFiles = (response.Contents || []).filter((obj: any) =>
obj.Key && (obj.Key.endsWith('.mp3') || obj.Key.endsWith('.wav') || obj.Key.endsWith('.m4a'))
)
audioKeys.push(...audioFiles.map((obj: any) => obj.Key!))
continuationToken = response.NextContinuationToken
} while (continuationToken)
return audioKeys
}
/**
* 恢复一个音频文件
*/
async function restoreOneAudio(materialId: string, currentFileName: string, s3Key: string): Promise<boolean> {
const localFileName = currentFileName // 使用数据库中的文件名
const localFullPath = path.join(UPLOAD_DIR, localFileName)
// 如果文件已存在,跳过
if (existsSync(localFullPath)) {
console.log(` ✅ 文件已存在: ${localFileName}`)
return true
}
try {
console.log(` 📥 从 S3 下载: ${s3Key}`)
const buffer = await downloadS3Object(s3Key)
console.log(` 📦 下载完成,大小:${buffer.length} 字节`)
// 确保 uploads 目录存在
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true })
}
// 写入本地(使用数据库中的文件名)
console.log(` 💾 写入本地文件: ${localFullPath}`)
await writeFile(localFullPath, buffer)
// 校验
const st = await fsStat(localFullPath)
if (st.size !== buffer.length) {
throw new Error(`本地文件大小不匹配,期望 ${buffer.length} 实际 ${st.size}`)
}
// 更新数据库的 zipFilePath(使用 Linux 路径分隔符)
const zipFilePath = localFullPath.replace(/\\/g, '/')
await prisma.material.update({
where: { id: materialId },
data: {
zipFilePath: zipFilePath,
} as any,
})
console.log(` ✅ 恢复完成: ${localFileName}`)
return true
} catch (error: any) {
console.error(` ❌ 恢复失败: ${error.message}`)
return false
}
}
async function main() {
console.log('🚀 开始恢复缺失的音频文件...\n')
if (!S3_BUCKET || !S3_PUBLIC_DOMAIN) {
console.error('❌ 缺少必要环境变量:S3_BUCKET 或 S3_PUBLIC_DOMAIN')
process.exit(1)
}
console.log('📋 配置信息:')
console.log(` S3_BUCKET: ${S3_BUCKET}`)
console.log(` S3_PUBLIC_DOMAIN: ${S3_PUBLIC_DOMAIN}`)
console.log(` UPLOAD_DIR: ${UPLOAD_DIR}`)
console.log('')
// 查询所有音乐素材
const materials = await prisma.material.findMany({
where: {
deletedAt: null,
materialType: '音乐' as any,
},
select: {
id: true,
zipFileName: true,
zipFilePath: true,
},
orderBy: { createdAt: 'asc' },
})
console.log(`🔍 找到 ${materials.length} 个音乐素材\n`)
// 列出 S3 上的所有音频文件
console.log('📋 列出 S3 上的音频文件...')
const s3AudioKeys = await listS3AudioFiles()
console.log(`📦 S3 上共有 ${s3AudioKeys.length} 个音频文件\n`)
if (s3AudioKeys.length === 0) {
console.log('❌ S3 上没有音频文件')
return
}
// 统计需要恢复的文件
let restored = 0
let skipped = 0
let failed = 0
// 简单匹配策略:按照创建时间顺序匹配
// 由于无法精确匹配,我们将按照顺序分配 S3 文件给缺失文件的素材
const missingMaterials = materials.filter(m => {
if (!m.zipFileName) return false
const localPath = path.join(UPLOAD_DIR, m.zipFileName)
return !existsSync(localPath)
})
console.log(`📊 需要恢复的文件数: ${missingMaterials.length}\n`)
if (missingMaterials.length === 0) {
console.log('✅ 所有文件都已存在,无需恢复')
return
}
// 如果缺失的文件数超过 S3 上的文件数,只处理能匹配的部分
const maxToRestore = Math.min(missingMaterials.length, s3AudioKeys.length)
console.log(`⚠️ 注意:将按照顺序匹配前 ${maxToRestore} 个文件\n`)
for (let i = 0; i < maxToRestore; i++) {
const material = missingMaterials[i]
const s3Key = s3AudioKeys[i]
console.log(`\n==== [${i + 1}/${maxToRestore}] 恢复素材 ${material.id} ====`)
console.log(` 当前文件名: ${material.zipFileName}`)
console.log(` S3 Key: ${s3Key}`)
try {
const success = await restoreOneAudio(material.id, material.zipFileName!, s3Key)
if (success) {
restored++
} else {
failed++
}
} catch (error: any) {
failed++
console.error(` ❌ 恢复失败: ${error.message}`)
}
}
console.log('\n📊 恢复结果统计:')
console.log(` 成功恢复: ${restored}`)
console.log(` 跳过: ${skipped}`)
console.log(` 失败: ${failed}`)
if (restored > 0) {
console.log('\n✅ 音频文件恢复完成')
} else {
console.log('\n⚠️ 没有成功恢复任何文件')
}
}
main()
.catch((err) => {
console.error('❌ 脚本执行出错:', err)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})