| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720 |
- 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<number> {
- 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<void> {
- 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<any> {
- // 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<string, any> {
- const where: Record<string, any> = {};
- 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<string, Date> = {};
- 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<string, any>,
- regexSource: string,
- ): Record<string, any> {
- 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<any> {
- 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<string>();
- 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,
- };
- }
- }
|