返回 AiToEarn
upload.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / libs / volcengine / services / upload.service.ts
1 import type {
2 FileTypes,
3 VodCommitUploadInfoResult,
4 VodQueryUploadTaskInfoRequest,
5 VodQueryUploadTaskInfoResult,
6 VodUploadMaterialRequest,
7 VodUploadMediaByUrlRequest,
8 VodUploadMediaByUrlResult,
9 } from '@volcengine/openapi/lib/services/vod/types'
10 import type {
11 UploadFailure,
12 UploadResult,
13 UploadSuccess,
14 VideoStreamInput,
15 VideoUrlInput,
16 } from '../volcengine.interface'
17 import path from 'node:path'
18 import { Readable } from 'node:stream'
19 import { Injectable } from '@nestjs/common'
20 import { AppException, getErrorDetail, getErrorMessage, ResponseCode } from '@yikart/common'
21 import axios, { AxiosResponse } from 'axios'
22 import { VolcengineConfig } from '../volcengine.config'
23 import { BaseService } from './base.service'
24
25 /**
26 * Volcengine 上传服务
27 * 负责视频上传相关功能:URL 批量拉取、流式上传、上传任务查询
28 */
29 @Injectable()
30 export class UploadService extends BaseService {
31 constructor(config: VolcengineConfig) {
32 super(config)
33 }
34
35 /**
36 * URL 批量拉取上传
37 * 将网络上的媒体资源直接拉取到视频点播服务
38 */
39 async uploadMediaByUrl(
40 request: VodUploadMediaByUrlRequest,
41 ): Promise<VodUploadMediaByUrlResult> {
42 const options: VodUploadMediaByUrlRequest = {
43 SpaceName: request.SpaceName || this.config.spaceName,
44 URLSets: request.URLSets,
45 }
46
47 this.logger.debug({
48 spaceName: options.SpaceName,
49 urlCount: options.URLSets?.length,
50 urls: options.URLSets?.map(u => u.SourceUrl),
51 }, '[uploadMediaByUrl] 开始上传')
52
53 try {
54 const response = await this.vodService.UploadMediaByUrl(options)
55
56 this.checkApiResponseError(response, 'uploadMediaByUrl', options)
57
58 this.logger.debug({
59 jobIds: response.Result.Data?.map(d => d.JobId),
60 }, '[uploadMediaByUrl] 上传请求成功')
61
62 return response.Result
63 }
64 catch (error) {
65 this.logger.error({
66 ...getErrorDetail(error),
67 urls: options.URLSets?.map(u => u.SourceUrl),
68 }, '[uploadMediaByUrl] 上传异常')
69 throw error
70 }
71 }
72
73 /**
74 * 流式上传
75 * 将本地文件以流的方式上传到视频点播服务
76 */
77 async uploadMaterial(
78 request: VodUploadMaterialRequest,
79 ): Promise<VodCommitUploadInfoResult> {
80 // 添加Functions参数,指定这是音视频(RecordType: 1)而不是素材(RecordType: 2)
81 const functions = [
82 { Name: 'GetMeta' },
83 {
84 Name: 'AddOptionInfo',
85 Input: {
86 RecordType: 1, // 1表示音视频,2表示素材
87 Category: 'video',
88 Format: request.FileExtension?.replace('.', '').toUpperCase() || 'MP4',
89 },
90 },
91 ]
92
93 const options: VodUploadMaterialRequest = {
94 ...request,
95 SpaceName: request.SpaceName || this.config.spaceName,
96 FileType: 'media' as unknown as FileTypes, // SDK 枚举缺少 'media',但 API 需要此值
97 Functions: JSON.stringify(functions),
98 }
99
100 try {
101 const response = await this.vodService.UploadMaterial(options)
102 this.logger.debug({
103 hasResult: !!response.Result,
104 hasError: !!response.ResponseMetadata?.Error,
105 responseMetadata: response.ResponseMetadata,
106 }, '[uploadMaterial] 收到火山引擎响应')
107
108 this.checkApiResponseError(response, 'uploadMaterial', {
109 fileName: options.FileName,
110 fileExtension: options.FileExtension,
111 })
112
113 this.logger.debug({
114 vid: response.Result.Data?.Vid,
115 posterUri: response.Result.Data?.PosterUri,
116 }, '[uploadMaterial] 上传成功')
117
118 return response.Result
119 }
120 catch (error) {
121 this.logger.error({
122 ...getErrorDetail(error),
123 fileName: options.FileName,
124 fileExtension: options.FileExtension,
125 fileSize: options.FileSize,
126 }, '[uploadMaterial] 上传异常')
127 throw error
128 }
129 }
130
131 /**
132 * 查询上传任务状态
133 * 查询 URL 批量拉取上传任务的状态
134 * @deprecated 使用 getUploadTaskInfo 代替
135 */
136 async queryUploadTaskInfo(
137 request: VodQueryUploadTaskInfoRequest,
138 ): Promise<VodQueryUploadTaskInfoResult> {
139 return this.getUploadTaskInfo(request)
140 }
141
142 /**
143 * 获取上传任务状态
144 * 查询 URL 批量拉取上传任务的状态
145 */
146 async getUploadTaskInfo(
147 request: VodQueryUploadTaskInfoRequest,
148 ): Promise<VodQueryUploadTaskInfoResult> {
149 const response = await this.vodService.QueryUploadTaskInfo(request)
150
151 // 调试日志:查看完整响应结构
152 const mediaInfoList = response.Result?.Data?.MediaInfoList
153 this.logger.debug({
154 hasResult: !!response.Result,
155 hasData: !!response.Result?.Data,
156 mediaInfoCount: mediaInfoList?.length || 0,
157 firstTaskState: mediaInfoList?.[0]?.State,
158 firstTaskVid: mediaInfoList?.[0]?.Vid,
159 firstTaskJobId: mediaInfoList?.[0]?.JobId,
160 }, '查询上传任务响应')
161
162 this.checkApiResponseError(response, 'getUploadTaskInfo', request)
163
164 return response.Result
165 }
166
167 /**
168 * 下载 URL 并使用 stream 方式上传
169 * @param url 视频 URL
170 * @param options 可选参数(文件名、扩展名等)
171 * @returns 上传成功后的 VID
172 */
173 async downloadUrlAndUploadAsStream(
174 url: string,
175 options?: VideoUrlInput,
176 ): Promise<string> {
177 let fileName = options?.fileName
178 let fileExtension = options?.fileExtension
179
180 const response: AxiosResponse<NodeJS.ReadableStream> = await axios.get(url, {
181 responseType: 'stream',
182 timeout: 60000,
183 maxContentLength: 1000 * 1024 * 1024,
184 maxBodyLength: 1000 * 1024 * 1024,
185 })
186
187 const contentLength = response.headers['content-length']
188 if (!contentLength) {
189 throw new AppException(ResponseCode.VideoUploadFailed, {
190 message: '无法获取视频文件大小(响应头缺少 content-length)',
191 })
192 }
193 const fileSize = Number.parseInt(contentLength, 10)
194
195 if (!fileName || !fileExtension) {
196 const urlObj = new URL(url)
197 const pathname = urlObj.pathname
198 const fileNameFromUrl = pathname.split('/').pop() || 'video'
199
200 if (!fileExtension) {
201 fileExtension = path.extname(fileNameFromUrl).toLowerCase() || '.mp4'
202 }
203 else if (!fileExtension.startsWith('.')) {
204 fileExtension = `.${fileExtension}`
205 }
206
207 if (!fileName) {
208 fileName = path.basename(urlObj.pathname)
209 }
210 }
211 fileName = path.basename(fileName, path.extname(fileName))
212
213 this.logger.debug({
214 fileName,
215 fileExtension,
216 fileSize,
217 url,
218 }, '[downloadUrlAndUploadAsStream] 准备上传')
219
220 const uploadResult = await this.uploadMaterial({
221 SpaceName: this.config.spaceName,
222 Content: response.data,
223 FileSize: fileSize,
224 FileName: fileName,
225 FileExtension: fileExtension,
226 })
227
228 const vid = uploadResult.Data?.Vid
229 if (!vid) {
230 throw new AppException(ResponseCode.VideoUploadVidNotFound)
231 }
232
233 return vid
234 }
235
236 /**
237 * 批量上传 URL 并获取 vids
238 * 使用下载后流式上传的方式,比 URL 批量拉取更快
239 */
240 async batchUploadUrlsAndGetVids(
241 urlInputs: Array<{ url: string, options?: VideoUrlInput }>,
242 ): Promise<string[]> {
243 const results = await Promise.allSettled(
244 urlInputs.map(({ url, options }, index) =>
245 this.downloadUrlAndUploadAsStream(url, options)
246 .then((vid): UploadSuccess => ({ success: true, vid, url, index }))
247 .catch((error): UploadFailure => {
248 const errorMessage = getErrorMessage(error)
249 this.logger.error({ url, index: index + 1, error: errorMessage }, '批量上传单个视频失败')
250 return { success: false, url, index, error: errorMessage }
251 }),
252 ),
253 )
254
255 const allResults = results
256 .map(r => r.status === 'fulfilled' ? r.value : null)
257 .filter((r): r is UploadResult => r !== null)
258
259 const failedUploads = allResults.filter((r): r is UploadFailure => !r.success)
260
261 if (failedUploads.length > 0) {
262 const failureDetails = failedUploads.map(f =>
263 `第 ${f.index + 1} 个 (${f.url}): ${f.error}`,
264 ).join('\n')
265 this.logger.error({ failedCount: failedUploads.length, details: failureDetails }, '批量上传部分失败')
266 throw new AppException(ResponseCode.VideoUploadFailed, {
267 message: '部分视频上传失败',
268 details: failureDetails,
269 })
270 }
271
272 const vids = allResults
273 .filter((r): r is UploadSuccess => r.success)
274 .map(r => r.vid)
275
276 return vids
277 }
278
279 /**
280 * 上传文件流并获取 vid
281 */
282 async uploadStreamAndGetVid(input: VideoStreamInput): Promise<string> {
283 const content: NodeJS.ReadableStream = Buffer.isBuffer(input.stream)
284 ? Readable.from(input.stream)
285 : input.stream
286
287 const uploadResult = await this.uploadMaterial({
288 SpaceName: this.config.spaceName,
289 Content: content,
290 FileSize: input.fileSize,
291 FileName: input.fileName,
292 FileExtension: input.fileExtension,
293 })
294
295 const vid = uploadResult.Data?.Vid
296 if (!vid) {
297 throw new AppException(ResponseCode.VideoUploadVidNotFound)
298 }
299
300 return vid
301 }
302 }
303
303 lines TYPESCRIPT