| 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 |