import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { DramasService } from '../dramas/dramas.service'; import { CreateCoverDto } from './dto/create-cover.dto'; import { CreateDirectUploadTaskDto } from './dto/create-direct-upload-task.dto'; import { CreateUploadJobDto } from './dto/create-upload-job.dto'; import { CoverRecord, MediaUploadJobRecord, } from './interfaces/media-records.interface'; import { BytePlusMediaProvider } from './providers/byteplus-media.provider'; interface PaginationInput { page: number; pageSize: number; } @Injectable() export class MediaService { private coverSequence = 1; private uploadJobSequence = 1; private readonly covers: CoverRecord[] = []; private readonly uploadJobs: MediaUploadJobRecord[] = []; constructor( private readonly dramasService: DramasService, private readonly bytePlusMediaProvider: BytePlusMediaProvider, ) {} createCover(dto: CreateCoverDto): CoverRecord { const cover: CoverRecord = { id: this.coverSequence++, fileName: dto.fileName, url: dto.url, createdAt: new Date().toISOString(), }; this.covers.push(cover); return cover; } createUploadJob(dto: CreateUploadJobDto): MediaUploadJobRecord { const now = new Date().toISOString(); const job: MediaUploadJobRecord = { id: this.uploadJobSequence++, jobId: dto.jobId, byteplusVid: dto.byteplusVid, status: dto.status, rawResponse: dto.rawResponse, createdAt: now, updatedAt: now, }; this.uploadJobs.push(job); return job; } async createDirectUploadTask( dto: CreateDirectUploadTaskDto, ): Promise { await this.dramasService.getEpisodeOrThrow(dto.episodeId); const now = new Date().toISOString(); const jobId = randomUUID(); const upload = this.bytePlusMediaProvider.createDirectUpload({ jobId, fileName: dto.fileName, contentType: dto.contentType, }); const job: MediaUploadJobRecord = { id: this.uploadJobSequence++, jobId, episodeId: dto.episodeId, status: 'pending', fileName: dto.fileName, contentType: dto.contentType, fileSize: dto.fileSize, bucket: upload.bucket, objectKey: upload.objectKey, uploadUrl: upload.uploadUrl, reviewStatus: 'not_submitted', rawResponse: { headers: upload.headers, expiresAt: upload.expiresAt, }, createdAt: now, updatedAt: now, }; this.uploadJobs.push(job); return job; } async completeDirectUploadTask(jobId: string): Promise { const job = this.getUploadJobOrThrow(jobId); if (!job.objectKey || !job.episodeId) { throw new NotFoundException('Upload task not found'); } const completed = this.bytePlusMediaProvider.completeUpload({ jobId: job.jobId, objectKey: job.objectKey, }); const review = this.bytePlusMediaProvider.submitReview(completed.byteplusVid); Object.assign(job, { byteplusVid: completed.byteplusVid, videoUrl: completed.videoUrl, width: completed.width, height: completed.height, durationSeconds: completed.durationSeconds, status: review.reviewStatus, reviewStatus: review.reviewStatus, reviewMessage: review.reviewMessage, rawResponse: { ...job.rawResponse, upload: completed.rawResponse, review, }, updatedAt: new Date().toISOString(), }); await this.dramasService.attachEpisodeMedia({ episodeId: job.episodeId, byteplusVid: completed.byteplusVid, videoUrl: completed.videoUrl, storageBucket: job.bucket ?? '', objectKey: job.objectKey, durationSeconds: completed.durationSeconds, width: completed.width, height: completed.height, reviewStatus: review.reviewStatus, reviewMessage: review.reviewMessage, }); return job; } async retryReview(jobId: string): Promise { const job = this.getUploadJobOrThrow(jobId); if (!job.byteplusVid || !job.episodeId) { throw new NotFoundException('Upload task not found'); } const review = this.bytePlusMediaProvider.submitReview(job.byteplusVid); job.status = review.reviewStatus; job.reviewStatus = review.reviewStatus; job.reviewMessage = review.reviewMessage; job.updatedAt = new Date().toISOString(); await this.dramasService.updateEpisodeReviewStatus( job.episodeId, review.reviewStatus, review.reviewMessage, ); return job; } async syncReviewStatuses() { const reviewingJobs = this.uploadJobs.filter( (job) => job.byteplusVid && job.episodeId && (job.reviewStatus === 'reviewing' || job.reviewStatus === 'pending'), ); for (const job of reviewingJobs) { const review = this.bytePlusMediaProvider.queryReviewStatus( job.byteplusVid as string, ); job.status = review.reviewStatus; job.reviewStatus = review.reviewStatus; job.reviewMessage = review.reviewMessage; job.updatedAt = new Date().toISOString(); await this.dramasService.updateEpisodeReviewStatus( job.episodeId as number, review.reviewStatus, review.reviewMessage, ); } return { status: 'completed', synced: reviewingJobs.length, }; } getUploadTask(jobId: string): MediaUploadJobRecord { return this.getUploadJobOrThrow(jobId); } listUploadJobs( input: PaginationInput, ): PaginatedResponse { const page = Math.max(input.page, 1); const pageSize = Math.min(Math.max(input.pageSize, 1), 100); const start = (page - 1) * pageSize; const records = [...this.uploadJobs].reverse(); return { list: records.slice(start, start + pageSize), pagination: { page, pageSize, total: records.length, }, }; } private getUploadJobOrThrow(jobId: string) { const job = this.uploadJobs.find((item) => item.jobId === jobId); if (!job) { throw new NotFoundException('Upload task not found'); } return job; } }