import { Injectable, NotFoundException, BadRequestException, } from '@nestjs/common'; import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service'; import { ImageUploadService } from '../image-upload/image-upload.service'; import { VideoMediaListQueryDto, UpdateVideoMediaManageDto, UpdateVideoMediaStatusDto, BatchUpdateVideoMediaStatusDto, } from './video-media.dto'; @Injectable() export class VideoMediaService { constructor( private readonly prisma: MongoPrismaService, private readonly imageUploadService: ImageUploadService, ) {} async findAll(query: VideoMediaListQueryDto): Promise { const page = query.page ?? 1; const pageSize = query.size ?? 20; const skip = (page - 1) * pageSize; const take = pageSize; const where: any = {}; if (query.keyword) { where.OR = [ { title: { contains: query.keyword, mode: 'insensitive' } }, { filename: { contains: query.keyword, mode: 'insensitive' } }, ]; } if (query.categoryId) { where.categoryId = query.categoryId; } if (query.tagId) { where.tagIds = { has: query.tagId }; } if (typeof query.listStatus === 'number') { where.listStatus = query.listStatus; } // filter by editedFrom and editedTo if ( typeof query.editedFrom === 'number' || typeof query.editedTo === 'number' ) { where.editedAt = {}; if (typeof query.editedFrom === 'number') { where.editedAt.gte = BigInt(query.editedFrom); } if (typeof query.editedTo === 'number') { where.editedAt.lte = BigInt(query.editedTo); } } const [total, rows] = await Promise.all([ this.prisma.videoMedia.count({ where }), this.prisma.videoMedia.findMany({ where, skip, take, orderBy: { addedTime: 'desc' }, }), ]); return { total, page, pageSize, items: rows.map((row) => ({ id: row.id, title: row.title, filename: row.filename, videoTime: row.videoTime, size: row.size?.toString?.() ?? '0', coverImg: row.coverImg ?? '', categoryId: row.categoryId ?? null, 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 ?? [], // NOTE: We keep list DTO backward compatible. // If you later want to show tag names in list, we can add e.g. `tagsFlat` or `tagNames` here. })), }; } 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.categoryId ? this.prisma.category.findUnique({ where: { id: video.categoryId }, }) : 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, 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 ?? '', categoryId: video.categoryId ?? null, 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 { finalCategoryId, finalTagIds, tags, tagsFlat } = await this.validateCategoryAndTags(categoryId, tagIds); updateData.categoryId = finalCategoryId; 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, }); 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, }, }); 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(); const result = await this.prisma.videoMedia.updateMany({ where: { id: { in: dto.ids } }, data: { listStatus: dto.listStatus, editedAt, updatedAt, }, }); return { affected: result.count, listStatus: dto.listStatus, editedAt: editedAt.toString(), }; } /** * Upload and update VideoMedia cover image. */ async updateCover(id: string, file: Express.Multer.File) { // Ensure video exists const video = await this.prisma.videoMedia.findUnique({ where: { id }, }); if (!video) { throw new NotFoundException('Video not found'); } // Upload image const { key, imgSource } = await this.imageUploadService.uploadCoverImage( 'video-cover', file, ); const editedAt = BigInt(Date.now()); const updatedAt = new Date(); // Update VideoMedia record const updated = await this.prisma.videoMedia.update({ where: { id }, data: { coverImg: key, imgSource, editedAt, updatedAt, }, }); return { id: updated.id, coverImg: updated.coverImg, imgSource: updated.imgSource, editedAt: editedAt.toString(), }; } private async validateCategoryAndTags( categoryId: string | null | undefined, tagIds: string[] | undefined, ): Promise<{ finalCategoryId: string | null; finalTagIds: string[]; tags: string[]; // NEW: denormalised tag names (lowercased) tagsFlat: string; // NEW: concatenated names for search }> { let finalCategoryId: string | null = typeof categoryId === 'undefined' ? (undefined as any) : 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 && !finalCategoryId) { throw new BadRequestException( 'Category is required when tags are provided.', ); } // Validate category if present if (typeof finalCategoryId !== 'undefined' && finalCategoryId !== null) { const category = await this.prisma.category.findUnique({ where: { id: finalCategoryId }, }); 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 (finalCategoryId && tagCategoryId !== finalCategoryId) { throw new BadRequestException( 'Tags do not belong to the specified category', ); } // If categoryId was not provided but tags exist, infer from tags if (!finalCategoryId) { finalCategoryId = 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" } if (typeof finalCategoryId === 'undefined') { finalCategoryId = null; } return { finalCategoryId, finalTagIds, tags, tagsFlat, }; } }