Bläddra i källkod

feat(video): enhance video processing with sanitizedSecondTags and improved filtering

Dave 1 månad sedan
förälder
incheckning
b3e4d25f35

+ 24 - 9
apps/box-mgnt-api/src/mgnt-backend/feature/provider-video-sync/provider-video-sync.service.ts

@@ -617,26 +617,35 @@ export class ProviderVideoSyncService {
       };
     }
 
-    const normalized = rawList.map((item) => this.normalizeItem(item));
+    const normalized = rawList.map((item) => this.normalizeItem(item)) as Array<
+      Record<string, any>
+    >;
 
     for (const record of normalized) {
-      const previousLength = Array.isArray(record.secondTags)
+      const rawLength = Array.isArray(record.secondTags)
         ? record.secondTags.length
         : 0;
-      record.secondTags = this.sanitizeSecondTags(record.secondTags);
-      if (record.secondTags.length !== previousLength) {
+      const sanitized = this.sanitizeSecondTags(record.secondTags);
+      record.sanitizedSecondTags = sanitized;
+      if (sanitized.length !== rawLength) {
         this.logger.debug(
-          `[processProviderRawList] sanitized secondTags count ${previousLength} -> ${record.secondTags.length} for ${record.id}`,
+          `[processProviderRawList] sanitized secondTags count ${rawLength} -> ${sanitized.length} for ${record.id}`,
         );
       }
     }
 
     const hasSecondTags = normalized.some(
-      (v) => Array.isArray(v.secondTags) && v.secondTags.length > 0,
+      (v) =>
+        Array.isArray(v.sanitizedSecondTags) &&
+        v.sanitizedSecondTags.length > 0,
     );
 
     if (hasSecondTags) {
-      await this.upsertSecondTagsFromVideos_NoUniqueName(normalized);
+      await this.upsertSecondTagsFromVideos_NoUniqueName(
+        normalized.map((v) => ({
+          secondTags: v.sanitizedSecondTags ?? [],
+        })),
+      );
     }
 
     let maxUpdatedAtSeen = currentMaxUpdatedAt;
@@ -844,8 +853,14 @@ export class ProviderVideoSyncService {
 
       await this.mongo.videoMedia.upsert({
         where: { id },
-        create: record,
-        update: updateData,
+        create: {
+          ...record,
+          sanitizedSecondTags: record.sanitizedSecondTags ?? [],
+        },
+        update: {
+          ...updateData,
+          sanitizedSecondTags: (record as any).sanitizedSecondTags ?? [],
+        },
       });
 
       return { ok: true };

+ 1 - 2
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.controller.ts

@@ -45,8 +45,7 @@ export class VideoMediaController {
    */
   @ApiOperation({
     summary: '获取视频媒体列表',
-    description:
-      '分页查询视频列表,支持关键词搜索、分类过滤、标签过滤和上下架状态过滤',
+    description: '分页查询视频列表,支持关键词搜索和上下架状态过滤',
   })
   @ApiOkResponse({
     description: '返回视频媒体分页列表',

+ 0 - 24
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.dto.ts

@@ -31,30 +31,6 @@ export class VideoMediaListQueryDto extends PageListDto {
   keyword?: string;
 
   /**
-   * 分类过滤:可选
-   */
-  @ApiPropertyOptional({
-    type: String,
-    description: '视频分类 MongoDB ID',
-    example: '507f1f77bcf86cd799439011',
-  })
-  @IsOptional()
-  @IsMongoId()
-  categoryId?: string;
-
-  /**
-   * 标签过滤(通常前端只会传一个 tagId)
-   */
-  @ApiPropertyOptional({
-    type: String,
-    description: '视频标签 MongoDB ID',
-    example: '507f1f77bcf86cd799439012',
-  })
-  @IsOptional()
-  @IsMongoId()
-  tagId?: string;
-
-  /**
    * 上/下架状态过滤
    * 0 = 下架, 1 = 上架
    */

+ 102 - 43
apps/box-mgnt-api/src/mgnt-backend/feature/video-media/video-media.service.ts

@@ -19,6 +19,12 @@ import {
 } from './video-media.dto';
 import { MEDIA_STORAGE_STRATEGY } from '../../../shared/tokens';
 
+type MongoAggregateResult = {
+  cursor?: {
+    firstBatch?: any[];
+  };
+};
+
 @Injectable()
 export class VideoMediaService {
   constructor(
@@ -35,51 +41,52 @@ export class VideoMediaService {
     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.categoryIds = { has: query.categoryId };
-    }
-
-    if (query.tagId) {
-      where.tagIds = { has: query.tagId };
+    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 regex = new RegExp(this.escapeRegex(keyword), 'i');
+      const matchFilter = this.buildKeywordMatchFilter(baseWhere, regex);
+
+      // 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,
+      }));
     }
 
-    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,
@@ -105,6 +112,58 @@ export class VideoMediaService {
     };
   }
 
+  private buildVideoListBaseFilter(
+    query: VideoMediaListQueryDto,
+  ): Record<string, any> {
+    const where: Record<string, any> = {};
+
+    if (typeof query.listStatus === 'number') {
+      where.listStatus = query.listStatus;
+    }
+
+    if (
+      typeof query.editedFrom === 'number' ||
+      typeof query.editedTo === 'number'
+    ) {
+      const editedAt: Record<string, bigint> = {};
+      if (typeof query.editedFrom === 'number') {
+        editedAt.gte = BigInt(query.editedFrom);
+      }
+      if (typeof query.editedTo === 'number') {
+        editedAt.lte = BigInt(query.editedTo);
+      }
+      where.editedAt = editedAt;
+    }
+
+    return where;
+  }
+
+  private buildKeywordMatchFilter(
+    baseFilter: Record<string, any>,
+    regex: RegExp,
+  ): Record<string, any> {
+    const matchFilter = { ...baseFilter };
+    if (Array.isArray(matchFilter.$and)) {
+      matchFilter.$and = [...matchFilter.$and];
+    }
+
+    const keywordClause = {
+      $or: [
+        { title: { $regex: regex } },
+        { sanitizedSecondTags: { $elemMatch: { $regex: regex } } },
+      ],
+    };
+
+    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 },

+ 3 - 0
prisma/mongo/schema/video-media.prisma

@@ -74,6 +74,9 @@ model VideoMedia {
   deleteDisk    Boolean   @default(false)
   infoTsName    String    @default("")
 
+  sanitizedSecondTags    String[]  @default([])
+
+
   // all above fields are from provider, keep original, avoid updating,
   // use below fields for local video media controls
   categoryIds   String[]   @default([]) @db.ObjectId  // 分类 IDs (local, supports multiple categories)