| 1 | import type { AccountType } from '@yikart/common' |
| 2 | import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios' |
| 3 | import type { Readable } from 'node:stream' |
| 4 | import type { PlatformMediaPolicy, PlatformMediaRules, PublishMediaMetadata } from '../platforms/platforms.interface' |
| 5 | import type { PublishMediaAdaptationOption } from '../platforms/publish-media-adaptation.schema' |
| 6 | import { createReadStream, createWriteStream, openAsBlob } from 'node:fs' |
| 7 | import { mkdtemp, rm, stat } from 'node:fs/promises' |
| 8 | import { tmpdir } from 'node:os' |
| 9 | import { basename, extname, join } from 'node:path' |
| 10 | import { pipeline } from 'node:stream/promises' |
| 11 | import { Injectable, Logger } from '@nestjs/common' |
| 12 | import { AssetsService, VideoMetadataService } from '@yikart/assets' |
| 13 | import { AppException, ResponseCode } from '@yikart/common' |
| 14 | import { AssetType } from '@yikart/mongodb' |
| 15 | import axios from 'axios' |
| 16 | import sizeOf from 'image-size' |
| 17 | import { lookup, extension as mimeExtension } from 'mime-types' |
| 18 | import sharp from 'sharp' |
| 19 | import { ChannelPlatformException, PlatformErrorCategory, PlatformErrorCauseType } from '../platforms/platforms.exception' |
| 20 | import { PublishMediaType } from '../platforms/platforms.interface' |
| 21 | import { isImageFormatAllowedByMediaRules, listAllowedAdaptationImageFormats, normalizeAdaptationImageFormat, PublishMediaAdaptationImageFormat } from '../platforms/publish-media-adaptation.schema' |
| 22 | import { PublishValidationField, PublishValidationIssue, PublishValidationIssueCode } from '../platforms/publish.schema' |
| 23 | |
| 24 | export interface MediaHttpInput { |
| 25 | platform: AccountType |
| 26 | url: string |
| 27 | endpoint: string |
| 28 | accountId?: string |
| 29 | taskId?: string |
| 30 | platformWorkId?: string |
| 31 | } |
| 32 | |
| 33 | export interface MediaUploadRange { |
| 34 | start: number |
| 35 | end: number |
| 36 | } |
| 37 | |
| 38 | export interface MediaUploadSource { |
| 39 | sizeBytes: number |
| 40 | contentType?: string |
| 41 | filename: string |
| 42 | stream: (range?: MediaUploadRange) => Readable |
| 43 | blob: (range?: MediaUploadRange) => Promise<Blob> |
| 44 | } |
| 45 | |
| 46 | declare module 'axios' { |
| 47 | export interface AxiosRequestConfig { |
| 48 | channelMedia?: MediaHttpInput |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | export interface ImageProbe { |
| 53 | width: number |
| 54 | height: number |
| 55 | format: string |
| 56 | sizeBytes: number |
| 57 | } |
| 58 | |
| 59 | export interface VideoProbe { |
| 60 | width: number |
| 61 | height: number |
| 62 | format: string |
| 63 | durationSec: number |
| 64 | codec: string |
| 65 | sizeBytes: number |
| 66 | } |
| 67 | |
| 68 | interface ValidateVideoOptions { |
| 69 | allowUnknownFormat?: boolean |
| 70 | } |
| 71 | |
| 72 | type PublishMediaProbe |
| 73 | = | { type: PublishMediaType.Image, probe: ImageProbe } |
| 74 | | { type: PublishMediaType.Video, probe: VideoProbe } |
| 75 | |
| 76 | export interface ConvertedImage { |
| 77 | buffer: Buffer |
| 78 | width: number |
| 79 | height: number |
| 80 | format: string |
| 81 | sizeBytes: number |
| 82 | } |
| 83 | |
| 84 | export interface PublishMediaLike { |
| 85 | url: string |
| 86 | metadata?: PublishMediaMetadata |
| 87 | options?: { adaptation?: PublishMediaAdaptationOption } |
| 88 | } |
| 89 | |
| 90 | export interface PublishCoverLike { |
| 91 | url: string |
| 92 | metadata?: PublishMediaMetadata |
| 93 | options?: { adaptation?: PublishMediaAdaptationOption } |
| 94 | } |
| 95 | |
| 96 | export interface PublishContentMediaLike { |
| 97 | title?: string |
| 98 | body?: string |
| 99 | media: PublishMediaLike[] |
| 100 | cover?: PublishCoverLike |
| 101 | } |
| 102 | |
| 103 | export interface PreparedPublishImage { |
| 104 | url: string |
| 105 | width: number |
| 106 | height: number |
| 107 | format: string |
| 108 | sizeBytes: number |
| 109 | } |
| 110 | |
| 111 | export type PublishMediaPreparationCache = Map<string, PreparedPublishImage> |
| 112 | |
| 113 | export interface PreparePublishContentMediaInput<TContent extends PublishContentMediaLike = PublishContentMediaLike> { |
| 114 | userId: string |
| 115 | content: TContent |
| 116 | mediaRules: PlatformMediaRules |
| 117 | mediaPolicy?: PlatformMediaPolicy |
| 118 | cache?: PublishMediaPreparationCache |
| 119 | } |
| 120 | |
| 121 | export interface PreparePublishContentMediaResult<TContent extends PublishContentMediaLike = PublishContentMediaLike> { |
| 122 | content: TContent |
| 123 | issues: PublishValidationIssue[] |
| 124 | } |
| 125 | |
| 126 | @Injectable() |
| 127 | export class MediaService { |
| 128 | private readonly logger = new Logger(MediaService.name) |
| 129 | private readonly imageConversionSourceMaxBytes = 25 * 1024 * 1024 |
| 130 | private readonly http: AxiosInstance |
| 131 | |
| 132 | constructor( |
| 133 | private readonly videoMetadataService: VideoMetadataService, |
| 134 | private readonly assetsService: AssetsService, |
| 135 | ) { |
| 136 | this.http = axios.create({ |
| 137 | timeout: 30000, |
| 138 | maxContentLength: Infinity, |
| 139 | maxBodyLength: Infinity, |
| 140 | }) |
| 141 | this.http.interceptors.response.use( |
| 142 | response => response, |
| 143 | (error: AxiosError) => { |
| 144 | const input = error.config?.channelMedia |
| 145 | if (!input) { |
| 146 | throw error |
| 147 | } |
| 148 | throw this.fromAxiosError(error, input) |
| 149 | }, |
| 150 | ) |
| 151 | } |
| 152 | |
| 153 | async getBuffer(input: MediaHttpInput): Promise<Buffer> { |
| 154 | return this.downloadBuffer(input.url, input) |
| 155 | } |
| 156 | |
| 157 | async getStream(input: MediaHttpInput): Promise<Readable> { |
| 158 | const response = await this.http.get(input.url, { |
| 159 | responseType: 'stream', |
| 160 | channelMedia: input, |
| 161 | }) |
| 162 | return response.data as Readable |
| 163 | } |
| 164 | |
| 165 | async withUploadSource<T>( |
| 166 | input: MediaHttpInput, |
| 167 | handler: (source: MediaUploadSource) => Promise<T>, |
| 168 | ): Promise<T> { |
| 169 | const tempDir = await mkdtemp(join(tmpdir(), 'aitoearn-media-')) |
| 170 | try { |
| 171 | const response = await this.http.get(input.url, { |
| 172 | responseType: 'stream', |
| 173 | channelMedia: input, |
| 174 | }) |
| 175 | const contentType = this.getHeaderString(response.headers['content-type']) |
| 176 | const filename = this.getUploadSourceFilename(input.url, contentType) |
| 177 | const filePath = join(tempDir, filename) |
| 178 | |
| 179 | await pipeline(response.data as Readable, createWriteStream(filePath)) |
| 180 | const fileStats = await stat(filePath) |
| 181 | const source: MediaUploadSource = { |
| 182 | sizeBytes: fileStats.size, |
| 183 | contentType, |
| 184 | filename, |
| 185 | stream: range => createReadStream(filePath, range ? { start: range.start, end: range.end } : {}), |
| 186 | blob: async (range) => { |
| 187 | const blob = await openAsBlob(filePath, contentType ? { type: contentType } : undefined) |
| 188 | return range ? blob.slice(range.start, range.end + 1, blob.type) : blob |
| 189 | }, |
| 190 | } |
| 191 | return await handler(source) |
| 192 | } |
| 193 | finally { |
| 194 | await rm(tempDir, { recursive: true, force: true }) |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | async head(input: MediaHttpInput): Promise<AxiosResponse['headers']> { |
| 199 | const response = await this.http.head(input.url, { |
| 200 | channelMedia: input, |
| 201 | }) |
| 202 | return response.headers |
| 203 | } |
| 204 | |
| 205 | async probeImage(url: string): Promise<ImageProbe> { |
| 206 | const head = await this.http.head(url, { timeout: 15000 }) |
| 207 | const { 'content-length': contentLength } = head.headers |
| 208 | const sizeBytes = Number(contentLength || 0) |
| 209 | const contentType = this.getHeaderString(head.headers['content-type']) |
| 210 | |
| 211 | const buffer = await this.downloadBuffer(url) |
| 212 | |
| 213 | const { width = 0, height = 0, type } = sizeOf(buffer) |
| 214 | const format = type || (contentType ? mimeExtension(contentType) || contentType : undefined) || lookup(url) || 'unknown' |
| 215 | |
| 216 | return { width, height, format, sizeBytes } |
| 217 | } |
| 218 | |
| 219 | async probeVideo(url: string): Promise<VideoProbe> { |
| 220 | const head = await this.http.head(url, { timeout: 15000 }) |
| 221 | const { 'content-length': contentLength } = head.headers |
| 222 | const sizeBytes = Number(contentLength || 0) |
| 223 | const contentType = this.getHeaderString(head.headers['content-type']) |
| 224 | |
| 225 | const metadata = await this.videoMetadataService.probeVideoMetadata(url) |
| 226 | const format = this.getUrlExtension(url) || contentType || 'unknown' |
| 227 | |
| 228 | return { |
| 229 | width: metadata.width, |
| 230 | height: metadata.height, |
| 231 | format, |
| 232 | durationSec: metadata.duration, |
| 233 | codec: 'unknown', |
| 234 | sizeBytes, |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | validateImage(probe: ImageProbe, rules: PlatformMediaRules, pathPrefix: Array<string | number>): PublishValidationIssue[] { |
| 239 | const issues: PublishValidationIssue[] = [] |
| 240 | |
| 241 | if (rules.maxImageSize && probe.sizeBytes > rules.maxImageSize) { |
| 242 | issues.push({ |
| 243 | code: PublishValidationIssueCode.TooBig, |
| 244 | path: [...pathPrefix], |
| 245 | params: { field: PublishValidationField.Image, current: probe.sizeBytes, maximum: rules.maxImageSize, unit: 'bytes' }, |
| 246 | }) |
| 247 | } |
| 248 | |
| 249 | if (rules.imageFormats?.length && !rules.imageFormats.includes(probe.format)) { |
| 250 | issues.push({ |
| 251 | code: PublishValidationIssueCode.UnsupportedFormat, |
| 252 | path: [...pathPrefix], |
| 253 | params: { field: PublishValidationField.Image, format: probe.format, allowed: rules.imageFormats.join(', ') }, |
| 254 | }) |
| 255 | } |
| 256 | |
| 257 | if (rules.minImageWidth && probe.width < rules.minImageWidth) { |
| 258 | issues.push({ |
| 259 | code: PublishValidationIssueCode.TooSmall, |
| 260 | path: [...pathPrefix], |
| 261 | params: { field: PublishValidationField.Image, dimension: 'width', current: probe.width, minimum: rules.minImageWidth, unit: 'pixels' }, |
| 262 | }) |
| 263 | } |
| 264 | if (rules.maxImageWidth && probe.width > rules.maxImageWidth) { |
| 265 | issues.push({ |
| 266 | code: PublishValidationIssueCode.TooBig, |
| 267 | path: [...pathPrefix], |
| 268 | params: { field: PublishValidationField.Image, dimension: 'width', current: probe.width, maximum: rules.maxImageWidth, unit: 'pixels' }, |
| 269 | }) |
| 270 | } |
| 271 | if (rules.minImageHeight && probe.height < rules.minImageHeight) { |
| 272 | issues.push({ |
| 273 | code: PublishValidationIssueCode.TooSmall, |
| 274 | path: [...pathPrefix], |
| 275 | params: { field: PublishValidationField.Image, dimension: 'height', current: probe.height, minimum: rules.minImageHeight, unit: 'pixels' }, |
| 276 | }) |
| 277 | } |
| 278 | if (rules.maxImageHeight && probe.height > rules.maxImageHeight) { |
| 279 | issues.push({ |
| 280 | code: PublishValidationIssueCode.TooBig, |
| 281 | path: [...pathPrefix], |
| 282 | params: { field: PublishValidationField.Image, dimension: 'height', current: probe.height, maximum: rules.maxImageHeight, unit: 'pixels' }, |
| 283 | }) |
| 284 | } |
| 285 | |
| 286 | if (rules.aspectRatio) { |
| 287 | const ratio = probe.width / probe.height |
| 288 | if (rules.aspectRatio.min && ratio < rules.aspectRatio.min) { |
| 289 | issues.push({ |
| 290 | code: PublishValidationIssueCode.TooSmall, |
| 291 | path: [...pathPrefix], |
| 292 | params: { field: PublishValidationField.Image, dimension: 'aspectRatio', current: Math.round(ratio * 100) / 100, minimum: rules.aspectRatio.min }, |
| 293 | }) |
| 294 | } |
| 295 | if (rules.aspectRatio.max && ratio > rules.aspectRatio.max) { |
| 296 | issues.push({ |
| 297 | code: PublishValidationIssueCode.TooBig, |
| 298 | path: [...pathPrefix], |
| 299 | params: { field: PublishValidationField.Image, dimension: 'aspectRatio', current: Math.round(ratio * 100) / 100, maximum: rules.aspectRatio.max }, |
| 300 | }) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | return issues |
| 305 | } |
| 306 | |
| 307 | validateVideo(probe: VideoProbe, rules: PlatformMediaRules, pathPrefix: Array<string | number>, options: ValidateVideoOptions = {}): PublishValidationIssue[] { |
| 308 | const issues: PublishValidationIssue[] = [] |
| 309 | |
| 310 | if (rules.maxVideoSize && probe.sizeBytes > rules.maxVideoSize) { |
| 311 | issues.push({ |
| 312 | code: PublishValidationIssueCode.TooBig, |
| 313 | path: [...pathPrefix], |
| 314 | params: { field: PublishValidationField.Video, current: probe.sizeBytes, maximum: rules.maxVideoSize, unit: 'bytes' }, |
| 315 | }) |
| 316 | } |
| 317 | |
| 318 | const format = this.normalizeVideoFormat(probe.format) |
| 319 | |
| 320 | if (rules.videoFormats?.length && !rules.videoFormats.includes(format) && !(options.allowUnknownFormat && format === 'unknown')) { |
| 321 | issues.push({ |
| 322 | code: PublishValidationIssueCode.UnsupportedFormat, |
| 323 | path: [...pathPrefix], |
| 324 | params: { field: PublishValidationField.Video, format, allowed: rules.videoFormats.join(', ') }, |
| 325 | }) |
| 326 | } |
| 327 | |
| 328 | if (rules.minVideoDuration !== undefined && probe.durationSec < rules.minVideoDuration) { |
| 329 | issues.push({ |
| 330 | code: PublishValidationIssueCode.InvalidDuration, |
| 331 | path: [...pathPrefix], |
| 332 | params: { field: PublishValidationField.Video, current: probe.durationSec, minimum: rules.minVideoDuration, maximum: rules.maxVideoDuration, unit: 'seconds' }, |
| 333 | }) |
| 334 | } |
| 335 | |
| 336 | if (rules.maxVideoDuration !== undefined && probe.durationSec > rules.maxVideoDuration) { |
| 337 | issues.push({ |
| 338 | code: PublishValidationIssueCode.InvalidDuration, |
| 339 | path: [...pathPrefix], |
| 340 | params: { field: PublishValidationField.Video, current: probe.durationSec, minimum: rules.minVideoDuration, maximum: rules.maxVideoDuration, unit: 'seconds' }, |
| 341 | }) |
| 342 | } |
| 343 | |
| 344 | return issues |
| 345 | } |
| 346 | |
| 347 | private getUrlExtension(url: string): string | undefined { |
| 348 | const pathname = this.getUrlPathname(url) |
| 349 | const extension = extname(pathname).replace('.', '').toLowerCase() |
| 350 | return extension || undefined |
| 351 | } |
| 352 | |
| 353 | private getUrlPathname(url: string): string { |
| 354 | try { |
| 355 | return new URL(url).pathname |
| 356 | } |
| 357 | catch { |
| 358 | return url.split(/[?#]/)[0] |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | private normalizeVideoFormat(format: string): string { |
| 363 | return (mimeExtension(format) || format).replace('.', '').toLowerCase() |
| 364 | } |
| 365 | |
| 366 | async validateMedia(content: PublishContentMediaLike, rules: PlatformMediaRules): Promise<PublishValidationIssue[]> { |
| 367 | const issues: PublishValidationIssue[] = [] |
| 368 | |
| 369 | const videoExtensions = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'flv', 'wmv', 'rmvb', '3gp'] |
| 370 | for (const [i, m] of content.media.entries()) { |
| 371 | const pathPrefix = ['content', 'media', i] |
| 372 | const mediaType = this.getMediaType(m) |
| 373 | const urlExtension = this.getUrlExtension(m.url) |
| 374 | if (mediaType === PublishMediaType.Video || videoExtensions.includes(urlExtension ?? '')) { |
| 375 | try { |
| 376 | const probe = await this.probeVideo(m.url) |
| 377 | issues.push(...this.validateVideo(probe, rules, pathPrefix, { |
| 378 | allowUnknownFormat: mediaType === PublishMediaType.Video && !urlExtension, |
| 379 | })) |
| 380 | } |
| 381 | catch (err) { |
| 382 | this.logger.warn(err, `Failed to probe video ${m.url}`) |
| 383 | issues.push({ |
| 384 | code: PublishValidationIssueCode.InvalidUrl, |
| 385 | path: pathPrefix, |
| 386 | params: { field: PublishValidationField.Video, url: m.url }, |
| 387 | }) |
| 388 | } |
| 389 | } |
| 390 | else { |
| 391 | try { |
| 392 | const probe = await this.probeImage(m.url) |
| 393 | issues.push(...this.validateImage(probe, rules, pathPrefix)) |
| 394 | } |
| 395 | catch (err) { |
| 396 | this.logger.warn(err, `Failed to probe image ${m.url}`) |
| 397 | issues.push({ |
| 398 | code: PublishValidationIssueCode.InvalidUrl, |
| 399 | path: pathPrefix, |
| 400 | params: { field: PublishValidationField.Image, url: m.url }, |
| 401 | }) |
| 402 | } |
| 403 | } |
| 404 | } |
| 405 | if (content.cover?.url) { |
| 406 | const pathPrefix = ['content', 'cover'] |
| 407 | try { |
| 408 | const probe = await this.probeImage(content.cover.url) |
| 409 | issues.push(...this.validateImage(probe, rules, pathPrefix)) |
| 410 | } |
| 411 | catch (err) { |
| 412 | this.logger.warn(err, `Failed to probe cover ${content.cover.url}`) |
| 413 | issues.push({ |
| 414 | code: PublishValidationIssueCode.InvalidUrl, |
| 415 | path: pathPrefix, |
| 416 | params: { field: PublishValidationField.Image, url: content.cover.url }, |
| 417 | }) |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | return issues |
| 422 | } |
| 423 | |
| 424 | async preparePublishContentMedia<TContent extends PublishContentMediaLike>( |
| 425 | input: PreparePublishContentMediaInput<TContent>, |
| 426 | ): Promise<PreparePublishContentMediaResult<TContent>> { |
| 427 | const cache = input.cache ?? new Map<string, PreparedPublishImage>() |
| 428 | const issues: PublishValidationIssue[] = [] |
| 429 | const media: PublishMediaLike[] = [] |
| 430 | |
| 431 | for (const [index, item] of input.content.media.entries()) { |
| 432 | const path = ['content', 'media', index] |
| 433 | const normalized = await this.normalizePublishMedia(item, path) |
| 434 | issues.push(...normalized.issues) |
| 435 | if (normalized.issues.length || normalized.media?.type !== PublishMediaType.Image) { |
| 436 | media.push(this.stripPublishMediaOptions(normalized.item)) |
| 437 | continue |
| 438 | } |
| 439 | |
| 440 | const prepared = await this.preparePublishImage({ |
| 441 | userId: input.userId, |
| 442 | item: normalized.item, |
| 443 | probe: normalized.media.probe, |
| 444 | path, |
| 445 | field: PublishValidationField.Image, |
| 446 | mediaRules: input.mediaRules, |
| 447 | mediaPolicy: input.mediaPolicy, |
| 448 | cache, |
| 449 | }) |
| 450 | issues.push(...prepared.issues) |
| 451 | media.push(prepared.item) |
| 452 | } |
| 453 | |
| 454 | let cover = input.content.cover |
| 455 | if (input.content.cover?.url) { |
| 456 | const normalized = await this.normalizePublishCover(input.content.cover, ['content', 'cover']) |
| 457 | issues.push(...normalized.issues) |
| 458 | if (normalized.issues.length || !normalized.probe) { |
| 459 | cover = normalized.item |
| 460 | } |
| 461 | else { |
| 462 | const prepared = await this.preparePublishImage({ |
| 463 | userId: input.userId, |
| 464 | item: normalized.item, |
| 465 | probe: normalized.probe, |
| 466 | path: ['content', 'cover'], |
| 467 | field: PublishValidationField.Cover, |
| 468 | mediaRules: input.mediaRules, |
| 469 | mediaPolicy: input.mediaPolicy, |
| 470 | cache, |
| 471 | }) |
| 472 | issues.push(...prepared.issues) |
| 473 | cover = prepared.item |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | return { |
| 478 | content: issues.length > 0 |
| 479 | ? input.content |
| 480 | : { |
| 481 | ...input.content, |
| 482 | media, |
| 483 | cover, |
| 484 | } as TContent, |
| 485 | issues, |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | private async normalizePublishMedia( |
| 490 | item: PublishMediaLike, |
| 491 | path: Array<string | number>, |
| 492 | ): Promise<{ item: PublishMediaLike, media?: PublishMediaProbe, issues: PublishValidationIssue[] }> { |
| 493 | try { |
| 494 | const media = await this.probePublishMedia(item) |
| 495 | return { |
| 496 | item: { |
| 497 | ...item, |
| 498 | metadata: this.getProbeMetadata(media), |
| 499 | }, |
| 500 | media, |
| 501 | issues: [], |
| 502 | } |
| 503 | } |
| 504 | catch (err) { |
| 505 | this.logger.warn(err, `Failed to probe media ${item.url}`) |
| 506 | return { |
| 507 | item: this.stripPublishMediaOptions(item), |
| 508 | issues: [{ |
| 509 | code: PublishValidationIssueCode.InvalidUrl, |
| 510 | path, |
| 511 | params: { field: PublishValidationField.Media, url: item.url }, |
| 512 | }], |
| 513 | } |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | private async normalizePublishCover( |
| 518 | item: PublishCoverLike, |
| 519 | path: Array<string | number>, |
| 520 | ): Promise<{ item: PublishCoverLike, probe?: ImageProbe, issues: PublishValidationIssue[] }> { |
| 521 | try { |
| 522 | const probe = await this.probeImage(item.url) |
| 523 | return { |
| 524 | item: { |
| 525 | ...item, |
| 526 | metadata: this.getImageMetadata(probe), |
| 527 | }, |
| 528 | probe, |
| 529 | issues: [], |
| 530 | } |
| 531 | } |
| 532 | catch (err) { |
| 533 | this.logger.warn(err, `Failed to probe cover ${item.url}`) |
| 534 | return { |
| 535 | item: this.stripPublishMediaOptions(item), |
| 536 | issues: [{ |
| 537 | code: PublishValidationIssueCode.InvalidUrl, |
| 538 | path, |
| 539 | params: { field: PublishValidationField.Cover, url: item.url }, |
| 540 | }], |
| 541 | } |
| 542 | } |
| 543 | } |
| 544 | |
| 545 | private async probePublishMedia(item: PublishMediaLike): Promise<PublishMediaProbe> { |
| 546 | const type = this.getMediaType(item) |
| 547 | if (type === PublishMediaType.Video) { |
| 548 | return { type: PublishMediaType.Video, probe: await this.probeVideo(item.url) } |
| 549 | } |
| 550 | if (type === PublishMediaType.Image) { |
| 551 | return { type: PublishMediaType.Image, probe: await this.probeImage(item.url) } |
| 552 | } |
| 553 | |
| 554 | const extension = this.getUrlExtension(item.url) |
| 555 | if (this.isVideoExtension(extension)) { |
| 556 | return { type: PublishMediaType.Video, probe: await this.probeVideo(item.url) } |
| 557 | } |
| 558 | if (this.isImageExtension(extension)) { |
| 559 | return { type: PublishMediaType.Image, probe: await this.probeImage(item.url) } |
| 560 | } |
| 561 | |
| 562 | const contentType = await this.getRemoteContentType(item.url) |
| 563 | if (contentType?.startsWith('video/')) { |
| 564 | return { type: PublishMediaType.Video, probe: await this.probeVideo(item.url) } |
| 565 | } |
| 566 | if (contentType?.startsWith('image/')) { |
| 567 | return { type: PublishMediaType.Image, probe: await this.probeImage(item.url) } |
| 568 | } |
| 569 | |
| 570 | try { |
| 571 | return { type: PublishMediaType.Video, probe: await this.probeVideo(item.url) } |
| 572 | } |
| 573 | catch { |
| 574 | return { type: PublishMediaType.Image, probe: await this.probeImage(item.url) } |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | async convertImage(url: string, policy: PlatformMediaPolicy, maxSourceBytes?: number): Promise<ConvertedImage | null> { |
| 579 | if (!policy.maxImageWidth && !policy.maxImageHeight && !policy.imageConvertFormat) { |
| 580 | return null |
| 581 | } |
| 582 | |
| 583 | const sourceBuffer = await this.downloadBuffer(url, undefined, maxSourceBytes) |
| 584 | let image = sharp(sourceBuffer) |
| 585 | |
| 586 | const metadata = await image.metadata() |
| 587 | const currentWidth = metadata.width ?? 0 |
| 588 | const currentHeight = metadata.height ?? 0 |
| 589 | |
| 590 | let targetWidth = currentWidth |
| 591 | let targetHeight = currentHeight |
| 592 | if (policy.maxImageWidth && currentWidth > policy.maxImageWidth) { |
| 593 | const ratio = policy.maxImageWidth / currentWidth |
| 594 | targetWidth = policy.maxImageWidth |
| 595 | targetHeight = Math.round(currentHeight * ratio) |
| 596 | } |
| 597 | if (policy.maxImageHeight && targetHeight > policy.maxImageHeight) { |
| 598 | const ratio = policy.maxImageHeight / targetHeight |
| 599 | targetHeight = policy.maxImageHeight |
| 600 | targetWidth = Math.round(targetWidth * ratio) |
| 601 | } |
| 602 | |
| 603 | if (targetWidth !== currentWidth || targetHeight !== currentHeight) { |
| 604 | image = image.resize(targetWidth, targetHeight, { fit: 'inside', withoutEnlargement: true }) |
| 605 | } |
| 606 | |
| 607 | const format = policy.imageConvertFormat ?? metadata.format ?? 'jpeg' |
| 608 | const quality = policy.imageQuality ?? 90 |
| 609 | if (format === 'png') { |
| 610 | image = image.png({ quality }) |
| 611 | } |
| 612 | else if (format === 'webp') { |
| 613 | image = image.webp({ quality }) |
| 614 | } |
| 615 | else { |
| 616 | image = image.jpeg({ quality }) |
| 617 | } |
| 618 | |
| 619 | const convertedBuffer = await image.toBuffer() |
| 620 | |
| 621 | return { |
| 622 | buffer: convertedBuffer, |
| 623 | width: targetWidth, |
| 624 | height: targetHeight, |
| 625 | format, |
| 626 | sizeBytes: convertedBuffer.length, |
| 627 | } |
| 628 | } |
| 629 | |
| 630 | private async preparePublishImage<TItem extends PublishMediaLike | PublishCoverLike>(input: { |
| 631 | userId: string |
| 632 | item: TItem |
| 633 | probe: ImageProbe |
| 634 | path: Array<string | number> |
| 635 | field: PublishValidationField |
| 636 | mediaRules: PlatformMediaRules |
| 637 | mediaPolicy?: PlatformMediaPolicy |
| 638 | cache: PublishMediaPreparationCache |
| 639 | }): Promise<{ item: TItem, issues: PublishValidationIssue[] }> { |
| 640 | const item = this.stripPublishMediaOptions(input.item) |
| 641 | const imageFormat = input.item.options?.adaptation?.imageFormat |
| 642 | if (!imageFormat || imageFormat === PublishMediaAdaptationImageFormat.Off) { |
| 643 | return { item, issues: [] } |
| 644 | } |
| 645 | |
| 646 | if (!this.isHttpUrl(input.item.url)) { |
| 647 | return { |
| 648 | item, |
| 649 | issues: [{ |
| 650 | code: PublishValidationIssueCode.InvalidUrl, |
| 651 | path: input.path, |
| 652 | params: { field: input.field, url: input.item.url }, |
| 653 | }], |
| 654 | } |
| 655 | } |
| 656 | |
| 657 | const sourceFormat = normalizeAdaptationImageFormat(mimeExtension(input.probe.format) || input.probe.format) |
| 658 | const needsResize = Boolean(input.mediaPolicy?.maxImageWidth || input.mediaPolicy?.maxImageHeight) |
| 659 | const targetFormat = this.resolveTargetImageFormat(imageFormat, sourceFormat, input.mediaRules, needsResize) |
| 660 | if (!targetFormat) { |
| 661 | return { |
| 662 | item, |
| 663 | issues: [{ |
| 664 | code: PublishValidationIssueCode.InvalidOption, |
| 665 | path: [...input.path, 'options', 'adaptation', 'imageFormat'], |
| 666 | params: { |
| 667 | field: PublishValidationField.Option, |
| 668 | current: imageFormat, |
| 669 | allowed: listAllowedAdaptationImageFormats(input.mediaRules).join(', '), |
| 670 | }, |
| 671 | }], |
| 672 | } |
| 673 | } |
| 674 | if (sourceFormat === targetFormat && !needsResize) { |
| 675 | return { item, issues: [] } |
| 676 | } |
| 677 | |
| 678 | const cacheKey = this.getPublishImageCacheKey(input.item.url, targetFormat, input.mediaPolicy) |
| 679 | const cached = input.cache.get(cacheKey) |
| 680 | if (cached) { |
| 681 | const issues = this.validatePreparedImageSize(cached.sizeBytes, input.mediaRules, input.path, input.field) |
| 682 | return { |
| 683 | item: issues.length ? item : { ...item, url: cached.url, metadata: this.getPreparedImageMetadata(cached) } as TItem, |
| 684 | issues, |
| 685 | } |
| 686 | } |
| 687 | |
| 688 | const sourceSizeBytes = await this.getRemoteContentLength(input.item.url) |
| 689 | if (sourceSizeBytes && sourceSizeBytes > this.imageConversionSourceMaxBytes) { |
| 690 | return { |
| 691 | item, |
| 692 | issues: [{ |
| 693 | code: PublishValidationIssueCode.TooBig, |
| 694 | path: input.path, |
| 695 | params: { field: input.field, current: sourceSizeBytes, maximum: this.imageConversionSourceMaxBytes, unit: 'bytes' }, |
| 696 | }], |
| 697 | } |
| 698 | } |
| 699 | |
| 700 | let converted: ConvertedImage | null |
| 701 | try { |
| 702 | converted = await this.convertImage(input.item.url, { |
| 703 | ...input.mediaPolicy, |
| 704 | imageConvertFormat: targetFormat, |
| 705 | }, this.imageConversionSourceMaxBytes) |
| 706 | } |
| 707 | catch (err) { |
| 708 | this.logger.warn(err, `Failed to convert image ${input.item.url}`) |
| 709 | return { |
| 710 | item, |
| 711 | issues: [{ |
| 712 | code: PublishValidationIssueCode.InvalidUrl, |
| 713 | path: input.path, |
| 714 | params: { field: input.field, url: input.item.url }, |
| 715 | }], |
| 716 | } |
| 717 | } |
| 718 | |
| 719 | if (!converted) { |
| 720 | return { item, issues: [] } |
| 721 | } |
| 722 | |
| 723 | const sizeIssues = this.validatePreparedImageSize(converted.sizeBytes, input.mediaRules, input.path, input.field) |
| 724 | if (sizeIssues.length) { |
| 725 | return { item, issues: sizeIssues } |
| 726 | } |
| 727 | |
| 728 | const upload = await this.assetsService.uploadFromBuffer(input.userId, converted.buffer, { |
| 729 | type: AssetType.PublishMedia, |
| 730 | mimeType: this.getImageMimeType(targetFormat), |
| 731 | metadata: { |
| 732 | width: converted.width, |
| 733 | height: converted.height, |
| 734 | }, |
| 735 | }) |
| 736 | const prepared = { |
| 737 | url: upload.url, |
| 738 | width: converted.width, |
| 739 | height: converted.height, |
| 740 | format: targetFormat, |
| 741 | sizeBytes: converted.sizeBytes, |
| 742 | } |
| 743 | input.cache.set(cacheKey, prepared) |
| 744 | |
| 745 | return { |
| 746 | item: { ...item, url: upload.url, metadata: this.getPreparedImageMetadata(prepared) } as TItem, |
| 747 | issues: [], |
| 748 | } |
| 749 | } |
| 750 | |
| 751 | private resolveTargetImageFormat( |
| 752 | imageFormat: PublishMediaAdaptationImageFormat, |
| 753 | sourceFormat: string | undefined, |
| 754 | rules: PlatformMediaRules, |
| 755 | needsResize: boolean, |
| 756 | ): PublishMediaAdaptationImageFormat | undefined { |
| 757 | if (imageFormat === PublishMediaAdaptationImageFormat.Auto) { |
| 758 | if (!needsResize && isImageFormatAllowedByMediaRules(sourceFormat, rules)) { |
| 759 | return sourceFormat as PublishMediaAdaptationImageFormat |
| 760 | } |
| 761 | return listAllowedAdaptationImageFormats(rules)[0] |
| 762 | } |
| 763 | return listAllowedAdaptationImageFormats(rules).includes(imageFormat) |
| 764 | ? imageFormat |
| 765 | : undefined |
| 766 | } |
| 767 | |
| 768 | private stripPublishMediaOptions<TItem extends PublishMediaLike | PublishCoverLike>(item: TItem): TItem { |
| 769 | if (!item.options) { |
| 770 | return item |
| 771 | } |
| 772 | const options = { ...item.options } |
| 773 | delete options.adaptation |
| 774 | if (Object.keys(options).length) { |
| 775 | return { ...item, options } as TItem |
| 776 | } |
| 777 | const rest = { ...item } |
| 778 | delete rest.options |
| 779 | return rest as TItem |
| 780 | } |
| 781 | |
| 782 | private validatePreparedImageSize( |
| 783 | sizeBytes: number, |
| 784 | rules: PlatformMediaRules, |
| 785 | path: Array<string | number>, |
| 786 | field: PublishValidationField, |
| 787 | ): PublishValidationIssue[] { |
| 788 | if (!rules.maxImageSize || sizeBytes <= rules.maxImageSize) { |
| 789 | return [] |
| 790 | } |
| 791 | return [{ |
| 792 | code: PublishValidationIssueCode.TooBig, |
| 793 | path, |
| 794 | params: { field, current: sizeBytes, maximum: rules.maxImageSize, unit: 'bytes' }, |
| 795 | }] |
| 796 | } |
| 797 | |
| 798 | private getPublishImageCacheKey(url: string, targetFormat: PublishMediaAdaptationImageFormat, policy?: PlatformMediaPolicy): string { |
| 799 | return [ |
| 800 | url, |
| 801 | targetFormat, |
| 802 | policy?.maxImageWidth ?? '', |
| 803 | policy?.maxImageHeight ?? '', |
| 804 | policy?.imageQuality ?? '', |
| 805 | ].join('|') |
| 806 | } |
| 807 | |
| 808 | private getImageMimeType(format: PublishMediaAdaptationImageFormat): string { |
| 809 | if (format === PublishMediaAdaptationImageFormat.Png) { |
| 810 | return 'image/png' |
| 811 | } |
| 812 | if (format === PublishMediaAdaptationImageFormat.Webp) { |
| 813 | return 'image/webp' |
| 814 | } |
| 815 | return 'image/jpeg' |
| 816 | } |
| 817 | |
| 818 | private getProbeMetadata(media: PublishMediaProbe): PublishMediaMetadata { |
| 819 | return media.type === PublishMediaType.Video |
| 820 | ? this.getVideoMetadata(media.probe) |
| 821 | : this.getImageMetadata(media.probe) |
| 822 | } |
| 823 | |
| 824 | private getImageMetadata(probe: ImageProbe): PublishMediaMetadata { |
| 825 | return { |
| 826 | type: PublishMediaType.Image, |
| 827 | width: probe.width, |
| 828 | height: probe.height, |
| 829 | format: probe.format, |
| 830 | sizeBytes: probe.sizeBytes, |
| 831 | } |
| 832 | } |
| 833 | |
| 834 | private getVideoMetadata(probe: VideoProbe): PublishMediaMetadata { |
| 835 | return { |
| 836 | type: PublishMediaType.Video, |
| 837 | width: probe.width, |
| 838 | height: probe.height, |
| 839 | durationSec: probe.durationSec, |
| 840 | codec: probe.codec, |
| 841 | format: probe.format, |
| 842 | sizeBytes: probe.sizeBytes, |
| 843 | } |
| 844 | } |
| 845 | |
| 846 | private getPreparedImageMetadata(prepared: PreparedPublishImage): PublishMediaMetadata { |
| 847 | return { |
| 848 | type: PublishMediaType.Image, |
| 849 | width: prepared.width, |
| 850 | height: prepared.height, |
| 851 | format: prepared.format, |
| 852 | sizeBytes: prepared.sizeBytes, |
| 853 | } |
| 854 | } |
| 855 | |
| 856 | private async getRemoteContentType(url: string): Promise<string | undefined> { |
| 857 | try { |
| 858 | const response = await this.http.head(url, { timeout: 15000 }) |
| 859 | return this.getHeaderString(response.headers['content-type'])?.toLowerCase() |
| 860 | } |
| 861 | catch { |
| 862 | return undefined |
| 863 | } |
| 864 | } |
| 865 | |
| 866 | private async getRemoteContentLength(url: string): Promise<number | undefined> { |
| 867 | try { |
| 868 | const response = await this.http.head(url, { timeout: 15000 }) |
| 869 | const contentLength = response.headers['content-length'] |
| 870 | const value = Array.isArray(contentLength) ? contentLength[0] : contentLength |
| 871 | const size = Number(value) |
| 872 | return Number.isFinite(size) && size > 0 ? size : undefined |
| 873 | } |
| 874 | catch { |
| 875 | return undefined |
| 876 | } |
| 877 | } |
| 878 | |
| 879 | private isHttpUrl(url: string): boolean { |
| 880 | try { |
| 881 | const protocol = new URL(url).protocol |
| 882 | return protocol === 'http:' || protocol === 'https:' |
| 883 | } |
| 884 | catch { |
| 885 | return false |
| 886 | } |
| 887 | } |
| 888 | |
| 889 | private isVideoExtension(extension: string | undefined): boolean { |
| 890 | return ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'flv', 'wmv', 'rmvb', '3gp'].includes(extension ?? '') |
| 891 | } |
| 892 | |
| 893 | private isImageExtension(extension: string | undefined): boolean { |
| 894 | return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'].includes(extension ?? '') |
| 895 | } |
| 896 | |
| 897 | private async downloadBuffer(url: string, input?: MediaHttpInput, maxBytes?: number): Promise<Buffer> { |
| 898 | const response = await this.http.get<ArrayBuffer | Buffer>(url, { |
| 899 | responseType: 'arraybuffer', |
| 900 | maxContentLength: maxBytes ?? Infinity, |
| 901 | maxBodyLength: maxBytes ?? Infinity, |
| 902 | ...(input ? { channelMedia: input } : {}), |
| 903 | }) |
| 904 | const buffer = Buffer.isBuffer(response.data) |
| 905 | ? response.data |
| 906 | : Buffer.from(response.data) |
| 907 | if (maxBytes && buffer.length > maxBytes) { |
| 908 | if (!input) { |
| 909 | throw new AppException(ResponseCode.ChannelPlatformMediaProcessingFailed, { |
| 910 | reasonCode: 'media_exceeds_max_bytes', |
| 911 | maxBytes, |
| 912 | sizeBytes: buffer.length, |
| 913 | }) |
| 914 | } |
| 915 | throw new ChannelPlatformException({ |
| 916 | code: ResponseCode.ChannelPlatformMediaProcessingFailed, |
| 917 | platform: input.platform, |
| 918 | category: PlatformErrorCategory.MediaProcessingFailed, |
| 919 | context: { |
| 920 | endpoint: input.endpoint, |
| 921 | taskId: input.taskId, |
| 922 | accountId: input.accountId, |
| 923 | platformWorkId: input.platformWorkId, |
| 924 | }, |
| 925 | cause: { |
| 926 | type: PlatformErrorCauseType.Platform, |
| 927 | platformMessage: 'Media exceeds maximum byte size', |
| 928 | raw: { |
| 929 | reasonCode: 'media_exceeds_max_bytes', |
| 930 | maxBytes, |
| 931 | sizeBytes: buffer.length, |
| 932 | }, |
| 933 | }, |
| 934 | retryable: false, |
| 935 | }) |
| 936 | } |
| 937 | return buffer |
| 938 | } |
| 939 | |
| 940 | private getUploadSourceFilename(url: string, contentType?: string): string { |
| 941 | const filename = basename(this.getUrlPathname(url)) |
| 942 | if (filename && filename !== '.' && filename !== '/') { |
| 943 | return filename |
| 944 | } |
| 945 | const extension = contentType ? mimeExtension(contentType) : undefined |
| 946 | return extension ? `media.${extension}` : 'media' |
| 947 | } |
| 948 | |
| 949 | private getMediaType(media: { metadata?: { type?: unknown } }): PublishMediaType | undefined { |
| 950 | const type = media.metadata?.type |
| 951 | return type === PublishMediaType.Image || type === PublishMediaType.Video ? type : undefined |
| 952 | } |
| 953 | |
| 954 | private getHeaderString(value: unknown): string | undefined { |
| 955 | if (Array.isArray(value)) { |
| 956 | return typeof value[0] === 'string' ? value[0] : undefined |
| 957 | } |
| 958 | return typeof value === 'string' ? value : undefined |
| 959 | } |
| 960 | |
| 961 | private fromAxiosError(error: AxiosError, input: MediaHttpInput): ChannelPlatformException { |
| 962 | const response = error.response |
| 963 | |
| 964 | return new ChannelPlatformException({ |
| 965 | code: ResponseCode.ChannelPlatformMediaProcessingFailed, |
| 966 | platform: input.platform, |
| 967 | category: response ? PlatformErrorCategory.MediaUnavailable : PlatformErrorCategory.Network, |
| 968 | context: { |
| 969 | endpoint: input.endpoint, |
| 970 | taskId: input.taskId, |
| 971 | accountId: input.accountId, |
| 972 | platformWorkId: input.platformWorkId, |
| 973 | metadata: { url: input.url }, |
| 974 | }, |
| 975 | cause: { |
| 976 | type: response ? PlatformErrorCauseType.Http : PlatformErrorCauseType.Network, |
| 977 | httpStatus: response?.status, |
| 978 | platformMessage: error.message, |
| 979 | raw: response?.data ?? error.toJSON(), |
| 980 | }, |
| 981 | retryable: !response || response.status === 408 || response.status === 429 || response.status >= 500, |
| 982 | }) |
| 983 | } |
| 984 | } |
| 985 |