import { Injectable, NotFoundException, BadRequestException, Inject, Logger, } from '@nestjs/common'; import type { MultipartFile } from '@fastify/multipart'; import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service'; import { CacheSyncService } from '../../../cache-sync/cache-sync.service'; import { CacheEntityType } from '../../../cache-sync/cache-sync.types'; import { MediaManagerService } from '@box/core/media-manager/media-manager.service'; import type { StorageStrategy } from '@box/core/media-manager/types'; import { randomUUID } from 'crypto'; import { VideoMediaListQueryDto, UpdateVideoMediaManageDto, UpdateVideoMediaStatusDto, BatchUpdateVideoMediaStatusDto, } from './video-media.dto'; import { MEDIA_STORAGE_STRATEGY } from '../../../shared/tokens'; type MongoAggregateResult = { cursor?: { firstBatch?: any[]; }; }; @Injectable() export class VideoMediaService { private readonly logger = new Logger(VideoMediaService.name); private isBackfillingVid = false; constructor( private readonly prisma: MongoPrismaService, private readonly cacheSyncService: CacheSyncService, private readonly mediaManagerService: MediaManagerService, @Inject(MEDIA_STORAGE_STRATEGY) private readonly mediaStorageStrategy: StorageStrategy, ) {} // helper to generate next vid private async generateNextVid(): Promise { const last = await this.prisma.videoMedia.findFirst({ where: { vid: { isSet: true } }, orderBy: { vid: 'desc' }, select: { vid: true }, }); return (last?.vid ?? 0) + 1; } // backfill vid for videoMedia documents without one private async backfillVids(): Promise { if (this.isBackfillingVid) { this.logger.warn('backfillVids is already running, skipping.'); return; } this.isBackfillingVid = true; this.logger.log('Starting backfill of vid...'); try { const videosWithoutVid = await this.prisma.videoMedia.findMany({ where: { OR: [{ vid: { isSet: false } }, { vid: null }], }, orderBy: { createdAt: 'asc' }, select: { id: true }, }); if (videosWithoutVid.length === 0) { this.logger.log('No videos need backfilling vid.'); return; } this.logger.log( `Found ${videosWithoutVid.length} videos without vid. Starting backfill...`, ); let nextVid = await this.generateNextVid(); for (const video of videosWithoutVid) { let assigned = false; while (!assigned) { try { await this.prisma.videoMedia.update({ where: { id: video.id }, data: { vid: nextVid }, }); this.logger.log( `Backfilled vid ${nextVid} for videoMedia id ${video.id}`, ); nextVid += 1; assigned = true; } catch (e: any) { // Unique constraint violation → retry with a fresh number if (e?.code === 'P2002') { this.logger.warn(`Duplicate vid ${nextVid}, retrying...`); nextVid = await this.generateNextVid(); } else { throw e; } } } } this.logger.log( `Backfilled ${videosWithoutVid.length} vids successfully.`, ); } finally { this.isBackfillingVid = false; this.logger.log('Finished backfillVids process.'); } } async findAll(query: VideoMediaListQueryDto): Promise { // ensure vids are backfilled await this.backfillVids().catch((err) => this.logger.error('Backfill vid failed', err?.stack ?? String(err)), ); const page = query.page ?? 1; const pageSize = query.size ?? 20; const skip = (page - 1) * pageSize; const take = pageSize; const baseWhere = this.buildVideoListBaseFilter(query); const keyword = query.keyword?.trim(); let total: number; let rows: any[]; if (!keyword) { [total, rows] = await Promise.all([ this.prisma.videoMedia.count({ where: baseWhere }), this.prisma.videoMedia.findMany({ where: baseWhere, skip, take, orderBy: { addedTime: 'desc' }, }), ]); } else { const regexSource = this.escapeRegex(keyword); const matchFilter = this.buildKeywordMatchFilter(baseWhere, regexSource); // Prisma Mongo cannot express regex searches inside array elements, so we fall back to a raw aggregate that uses sanitizedSecondTags. const countRes = (await this.prisma.$runCommandRaw({ aggregate: 'videoMedia', pipeline: [{ $match: matchFilter }, { $count: 'total' }], cursor: {}, })) as unknown as MongoAggregateResult; total = Number(countRes.cursor.firstBatch?.[0]?.total ?? 0); const dataRes = (await this.prisma.$runCommandRaw({ aggregate: 'videoMedia', pipeline: [ { $match: matchFilter }, { $sort: { addedTime: -1 } }, { $skip: skip }, { $limit: take }, ], cursor: {}, })) as unknown as MongoAggregateResult; rows = (dataRes.cursor.firstBatch ?? []).map((doc: any) => ({ ...doc, id: doc.id ?? doc._id, })); } return { total, page, pageSize, items: rows.map((row) => ({ id: row.id, vid: row.vid ?? null, title: row.title, filename: row.filename, preFileName: row.preFileName, fieldNameFs: row.fieldNameFs, videoTime: row.videoTime, size: row.size?.toString?.() ?? '0', coverImg: row.coverImg ?? '', categoryIds: row.categoryIds ?? [], tagIds: row.tagIds ?? [], listStatus: row.listStatus ?? 0, editedAt: Number(row.editedAt ?? 0), updatedAt: row.updatedAt ?? null, tags: row.tags ?? [], tagsFlat: row.tagsFlat ?? '', secondTags: row.secondTags ?? [], sanitizedSecondTags: row.sanitizedSecondTags ?? [], })), }; } private buildVideoListBaseFilter( query: VideoMediaListQueryDto, ): Record { const where: Record = {}; if (typeof query.listStatus === 'number') { where.listStatus = query.listStatus; } // filter by tag, by videoMedia.secondTags[] if (typeof query.tag === 'string' && query.tag.trim() !== '') { where.secondTags = { has: query.tag.trim(), }; } if ( typeof query.updatedFrom === 'number' || typeof query.updatedTo === 'number' ) { const updatedAt: Record = {}; if (typeof query.updatedFrom === 'number') { // epoch seconds → milliseconds → Date updatedAt.gte = new Date(query.updatedFrom * 1000); } if (typeof query.updatedTo === 'number') { updatedAt.lte = new Date(query.updatedTo * 1000); } where.updatedAt = updatedAt; } return where; } private buildKeywordMatchFilter( baseFilter: Record, regexSource: string, ): Record { const matchFilter = { ...baseFilter }; if (Array.isArray(matchFilter.$and)) { matchFilter.$and = [...matchFilter.$and]; } const keywordClause = { $or: [ { title: { $regex: regexSource, $options: 'i', }, }, { sanitizedSecondTags: { $elemMatch: { $regex: regexSource, $options: 'i', }, }, }, ], }; matchFilter.$and = matchFilter.$and ?? []; matchFilter.$and.push(keywordClause); return matchFilter; } private escapeRegex(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } async findOne(id: string): Promise { const video = await this.prisma.videoMedia.findUnique({ where: { id }, }); if (!video) { throw new NotFoundException('Video not found'); } const [category, tags] = await Promise.all([ video.categoryIds && video.categoryIds.length > 0 ? this.prisma.category.findUnique({ where: { id: video.categoryIds[0] }, }) : null, video.tagIds && video.tagIds.length ? this.prisma.tag.findMany({ where: { id: { in: video.tagIds } }, orderBy: { seq: 'asc' }, }) : [], ]); return { id: video.id, title: video.title, filename: video.filename, preFileName: video.preFileName, fieldNameFs: video.fieldNameFs, videoTime: video.videoTime, size: video.size?.toString?.() ?? '0', coverImg: video.coverImg ?? '', type: video.type, formatType: video.formatType, contentType: video.contentType, country: video.country, status: video.status, desc: video.desc ?? '', categoryIds: video.categoryIds ?? [], tagIds: video.tagIds ?? [], listStatus: video.listStatus ?? 0, editedAt: Number(video.editedAt ?? 0), updatedAt: video.updatedAt ?? null, categoryName: category?.name ?? null, // Existing DTO: tags as {id, name}[] tags: video.tags ?? [], tagsFlat: video.tagsFlat ?? '', secondTags: video.secondTags ?? [], }; } async updateManage(id: string, dto: UpdateVideoMediaManageDto) { const video = await this.prisma.videoMedia.findUnique({ where: { id }, }); if (!video) { throw new NotFoundException('Video not found'); } const updateData: any = {}; if (typeof dto.title === 'string') { updateData.title = dto.title.trim(); } let categoryId: string | null | undefined = dto.categoryId; const tagIds: string[] | undefined = dto.tagIds; if (dto.categoryId === null) { categoryId = null; } if (typeof categoryId !== 'undefined' || typeof tagIds !== 'undefined') { const { finalCategoryIds, finalTagIds, tags, tagsFlat } = await this.validateCategoryAndTags(categoryId, tagIds); updateData.categoryIds = finalCategoryIds; updateData.tagIds = finalTagIds; updateData.tags = tags; // NEW: store denormalised tag names (lowercased) updateData.tagsFlat = tagsFlat; // existing: text for search } if (typeof dto.listStatus === 'number') { if (dto.listStatus !== 0 && dto.listStatus !== 1) { throw new BadRequestException('Invalid listStatus value'); } updateData.listStatus = dto.listStatus; } updateData.editedAt = BigInt(Date.now()); updateData.updatedAt = new Date(); await this.prisma.videoMedia.update({ where: { id }, data: updateData, }); // Refresh category video lists cache if category changed or affected if (video.categoryIds && video.categoryIds.length > 0) { for (const cid of video.categoryIds) { await this.cacheSyncService.scheduleAction({ entityType: CacheEntityType.VIDEO_LIST, operation: 'REFRESH', payload: { categoryId: cid }, } as any); } } if (updateData.categoryIds && updateData.categoryIds.length > 0) { const oldCategoryIds = new Set(video.categoryIds || []); for (const cid of updateData.categoryIds) { if (!oldCategoryIds.has(cid)) { await this.cacheSyncService.scheduleAction({ entityType: CacheEntityType.VIDEO_LIST, operation: 'REFRESH', payload: { categoryId: cid }, } as any); } } } return this.findOne(id); } async updateStatus(id: string, dto: UpdateVideoMediaStatusDto) { const video = await this.prisma.videoMedia.findUnique({ where: { id }, }); if (!video) { throw new NotFoundException('Video not found'); } if (dto.listStatus !== 0 && dto.listStatus !== 1) { throw new BadRequestException('Invalid listStatus value'); } const editedAt = BigInt(Date.now()); const updatedAt = new Date(); await this.prisma.videoMedia.update({ where: { id }, data: { listStatus: dto.listStatus, editedAt, updatedAt, }, }); // Refresh category video lists cache if video has a category if (video.categoryIds && video.categoryIds.length > 0) { for (const categoryId of video.categoryIds) { await this.cacheSyncService.scheduleAction({ entityType: CacheEntityType.VIDEO_LIST, operation: 'REFRESH', payload: { categoryId }, } as any); } } return { id, listStatus: dto.listStatus, editedAt: editedAt.toString(), }; } async batchUpdateStatus(dto: BatchUpdateVideoMediaStatusDto) { if (!dto.ids?.length) { throw new BadRequestException('ids cannot be empty'); } if (dto.listStatus !== 0 && dto.listStatus !== 1) { throw new BadRequestException('Invalid listStatus value'); } const editedAt = BigInt(Date.now()); const updatedAt = new Date(); // Fetch affected videos to get their categories for cache refresh const affectedVideos = await this.prisma.videoMedia.findMany({ where: { id: { in: dto.ids } }, select: { categoryIds: true }, }); const result = await this.prisma.videoMedia.updateMany({ where: { id: { in: dto.ids } }, data: { listStatus: dto.listStatus, editedAt, updatedAt, }, }); // Refresh cache for all affected categories (fire-and-forget) const allAffectedCategoryIds = new Set(); for (const video of affectedVideos) { if (Array.isArray(video.categoryIds)) { for (const cid of video.categoryIds) { allAffectedCategoryIds.add(cid); } } } for (const categoryId of allAffectedCategoryIds) { await this.cacheSyncService.scheduleAction({ entityType: CacheEntityType.VIDEO_LIST, operation: 'REFRESH', payload: { categoryId }, } as any); } return { affected: result.count, listStatus: dto.listStatus, editedAt: editedAt.toString(), }; } // create an async function to delete a video media by id and return the deleted id also update Redis cache async delete(id: string) { const video = await this.prisma.videoMedia.findUnique({ where: { id }, }); if (!video) { throw new NotFoundException('Video not found'); } await this.prisma.videoMedia.delete({ where: { id }, }); // Refresh category video lists cache if video has a category if (video.categoryIds && video.categoryIds.length > 0) { for (const categoryId of video.categoryIds) { await this.cacheSyncService.scheduleAction({ entityType: CacheEntityType.VIDEO_LIST, operation: 'REFRESH', payload: { categoryId }, } as any); } } return { id, }; } /** * Upload and update VideoMedia cover image. */ async updateCover(id: string, file: MultipartFile) { const video = await this.prisma.videoMedia.findUnique({ where: { id } }); if (!video) { throw new NotFoundException('Video not found'); } const previous = { path: video.coverImg, strategy: video.imgSource as StorageStrategy | undefined, }; const filename = this.sanitizeFilename(file.filename); const relativePath = this.buildRelativePath( 'videos', 'images', id, filename, ); const strategy = this.mediaStorageStrategy; const uploadResult = await this.mediaManagerService.upload({ storageStrategy: strategy, relativePath: [relativePath], localStoragePrefix: 'local', fileStreams: [file.file], }); if (uploadResult.status !== 1) { throw new BadRequestException('Failed to upload cover image'); } const editedAt = BigInt(Math.floor(Date.now() / 1000)); const updatedAt = new Date(); const updated = await this.prisma.videoMedia.update({ where: { id }, data: { coverImg: relativePath, imgSource: uploadResult.storageStrategy, editedAt, updatedAt, }, }); if (video.categoryIds && video.categoryIds.length > 0) { for (const categoryId of video.categoryIds) { await this.cacheSyncService.scheduleAction({ entityType: CacheEntityType.VIDEO_LIST, operation: 'REFRESH', payload: { categoryId }, } as any); } } await this.cleanupPreviousCover(previous); return { id: updated.id, coverImg: updated.coverImg, imgSource: updated.imgSource, editedAt: editedAt.toString(), }; } private async cleanupPreviousCover(previous: { path?: string | null; strategy?: StorageStrategy; }) { if (!previous.path || !previous.strategy) return; await this.mediaManagerService .cleanup(previous.strategy, [previous.path], 'local') .catch(() => undefined); } private buildRelativePath( domain: string, type: 'images' | 'videos' | 'others', id: string, filename: string, ): string { return `${domain}/${type}/${id}/${filename}`; } private sanitizeFilename(name?: string | null): string { const raw = (name || 'file').trim(); const cleaned = raw.replace(/[\\/]+/g, ''); return cleaned || `${randomUUID()}.jpg`; } private async validateCategoryAndTags( categoryId: string | null | undefined, tagIds: string[] | undefined, ): Promise<{ finalCategoryIds: string[]; finalTagIds: string[]; tags: string[]; // NEW: denormalised tag names (lowercased) tagsFlat: string; // NEW: concatenated names for search }> { let finalCategoryIds: string[] = typeof categoryId === 'undefined' || categoryId === null ? [] : [categoryId]; let finalTagIds: string[] = []; let tags: string[] = []; // NEW let tagsFlat = ''; // Normalize tagIds: remove duplicates if (Array.isArray(tagIds)) { const unique = [...new Set(tagIds)]; if (unique.length > 5) { throw new BadRequestException('Tag count cannot exceed 5'); } finalTagIds = unique; } // If tags are provided but categoryId is null/undefined -> error if (finalTagIds.length > 0 && finalCategoryIds.length === 0) { throw new BadRequestException( 'Category is required when tags are provided.', ); } // Validate category if present if (finalCategoryIds.length > 0) { const category = await this.prisma.category.findUnique({ where: { id: finalCategoryIds[0] }, }); if (!category) { throw new BadRequestException('Category not found'); } if (category.status !== 1) { throw new BadRequestException('Category is disabled'); } } if (finalTagIds.length > 0) { const tagEntities = await this.prisma.tag.findMany({ where: { id: { in: finalTagIds } }, }); if (tagEntities.length !== finalTagIds.length) { throw new BadRequestException('Some tags do not exist'); } const distinctCategoryIds = [ ...new Set(tagEntities.map((t) => t.categoryId.toString())), ]; if (distinctCategoryIds.length > 1) { throw new BadRequestException( 'All tags must belong to the same category', ); } const tagCategoryId = distinctCategoryIds[0]; if ( finalCategoryIds.length > 0 && tagCategoryId !== finalCategoryIds[0] ) { throw new BadRequestException( 'Tags do not belong to the specified category', ); } // If categoryId was not provided but tags exist, infer from tags if (finalCategoryIds.length === 0) { finalCategoryIds = [tagCategoryId]; } // Build tags & tagsFlat: lowercased names const tagNames = tagEntities .map((t) => t.name?.trim()) .filter(Boolean) as string[]; tags = tagNames.map((name) => name.toLowerCase()); // NEW tagsFlat = tags.join(' '); // e.g. "funny hot 2025" } return { finalCategoryIds, finalTagIds, tags, tagsFlat, }; } }