Ver código fonte

feat: refactor video and homepage controllers, services, and cache builders

- Updated HomepageController to change endpoint from 'categories' to 'categorytags' and commented out unused tag-related endpoints.
- Removed deprecated methods from VideoController and VideoService, including getLatestVideosByCategory, getCategoriesWithTags, and searchVideosByTagName.
- Introduced LatestVideosCacheBuilder to manage caching of the latest videos.
- Updated CacheSyncService to include rebuilding of latest videos cache.
- Added new cache key for latest videos in cache-keys and ts-cache-key provider.
- Refactored RecommendedVideosCacheBuilder to utilize shared mapping functions for video items.
- Created a new video-item-mapper to centralize mapping logic for video items.
Dave 3 meses atrás
pai
commit
51ce9506c8

+ 2 - 2
.env

@@ -12,9 +12,9 @@ REDIS_PASSWORD=
 REDIS_DB=0
 REDIS_KEY_PREFIX=
 
-# RabbitMQ Config: RABBITMQ_URL="amqp://boxrabbit:BoxRabbit2025@localhost:5672"
+# RabbitMQ Config: RABBITMQ_URL="amqp://boxrabbit:BoxRabbit#2025@localhost:5672"
 # RabbitMQ Config
-RABBITMQ_URL=amqp://boxrabbit:BoxRabbit%232025@localhost:5672/
+RABBITMQ_URL=amqp://boxrabbit:BoxRabbit2025@localhost:5672/
 RABBITMQ_LOGIN_EXCHANGE=stats.user
 RABBITMQ_LOGIN_QUEUE=stats.user.login.q
 RABBITMQ_LOGIN_ROUTING_KEY="user.login

+ 1 - 1
apps/box-app-api/src/app.module.ts

@@ -57,7 +57,7 @@ import path from 'path';
     HealthModule,
     VideoModule,
     AdModule,
-    RecommendationModule,
+    // RecommendationModule,
     HomepageModule,
     SysParamsModule,
   ],

+ 58 - 58
apps/box-app-api/src/feature/ads/ad.controller.ts

@@ -141,39 +141,39 @@ export class AdController {
    * Record ad click event for analytics.
    * Protected endpoint that requires JWT authentication.
    */
-  @Post('click')
-  @UseGuards(JwtAuthGuard)
-  @ApiBearerAuth()
-  @ApiOperation({
-    summary: '广告点击事件上报',
-    description:
-      '记录广告点击事件(uid/IP/UA 由服务端填充,不接受客户端时间戳)。',
-  })
-  @ApiResponse({
-    status: 200,
-    description: '成功',
-    schema: { example: { status: 1, code: 'OK' } },
-  })
-  @ApiResponse({ status: 401, description: '未授权 - 需要JWT token' })
-  async recordAdClick(
-    @Body() body: AdClickDto,
-    @Req() req: RequestWithUser,
-  ): Promise<{ status: number; code: string }> {
-    const uid = req.user?.uid;
+  // @Post('click')
+  // // @UseGuards(JwtAuthGuard)
+  // // @ApiBearerAuth()
+  // @ApiOperation({
+  //   summary: '广告点击事件上报',
+  //   description:
+  //     '记录广告点击事件(uid/IP/UA 由服务端填充,不接受客户端时间戳)。',
+  // })
+  // @ApiResponse({
+  //   status: 200,
+  //   description: '成功',
+  //   schema: { example: { status: 1, code: 'OK' } },
+  // })
+  // @ApiResponse({ status: 401, description: '未授权 - 需要JWT token' })
+  // async recordAdClick(
+  //   @Body() body: AdClickDto,
+  //   @Req() req: RequestWithUser,
+  // ): Promise<{ status: number; code: string }> {
+  //   const uid = req.user?.uid;
 
-    if (!uid) {
-      this.logger.error('JWT payload missing uid');
-      throw new UnauthorizedException('Missing uid in JWT payload');
-    }
+  //   // if (!uid) {
+  //   //   this.logger.error('JWT payload missing uid');
+  //   //   throw new UnauthorizedException('Missing uid in JWT payload');
+  //   // }
 
-    const ip = this.getClientIp(req);
-    const userAgent = this.getUserAgent(req);
+  //   const ip = this.getClientIp(req);
+  //   const userAgent = this.getUserAgent(req);
 
-    // Fire-and-forget: don't await for immediate response
-    this.adService.recordAdClick(uid, body, ip, userAgent);
+  //   // Fire-and-forget: don't await for immediate response
+  //   this.adService.recordAdClick(uid, body, ip, userAgent);
 
-    return { status: 1, code: 'OK' };
-  }
+  //   return { status: 1, code: 'OK' };
+  // }
 
   /**
    * POST /ads/impression
@@ -181,39 +181,39 @@ export class AdController {
    * Record ad impression event for analytics.
    * Protected endpoint that requires JWT authentication.
    */
-  @Post('impression')
-  @UseGuards(JwtAuthGuard)
-  @ApiBearerAuth()
-  @ApiOperation({
-    summary: '广告曝光事件上报',
-    description:
-      '记录广告曝光事件(uid/IP/UA 由服务端填充,不接受客户端时间戳)。',
-  })
-  @ApiResponse({
-    status: 200,
-    description: '成功',
-    schema: { example: { status: 1, code: 'OK' } },
-  })
-  @ApiResponse({ status: 401, description: '未授权 - 需要JWT token' })
-  async recordAdImpression(
-    @Body() body: AdImpressionDto,
-    @Req() req: RequestWithUser,
-  ): Promise<{ status: number; code: string }> {
-    const uid = req.user?.uid;
+  // @Post('impression')
+  // @UseGuards(JwtAuthGuard)
+  // @ApiBearerAuth()
+  // @ApiOperation({
+  //   summary: '广告曝光事件上报',
+  //   description:
+  //     '记录广告曝光事件(uid/IP/UA 由服务端填充,不接受客户端时间戳)。',
+  // })
+  // @ApiResponse({
+  //   status: 200,
+  //   description: '成功',
+  //   schema: { example: { status: 1, code: 'OK' } },
+  // })
+  // @ApiResponse({ status: 401, description: '未授权 - 需要JWT token' })
+  // async recordAdImpression(
+  //   @Body() body: AdImpressionDto,
+  //   @Req() req: RequestWithUser,
+  // ): Promise<{ status: number; code: string }> {
+  //   const uid = req.user?.uid;
 
-    if (!uid) {
-      this.logger.error('JWT payload missing uid');
-      throw new UnauthorizedException('Missing uid in JWT payload');
-    }
+  //   if (!uid) {
+  //     this.logger.error('JWT payload missing uid');
+  //     throw new UnauthorizedException('Missing uid in JWT payload');
+  //   }
 
-    const ip = this.getClientIp(req);
-    const userAgent = this.getUserAgent(req);
+  //   const ip = this.getClientIp(req);
+  //   const userAgent = this.getUserAgent(req);
 
-    // Fire-and-forget: don't await for immediate response
-    this.adService.recordAdImpression(uid, body, ip, userAgent);
+  //   // Fire-and-forget: don't await for immediate response
+  //   this.adService.recordAdImpression(uid, body, ip, userAgent);
 
-    return { status: 1, code: 'OK' };
-  }
+  //   return { status: 1, code: 'OK' };
+  // }
 
   private getClientIp(req: Request): string {
     return (

+ 30 - 30
apps/box-app-api/src/feature/homepage/homepage.controller.ts

@@ -38,7 +38,7 @@ export class HomepageController {
     return this.homepageService.getHomepageData();
   }
 
-  @Get('categories')
+  @Get('categorytags')
   @ApiOperation({
     summary: '获取分类列表',
     description:
@@ -48,35 +48,35 @@ export class HomepageController {
     return this.homepageService.getCategoryList();
   }
 
-  @Get('tags')
-  @ApiOperation({
-    summary: '获取标签列表',
-    description: '返回 Redis 中的完整标签缓存(box:app:tag:all)或对象数据。',
-  })
-  async getTagList(): Promise<HomeTagCacheItem[] | Record<string, unknown>> {
-    return this.homepageService.getTagList();
-  }
+  // @Get('tags')
+  // @ApiOperation({
+  //   summary: '获取标签列表',
+  //   description: '返回 Redis 中的完整标签缓存(box:app:tag:all)或对象数据。',
+  // })
+  // async getTagList(): Promise<HomeTagCacheItem[] | Record<string, unknown>> {
+  //   return this.homepageService.getTagList();
+  // }
 
-  @Get('search/category')
-  @ApiOperation({
-    summary: '按分类名称搜索',
-    description: '按名称模糊匹配分类(大小写不敏感),q 为空返回空数组。',
-  })
-  async searchCategories(
-    @Query('q') query: string,
-  ): Promise<HomeCategoryCacheItem[]> {
-    return this.homepageService.searchByCategoryName(query);
-  }
+  // @Get('search/category')
+  // @ApiOperation({
+  //   summary: '按分类名称搜索',
+  //   description: '按名称模糊匹配分类(大小写不敏感),q 为空返回空数组。',
+  // })
+  // async searchCategories(
+  //   @Query('q') query: string,
+  // ): Promise<HomeCategoryCacheItem[]> {
+  //   return this.homepageService.searchByCategoryName(query);
+  // }
 
-  @Get('search/tag')
-  @ApiOperation({
-    summary: '按标签名称搜索',
-    description:
-      '按标签名称模糊匹配分类 tags 字段(大小写不敏感),q 为空返回空数组。',
-  })
-  async searchTags(
-    @Query('q') query: string,
-  ): Promise<HomeCategoryCacheItem[]> {
-    return this.homepageService.searchByTagName(query);
-  }
+  // @Get('search/tag')
+  // @ApiOperation({
+  //   summary: '按标签名称搜索',
+  //   description:
+  //     '按标签名称模糊匹配分类 tags 字段(大小写不敏感),q 为空返回空数组。',
+  // })
+  // async searchTags(
+  //   @Query('q') query: string,
+  // ): Promise<HomeCategoryCacheItem[]> {
+  //   return this.homepageService.searchByTagName(query);
+  // }
 }

+ 0 - 127
apps/box-app-api/src/feature/video/video.controller.ts

@@ -46,126 +46,6 @@ export class VideoController {
   constructor(private readonly videoService: VideoService) {}
 
   /**
-   * GET /api/v1/video/recommended
-   *
-   * Get recommended videos from Redis.
-   */
-  @Get('recommended')
-  @ApiOperation({
-    summary: '获取推荐视频列表',
-    description: '从 Redis 获取推荐视频列表。',
-  })
-  @ApiResponse({
-    status: 200,
-    description: '推荐视频列表',
-    type: RecommendedVideosDto,
-  })
-  async getRecommendedVideos(
-    @Req() req: RequestWithUser,
-  ): Promise<RecommendedVideosDto> {
-    const uid = req.user?.uid;
-
-    if (!uid) {
-      throw new UnauthorizedException('Missing uid in JWT payload');
-    }
-
-    const ip = this.getClientIp(req);
-    const userAgent = this.getUserAgent(req);
-    return this.videoService.getRecommendedVideos();
-  }
-
-  /**
-   * GET /api/v1/video/category/:channelId/:categoryId/latest
-   *
-   * Get latest videos for a category from Redis.
-   */
-  @Get('category/:channelId/:categoryId/latest')
-  @ApiOperation({
-    summary: '获取分类最新视频',
-    description: '从 Redis 获取指定频道和分类的最新视频列表。',
-  })
-  @ApiResponse({
-    status: 200,
-    description: '最新视频列表',
-    type: VideoDetailDto,
-    isArray: true,
-  })
-  async getLatestVideosByCategory(
-    @Param('channelId') channelId: string,
-    @Param('categoryId') categoryId: string,
-  ): Promise<VideoDetailDto[]> {
-    return this.videoService.getLatestVideosByCategory(categoryId);
-  }
-
-  /**
-   * GET /api/v1/video/categories-with-tags
-   *
-   * Get all video categories with their associated tags.
-   * Returns categories fetched from Redis cache (app:category:all),
-   * with tags for each category fetched from (box:app:tag:list:{categoryId}).
-   */
-  @Get('categories-with-tags')
-  @ApiOperation({
-    summary: '获取所有分类及其标签',
-    description:
-      '返回所有视频分类及其关联的标签。数据来源:Redis 缓存(由 box-mgnt-api 构建)。',
-  })
-  @ApiResponse({
-    status: 200,
-    description: '分类及其标签列表',
-    type: VideoCategoryWithTagsResponseDto,
-  })
-  async getCategoriesWithTags(): Promise<VideoCategoryWithTagsResponseDto> {
-    return this.videoService.getCategoriesWithTags();
-  }
-
-  /**
-   * POST /api/v1/video/list
-   *
-   * Get paginated list of videos for a category with optional tag filtering.
-   * Request body contains page, size, categoryId, and optional tagName.
-   * Returns paginated video list with metadata.
-   */
-  @Post('list')
-  @ApiOperation({
-    summary: '分页获取视频列表',
-    description:
-      '按分类(和可选的标签)分页获取视频列表。支持按页码和每页数量分页。',
-  })
-  @ApiResponse({
-    status: 200,
-    description: '成功返回分页视频列表',
-    type: VideoListResponseDto,
-  })
-  async getVideoList(
-    @Body() req: VideoListRequestDto,
-  ): Promise<VideoListResponseDto> {
-    return this.videoService.getVideoList(req);
-  }
-
-  /**
-   * POST /api/v1/video/search-by-tag
-   *
-   * Search videos by tag name across all categories.
-   * Collects all videos tagged with the specified tag name from all categories.
-   */
-  @Post('search-by-tag')
-  @ApiOperation({
-    summary: '按标签名称全局搜索视频',
-    description: '跨所有分类搜索具有指定标签名称的视频,返回分页结果。',
-  })
-  @ApiResponse({
-    status: 200,
-    description: '成功返回搜索结果',
-    type: VideoListResponseDto,
-  })
-  async searchByTag(
-    @Body() req: VideoSearchByTagRequestDto,
-  ): Promise<VideoListResponseDto> {
-    return this.videoService.searchVideosByTagName(req);
-  }
-
-  /**
    * GET /api/v1/video/search
    *
    * Search cached recommended videos by secondTags (supports comma-separated tags).
@@ -201,8 +81,6 @@ export class VideoController {
    * Protected endpoint that requires JWT authentication.
    */
   @Post('click')
-  @UseGuards(JwtAuthGuard)
-  @ApiBearerAuth()
   @ApiOperation({
     summary: '视频点击事件上报',
     description:
@@ -219,11 +97,6 @@ export class VideoController {
     @Req() req: RequestWithUser,
   ): Promise<{ status: number; code: string }> {
     const uid = req.user?.uid;
-
-    if (!uid) {
-      throw new UnauthorizedException('Missing uid in JWT payload');
-    }
-
     const ip = this.getClientIp(req);
     const userAgent = this.getUserAgent(req);
 

+ 0 - 696
apps/box-app-api/src/feature/video/video.service.ts

@@ -47,47 +47,6 @@ import { CategoryDto } from '../homepage/dto/homepage.dto';
  */
 @Injectable()
 export class VideoService {
-  /**
-   * Get latest videos for a category from Redis (for controller endpoint).
-   * Uses key: box:app:video:list:category:{channelId}:{categoryId}:latest
-   */
-  async getLatestVideosByCategory(
-    // channelId: string,
-    categoryId: string,
-  ): Promise<VideoDetailDto[]> {
-    try {
-      const key = tsCacheKeys.video.categoryList(categoryId);
-      const videoIds = await this.cacheHelper.getVideoIdList(key);
-      if (!videoIds || videoIds.length === 0) {
-        return [];
-      }
-      const videos = await this.mongoPrisma.videoMedia.findMany({
-        where: { id: { in: videoIds } },
-      });
-      const videoMap = new Map(videos.map((v) => [v.id, v]));
-      return videoIds
-        .map((id) => {
-          const video = videoMap.get(id);
-          if (!video) return null;
-          return {
-            id: video.id,
-            title: video.title ?? '',
-            categoryIds: video.categoryIds,
-            tagIds: video.tagIds,
-            listStatus: video.listStatus,
-            editedAt: video.editedAt?.toString() ?? '',
-            updatedAt: video.updatedAt?.toISOString() ?? '',
-          } as VideoDetailDto;
-        })
-        .filter((v): v is VideoDetailDto => v !== null);
-    } catch (err) {
-      this.logger.error(
-        `Error fetching latest videos for categoryId=${categoryId}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      return [];
-    }
-  }
   private readonly logger = new Logger(VideoService.name);
   private readonly cacheHelper: VideoCacheHelper;
 
@@ -118,490 +77,6 @@ export class VideoService {
   }
 
   /**
-   * Get category list for a channel.
-   *
-   * NEW SEMANTICS:
-   * - Key box:app:video:category:list:{categoryId} stores VIDEO IDs only (not category JSON)
-   * - This method needs to query MongoDB for category metadata
-   *
-   * For backward compatibility during migration:
-   * - If the key contains JSON objects (old format), detect and warn
-   * - Fall back to MongoDB query
-   */
-  async getCategoryListForChannel(
-    channelId: string,
-  ): Promise<VideoCategoryDto[]> {
-    try {
-      // Fetch categories from MongoDB (primary source for metadata)
-      // NOTE: Categories are no longer tied to Channel, returning all active categories
-      const categories = await this.mongoPrisma.category.findMany({
-        where: { status: 1 },
-        orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
-      });
-
-      // Transform to DTOs
-      return categories.map((cat) => ({
-        id: cat.id,
-        name: cat.name,
-        subtitle: cat.subtitle ?? null,
-        seq: cat.seq,
-        status: cat.status,
-        createAt: cat.createAt.toString(),
-        updateAt: cat.updateAt.toString(),
-      }));
-    } catch (err) {
-      this.logger.error(
-        `Error fetching category list for channelId=${channelId}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      return [];
-    }
-  }
-
-  /**
-   * Get tag list for a category.
-   *
-   * NEW SEMANTICS:
-   * - Key box:app:tag:list:{categoryId} stores TAG JSON objects (correct format)
-   * - Key box:app:video:tag:list:{categoryId}:{tagId} stores VIDEO IDs only
-   *
-   * This method reads from box:app:tag:list:{categoryId} to get tag metadata.
-   * Falls back to MongoDB if cache miss.
-   */
-  async getTagListForCategory(categoryId: string): Promise<VideoTagDto[]> {
-    try {
-      // Use helper to read tag metadata from cache
-      const key = tsCacheKeys.tag.metadataByCategory(categoryId);
-      const tags = await this.cacheHelper.getTagListForCategory(key);
-
-      if (!tags || tags.length === 0) {
-        this.logger.debug(
-          `Cache miss for tag list, falling back to DB: ${key}`,
-        );
-        return this.getTagListFromDb(categoryId);
-      }
-
-      // Transform to DTOs
-      return tags.map((tag) => ({
-        id: tag.id,
-        name: tag.name,
-        seq: tag.seq,
-        status: tag.status,
-        createAt: tag.createAt,
-        updateAt: tag.updateAt,
-        categoryId: tag.categoryId,
-      }));
-    } catch (err) {
-      this.logger.error(
-        `Error fetching tag list for categoryId=${categoryId}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      // Fall back to DB on error
-      return this.getTagListFromDb(categoryId);
-    }
-  }
-
-  /**
-   * Fallback: Get tag list from MongoDB when cache is unavailable.
-   */
-  private async getTagListFromDb(categoryId: string): Promise<VideoTagDto[]> {
-    try {
-      const tags = await this.mongoPrisma.tag.findMany({
-        where: { status: 1, categoryId },
-        orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
-      });
-
-      return tags.map((tag) => ({
-        id: tag.id,
-        name: tag.name,
-        seq: tag.seq,
-        status: tag.status,
-        createAt: tag.createAt.toString(),
-        updateAt: tag.updateAt.toString(),
-        categoryId: tag.categoryId,
-      }));
-    } catch (err) {
-      this.logger.error(
-        `Error fetching tags from DB for categoryId=${categoryId}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      return [];
-    }
-  }
-
-  /**
-   * Get videos under a category with pagination.
-   *
-   * NEW SEMANTICS:
-   * - Key box:app:video:category:list:{categoryId} stores VIDEO IDs only
-   * - Read IDs from LIST, then fetch video details from MongoDB
-   * - Preserve the order of IDs from Redis
-   *
-   * Uses ZREVRANGE on appVideoCategoryPoolKey for score-based pagination (pool keys).
-   * For simple listing, could use the categoryList key directly.
-   */
-  async getVideosByCategoryWithPaging(params: {
-    channelId: string;
-    categoryId: string;
-    page?: number;
-    pageSize?: number;
-  }): Promise<VideoPageDto<VideoDetailDto>> {
-    const { channelId, categoryId, page = 1, pageSize = 20 } = params;
-
-    try {
-      const key = tsCacheKeys.video.categoryPool(
-        channelId,
-        categoryId,
-        'latest',
-      );
-
-      // Calculate offset and limit for ZREVRANGE
-      const offset = (page - 1) * pageSize;
-      const limit = pageSize;
-
-      // ZREVRANGE returns items in descending order by score (latest first)
-      const videoIds = await this.redis.zrevrange(
-        key,
-        offset,
-        offset + limit - 1,
-      );
-
-      if (!videoIds || videoIds.length === 0) {
-        this.logger.debug(
-          `Cache miss for category pool, trying category list: ${key}`,
-        );
-        return this.getVideosByCategoryListFallback({
-          categoryId,
-          page,
-          pageSize,
-        });
-      }
-
-      // Fetch details for all videoIds from MongoDB (preserving order)
-      const details = await this.getVideoDetailsBatchFromDb(videoIds);
-
-      return {
-        items: details.filter((d) => d !== null) as VideoDetailDto[],
-        total: undefined, // Could add ZCARD to get total count
-        page,
-        pageSize,
-      };
-    } catch (err) {
-      this.logger.error(
-        `Error fetching videos by category for channelId=${channelId}, categoryId=${categoryId}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      return {
-        items: [],
-        total: 0,
-        page: page ?? 1,
-        pageSize: pageSize ?? 20,
-      };
-    }
-  }
-
-  /**
-   * Fallback: Get videos by category using the category video list (not pool).
-   * NEW SEMANTICS: Reads box:app:video:category:list:{categoryId} which contains video IDs.
-   */
-  private async getVideosByCategoryListFallback(params: {
-    categoryId: string;
-    page: number;
-    pageSize: number;
-  }): Promise<VideoPageDto<VideoDetailDto>> {
-    const { categoryId, page, pageSize } = params;
-
-    const key = tsCacheKeys.video.categoryList(categoryId);
-    const start = (page - 1) * pageSize;
-    const stop = start + pageSize - 1;
-
-    let listExists = false;
-    let videoIds: string[] = [];
-
-    try {
-      listExists = (await this.redis.exists(key)) > 0;
-      videoIds = await this.cacheHelper.getVideoIdList(key, start, stop);
-    } catch (err) {
-      this.logger.error(
-        `Error reading category list key=${key}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      listExists = false;
-      videoIds = [];
-    }
-
-    let usedCache = false;
-    if (listExists && videoIds.length > 0) {
-      if (videoIds[0] && this.isLegacyJsonFormat(videoIds[0])) {
-        this.logger.warn(
-          `Detected legacy JSON format in ${key}, falling back to DB query`,
-        );
-      } else {
-        usedCache = true;
-        const details = await this.getVideoDetailsBatchFromDb(videoIds);
-        return {
-          items: details.filter((d) => d !== null) as VideoDetailDto[],
-          total: undefined,
-          page,
-          pageSize,
-        };
-      }
-    }
-
-    if (!usedCache) {
-      const reason = listExists ? 'empty list' : 'missing key';
-      this.logger.debug(
-        `Cache miss for category list (${reason}), falling back to DB: ${key}`,
-      );
-    }
-
-    try {
-      const videos = await this.mongoPrisma.videoMedia.findMany({
-        where: { categoryIds: { has: categoryId }, listStatus: 1 },
-        orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
-        skip: start,
-        take: pageSize,
-      });
-
-      const items = videos.map((v) => ({
-        id: v.id,
-        title: v.title,
-        categoryIds: v.categoryIds,
-        tagIds: v.tagIds,
-        listStatus: v.listStatus,
-        editedAt: v.editedAt.toString(),
-        updatedAt: v.updatedAt.toISOString(),
-      }));
-
-      const cachedIds = videos.map((video) => video.id);
-      try {
-        await this.cacheHelper.saveVideoIdList(key, cachedIds);
-      } catch (err) {
-        this.logger.error(
-          `Error saving video ID list for key=${key}`,
-          err instanceof Error ? err.stack : String(err),
-        );
-      }
-
-      const entries = videos.map((video) => ({
-        key: tsCacheKeys.video.payload(video.id),
-        value: toVideoPayload(video as RawVideoPayloadRow),
-      }));
-
-      try {
-        await this.redis.pipelineSetJson(entries);
-      } catch (err) {
-        this.logger.error(
-          `Error writing payload cache for category list fallback key=${key}`,
-          err instanceof Error ? err.stack : String(err),
-        );
-      }
-
-      return {
-        items,
-        total: undefined,
-        page,
-        pageSize,
-      };
-    } catch (err) {
-      this.logger.error(
-        `Error fetching videos from DB for categoryId=${categoryId}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      return {
-        items: [],
-        total: 0,
-        page,
-        pageSize,
-      };
-    }
-  }
-
-  /**
-   * Get videos under a tag with pagination.
-   *
-   * NEW SEMANTICS:
-   * - Key box:app:video:tag:list:{categoryId}:{tagId} stores VIDEO IDs only
-   * - Read IDs from LIST, then fetch video details from MongoDB
-   *
-   * Uses ZREVRANGE on appVideoTagPoolKey for score-based pagination (pool keys).
-   */
-  async getVideosByTagWithPaging(params: {
-    channelId: string;
-    categoryId: string;
-    tagId: string;
-    page?: number;
-    pageSize?: number;
-  }): Promise<VideoPageDto<VideoDetailDto>> {
-    const { channelId, categoryId, tagId, page = 1, pageSize = 20 } = params;
-
-    try {
-      const key = tsCacheKeys.video.tagPool(channelId, tagId, 'latest');
-
-      const offset = (page - 1) * pageSize;
-      const limit = pageSize;
-
-      const videoIds = await this.redis.zrevrange(
-        key,
-        offset,
-        offset + limit - 1,
-      );
-
-      if (!videoIds || videoIds.length === 0) {
-        this.logger.debug(`Cache miss for tag pool, trying tag list: ${key}`);
-        return this.getVideosByTagListFallback({
-          categoryId,
-          tagId,
-          page,
-          pageSize,
-        });
-      }
-
-      const details = await this.getVideoDetailsBatchFromDb(videoIds);
-
-      return {
-        items: details.filter((d) => d !== null) as VideoDetailDto[],
-        total: undefined,
-        page,
-        pageSize,
-      };
-    } catch (err) {
-      this.logger.error(
-        `Error fetching videos by tag for channelId=${channelId}, tagId=${tagId}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      return {
-        items: [],
-        total: 0,
-        page: page ?? 1,
-        pageSize: pageSize ?? 20,
-      };
-    }
-  }
-
-  /**
-   * Fallback: Get videos by tag using the tag video list (not pool).
-   * NEW SEMANTICS: Reads box:app:video:tag:list:{categoryId}:{tagId} which contains video IDs.
-   */
-  private async getVideosByTagListFallback(params: {
-    categoryId: string;
-    tagId: string;
-    page: number;
-    pageSize: number;
-  }): Promise<VideoPageDto<VideoDetailDto>> {
-    const { categoryId, tagId, page, pageSize } = params;
-
-    const key = tsCacheKeys.video.tagList(categoryId, tagId);
-    const start = (page - 1) * pageSize;
-    const stop = start + pageSize - 1;
-
-    let listExists = false;
-    let videoIds: string[] = [];
-
-    try {
-      listExists = (await this.redis.exists(key)) > 0;
-      videoIds = await this.cacheHelper.getVideoIdList(key, start, stop);
-    } catch (err) {
-      this.logger.error(
-        `Error reading tag list key=${key}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      listExists = false;
-      videoIds = [];
-    }
-
-    let usedCache = false;
-    if (listExists && videoIds.length > 0) {
-      if (videoIds[0] && this.isLegacyJsonFormat(videoIds[0])) {
-        this.logger.warn(
-          `Detected legacy JSON format in ${key}, falling back to DB query`,
-        );
-      } else {
-        usedCache = true;
-        const details = await this.getVideoDetailsBatchFromDb(videoIds);
-        return {
-          items: details.filter((d) => d !== null) as VideoDetailDto[],
-          total: undefined,
-          page,
-          pageSize,
-        };
-      }
-    }
-
-    if (!usedCache) {
-      const reason = listExists ? 'empty list' : 'missing key';
-      this.logger.debug(
-        `Cache miss for tag list (${reason}), falling back to DB: ${key}`,
-      );
-    }
-
-    try {
-      const videos = await this.mongoPrisma.videoMedia.findMany({
-        where: {
-          categoryIds: { has: categoryId },
-          status: 'Completed',
-          tagIds: { has: tagId },
-        },
-        orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
-        skip: start,
-        take: pageSize,
-      });
-
-      const items = videos.map((v) => ({
-        id: v.id,
-        title: v.title,
-        categoryIds: v.categoryIds,
-        tagIds: v.tagIds,
-        listStatus: v.listStatus,
-        editedAt: v.editedAt.toString(),
-        updatedAt: v.updatedAt.toISOString(),
-      }));
-
-      const cachedIds = videos.map((video) => video.id);
-      try {
-        await this.cacheHelper.saveVideoIdList(key, cachedIds);
-      } catch (err) {
-        this.logger.error(
-          `Error saving video ID list for key=${key}`,
-          err instanceof Error ? err.stack : String(err),
-        );
-      }
-
-      const entries = videos.map((video) => ({
-        key: tsCacheKeys.video.payload(video.id),
-        value: toVideoPayload(video as RawVideoPayloadRow),
-      }));
-
-      try {
-        await this.redis.pipelineSetJson(entries);
-      } catch (err) {
-        this.logger.error(
-          `Error writing payload cache for tag list fallback key=${key}`,
-          err instanceof Error ? err.stack : String(err),
-        );
-      }
-
-      return {
-        items,
-        total: undefined,
-        page,
-        pageSize,
-      };
-    } catch (err) {
-      this.logger.error(
-        `Error fetching videos from DB for categoryId=${categoryId}, tagId=${tagId}`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      return {
-        items: [],
-        total: 0,
-        page,
-        pageSize,
-      };
-    }
-  }
-
-  /**
    * Get home section videos for a channel.
    * Reads from appVideoHomeSectionKey (LIST of videoIds).
    * Returns video details for each ID.
@@ -632,10 +107,6 @@ export class VideoService {
     }
   }
 
-  /**
-   * Fetch video details for multiple videoIds using Redis pipeline for efficiency.
-   * DEPRECATED: Use getVideoDetailsBatchFromDb instead for new semantics.
-   */
   private async getVideoDetailsBatch(
     videoIds: string[],
   ): Promise<(VideoDetailDto | null)[]> {
@@ -663,53 +134,6 @@ export class VideoService {
     }
   }
 
-  /**
-   * Fetch video details from MongoDB for multiple videoIds.
-   * NEW: Primary method for fetching video details in new semantics.
-   * Preserves the order of videoIds.
-   */
-  private async getVideoDetailsBatchFromDb(
-    videoIds: string[],
-  ): Promise<(VideoDetailDto | null)[]> {
-    if (!videoIds || videoIds.length === 0) {
-      return [];
-    }
-
-    try {
-      // Fetch all videos in one query
-      const videos = await this.mongoPrisma.videoMedia.findMany({
-        where: { id: { in: videoIds } },
-      });
-
-      // Create a map for O(1) lookup
-      const videoMap = new Map(videos.map((v) => [v.id, v]));
-
-      // Preserve original order
-      return videoIds.map((id) => {
-        const video = videoMap.get(id);
-        if (!video) {
-          return null;
-        }
-
-        return {
-          id: video.id,
-          title: video.title,
-          categoryIds: video.categoryIds,
-          tagIds: video.tagIds,
-          listStatus: video.listStatus,
-          editedAt: video.editedAt.toString(),
-          updatedAt: video.updatedAt.toISOString(),
-        };
-      });
-    } catch (err) {
-      this.logger.error(
-        `Error fetching video details from DB`,
-        err instanceof Error ? err.stack : String(err),
-      );
-    }
-    return videoIds.map(() => null);
-  }
-
   private async getVideoPayloadsByIds(
     videoIds: string[],
   ): Promise<VideoPayload[]> {
@@ -789,113 +213,6 @@ export class VideoService {
   }
 
   /**
-   * Detect legacy JSON format in Redis cache.
-   * Legacy format: Category/Tag JSON objects with "name" field.
-   * New format: Video IDs (ObjectId strings, no JSON structure).
-   *
-   * Returns true if the value looks like legacy JSON format.
-   */
-  private isLegacyJsonFormat(value: string): boolean {
-    try {
-      // Video IDs are 24-character hex strings (MongoDB ObjectId)
-      if (/^[0-9a-f]{24}$/i.test(value)) {
-        return false; // This is a video ID, not JSON
-      }
-
-      // Try to parse as JSON
-      const parsed = JSON.parse(value);
-
-      // If it has "name" but no typical video fields, it's likely legacy format
-      if (
-        parsed &&
-        typeof parsed === 'object' &&
-        'name' in parsed &&
-        !('videoUrl' in parsed) &&
-        !('title' in parsed)
-      ) {
-        return true;
-      }
-
-      return false;
-    } catch {
-      // Not valid JSON, assume it's a video ID
-      return false;
-    }
-  }
-
-  /**
-   * Get all categories with their associated tags.
-   * Reads categories from Redis cache (app:category:all).
-   * For each category, reads tags from Redis (box:app:tag:list:{categoryId}).
-   * If cache is missing/empty, treats as empty list.
-   */
-  async getCategoriesWithTags(): Promise<VideoCategoryWithTagsResponseDto> {
-    try {
-      const key = tsCacheKeys.category.all();
-      const rawCategories = await this.redis.getJson<
-        Array<{
-          id: string;
-          name: string;
-          subtitle?: string | null;
-          seq: number;
-          channelId: string;
-        }>
-      >(key);
-
-      if (!rawCategories || rawCategories.length === 0) {
-        this.logger.debug(`Cache miss for categories list key: ${key}`);
-        return { items: [] };
-      }
-
-      // For each category, fetch its tags from cache
-      const items = await Promise.all(
-        rawCategories.map(async (category) => {
-          try {
-            const tagKey = tsCacheKeys.tag.metadataByCategory(category.id);
-            const tagMetadata =
-              await this.cacheHelper.getTagListForCategory(tagKey);
-            const tags = (tagMetadata ?? []).map((tag) => ({
-              name: tag.name,
-              seq: tag.seq,
-            }));
-
-            return {
-              id: category.id,
-              name: category.name,
-              subtitle: category.subtitle ?? undefined,
-              seq: category.seq,
-              channelId: category.channelId,
-              tags,
-            };
-          } catch (err) {
-            this.logger.error(
-              `Error fetching tags for categoryId=${category.id}`,
-              err instanceof Error ? err.stack : String(err),
-            );
-            // Return category with empty tags on error
-            return {
-              id: category.id,
-              name: category.name,
-              subtitle: category.subtitle ?? undefined,
-              seq: category.seq,
-              channelId: category.channelId,
-              tags: [],
-            };
-          }
-        }),
-      );
-
-      return { items };
-    } catch (err) {
-      this.logger.error(
-        `Error fetching categories with tags`,
-        err instanceof Error ? err.stack : String(err),
-      );
-      return { items: [] };
-    }
-  }
-
-  /**
    * Get paginated list of videos for a category with optional tag filtering.
    * Reads video IDs from Redis cache, fetches full details from MongoDB,
    * and returns paginated results.
@@ -1297,19 +614,6 @@ export class VideoService {
     };
   }
 
-  /**
-   * Search videos by tag name across all categories.
-   *
-   * Algorithm:
-   * 1. Load all categories (from Redis or MongoDB)
-   * 2. For each category, read tag metadata and find tags matching tagName
-   * 3. Collect (categoryId, tagId) pairs where tag.name === dto.tagName
-   * 4. For each pair, read all video IDs from box:app:video:tag:list:{categoryId}:{tagId}
-   * 5. Combine all IDs, deduplicate, compute total
-   * 6. Apply in-memory pagination on unique ID list
-   * 7. Fetch videos from MongoDB, join with category metadata
-   * 8. Map to VideoListItemDto and return response
-   */
   async searchVideosByTagName(
     dto: VideoSearchByTagRequestDto,
   ): Promise<VideoListResponseDto> {

+ 1 - 0
apps/box-mgnt-api/src/cache-sync/cache-checklist.service.ts

@@ -119,6 +119,7 @@ export class CacheChecklistService implements OnApplicationBootstrap {
     const keys: string[] = [CHANNEL_ALL_KEY, CATEGORY_ALL_KEY, TAG_ALL_KEY];
 
     keys.push(tsCacheKeys.video.list());
+    keys.push(tsCacheKeys.video.latest());
     // Add one ad pool key per AdType (no scene/slot - simplified to one pool per type)
     const adTypes = Object.values(PrismaAdType) as AdType[];
     for (const adType of adTypes) {

+ 8 - 0
apps/box-mgnt-api/src/cache-sync/cache-sync.service.ts

@@ -13,6 +13,7 @@ import { CategoryCacheBuilder } from '@box/core/cache/category/category-cache.bu
 import { TagCacheBuilder } from '@box/core/cache/tag/tag-cache.builder';
 import { ChannelCacheBuilder } from '@box/core/cache/channel/channel-cache.builder';
 import { AdPoolService } from '@box/core/ad/ad-pool.service';
+import { LatestVideosCacheBuilder } from '@box/core/cache/video/latest/latest-videos-cache.builder';
 import { RecommendedVideosCacheBuilder } from '@box/core/cache/video/recommended/recommended-videos-cache.builder';
 
 import {
@@ -62,6 +63,7 @@ export class CacheSyncService {
     private readonly tagCacheBuilder: TagCacheBuilder,
     private readonly adPoolService: AdPoolService,
     private readonly recommendedVideosCacheBuilder: RecommendedVideosCacheBuilder,
+    private readonly latestVideosCacheBuilder: LatestVideosCacheBuilder,
   ) {}
 
   // Utility to get "now" as BigInt epoch millis
@@ -726,6 +728,8 @@ export class CacheSyncService {
       // Rebuild recommended videos
       await this.rebuildRecommendedVideos();
 
+      await this.rebuildLatestVideos();
+
       await this.rebuildAllAdsCaches();
 
       this.logger.log(`Cache warming complete in ${Date.now() - start}ms`);
@@ -769,6 +773,10 @@ export class CacheSyncService {
     await this.recommendedVideosCacheBuilder.buildAll();
   }
 
+  async rebuildLatestVideos(): Promise<void> {
+    await this.latestVideosCacheBuilder.buildAll();
+  }
+
   async rebuildAdsCacheByType(adType: AdType): Promise<void> {
     await this.adPoolService.rebuildPoolForType(adType);
   }

+ 1 - 0
libs/common/src/cache/cache-keys.ts

@@ -101,5 +101,6 @@ export const CacheKeys = {
   // RECOMMENDED VIDEOS
   // ─────────────────────────────────────────────
   appRecommendedVideos: 'box:app:video:recommended',
+  appVideoLatest: 'box:app:video:latest',
   appVideoList: 'box:app:video:list',
 };

+ 2 - 0
libs/common/src/cache/ts-cache-key.provider.ts

@@ -212,6 +212,7 @@ export interface TsCacheKeyBuilder {
      */
     homeSection(channelId: string, section: VideoHomeSectionKey): string;
     recommended(): string;
+    latest(): string;
     list(): string;
   };
 
@@ -269,6 +270,7 @@ export function createTsCacheKeyBuilder(): TsCacheKeyBuilder {
       homeSection: (channelId, section) =>
         CacheKeys.appVideoHomeSectionKey(channelId, section),
       recommended: () => CacheKeys.appRecommendedVideos,
+      latest: () => CacheKeys.appVideoLatest,
       list: () => CacheKeys.appVideoList,
     },
     videoList: {

+ 3 - 0
libs/core/src/cache/cache-manager.module.ts

@@ -18,6 +18,7 @@ import { VideoCategoryCacheBuilder } from './video/category/video-category-cache
 import { VideoCategoryWarmupService } from './video/category/video-category-warmup.service';
 import { VideoListCacheBuilder } from './video/list/video-list-cache.builder';
 import { RecommendedVideosCacheBuilder } from './video/recommended/recommended-videos-cache.builder';
+import { LatestVideosCacheBuilder } from './video/latest/latest-videos-cache.builder';
 
 @Module({
   providers: [
@@ -52,6 +53,7 @@ import { RecommendedVideosCacheBuilder } from './video/recommended/recommended-v
 
     // Recommended Videos
     RecommendedVideosCacheBuilder,
+    LatestVideosCacheBuilder,
   ],
   exports: [
     AdPoolService,
@@ -66,6 +68,7 @@ import { RecommendedVideosCacheBuilder } from './video/recommended/recommended-v
     VideoCategoryCacheBuilder,
     VideoListCacheBuilder,
     RecommendedVideosCacheBuilder,
+    LatestVideosCacheBuilder,
   ],
 })
 export class CacheManagerModule {}

+ 2 - 0
libs/core/src/cache/video/index.ts

@@ -12,3 +12,5 @@ export {
   RecommendedVideosCacheBuilder,
   RecommendedVideoItem,
 } from './recommended/recommended-videos-cache.builder';
+export { LatestVideosCacheBuilder } from './latest/latest-videos-cache.builder';
+export { mapVideoToRecommendedVideoItem } from './video-item-mapper';

+ 42 - 0
libs/core/src/cache/video/latest/latest-videos-cache.builder.ts

@@ -0,0 +1,42 @@
+import { Injectable } from '@nestjs/common';
+import { BaseCacheBuilder } from '@box/common/cache/cache-builder';
+import { RedisService } from '@box/db/redis/redis.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
+import { mapVideoToRecommendedVideoItem } from '../video-item-mapper';
+
+@Injectable()
+export class LatestVideosCacheBuilder extends BaseCacheBuilder {
+  private readonly MAX_ITEMS = 300;
+
+  constructor(redis: RedisService, mongoPrisma: MongoPrismaService) {
+    super(redis, mongoPrisma, LatestVideosCacheBuilder.name);
+  }
+
+  async buildAll(): Promise<void> {
+    this.logger.log('Building latest videos cache (status=Completed)...');
+    try {
+      const videos = await this.mongoPrisma.videoMedia.findMany({
+        where: { status: 'Completed' },
+        orderBy: { updatedAt: 'desc' },
+        take: this.MAX_ITEMS,
+      });
+
+      const items = videos.map((video) =>
+        mapVideoToRecommendedVideoItem(video),
+      );
+
+      await this.redis.setJson(tsCacheKeys.video.latest(), items);
+
+      this.logger.log(
+        `Latest videos cache built: ${items.length} records stored`,
+      );
+    } catch (error) {
+      this.logger.error(
+        'Error building latest videos cache',
+        error instanceof Error ? error.stack : String(error),
+      );
+      throw error;
+    }
+  }
+}

+ 15 - 59
libs/core/src/cache/video/recommended/recommended-videos-cache.builder.ts

@@ -4,32 +4,15 @@ import { BaseCacheBuilder } from '@box/common/cache/cache-builder';
 import { RedisService } from '@box/db/redis/redis.service';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
+import {
+  mapVideoToRecommendedVideoItem,
+  RecommendedVideoItem,
+} from '../video-item-mapper';
 
-/**
- * Recommended video item structure matching homepage VideoItemDto.
- */
-export interface RecommendedVideoItem {
-  id: string;
-  title: string;
-  coverImg?: string;
-  coverImgNew?: string;
-  videoTime?: number;
-  publish?: string;
-  secondTags?: string[];
-  updatedAt?: string;
-  filename?: string;
-  fieldNameFs?: string;
-  width?: number;
-  height?: number;
-  tags?: string[];
-  preFileName?: string;
-  actors?: string[];
-  size?: string;
-}
-
+export type { RecommendedVideoItem } from '../video-item-mapper';
 /**
  * Cache builder for recommended videos (homepage).
- * Builds a Redis LIST key containing 7 random completed videos.
+ * Builds a Redis string (JSON array) containing 99 random completed videos.
  *
  * Cache key: app:video:recommended
  * Structure: JSON array of RecommendedVideoItem[]
@@ -47,11 +30,13 @@ export class RecommendedVideosCacheBuilder extends BaseCacheBuilder {
 
   /**
    * Build recommended videos cache.
-   * Fetches 7 random completed videos from MongoDB and stores in Redis.
+   * Fetches 99 random completed videos from MongoDB and stores in Redis.
    */
   async buildAll(): Promise<void> {
     try {
-      this.logger.log('Building recommended videos cache...');
+      this.logger.log(
+        'Building recommended videos cache (status=Completed)...',
+      );
 
       // Fetch random completed videos using MongoDB aggregation
       const videos = await this.mongoPrisma.videoMedia.aggregateRaw({
@@ -65,7 +50,7 @@ export class RecommendedVideosCacheBuilder extends BaseCacheBuilder {
 
       // Map to RecommendedVideoItem structure
       const items: RecommendedVideoItem[] = videoList.map((v: any) =>
-        this.mapVideoToItem(v),
+        mapVideoToRecommendedVideoItem(v),
       );
 
       // Store in Redis as JSON
@@ -90,37 +75,6 @@ export class RecommendedVideosCacheBuilder extends BaseCacheBuilder {
   }
 
   /**
-   * Map MongoDB aggregation result to RecommendedVideoItem.
-   */
-  private mapVideoToItem(video: any): RecommendedVideoItem {
-    return {
-      id: video._id?.$oid ?? video._id?.toString() ?? video.id,
-      title: video.title ?? '',
-      coverImg: video.coverImg ?? undefined,
-      coverImgNew: video.coverImgNew ?? undefined,
-      videoTime: video.videoTime ?? undefined,
-      publish: video.publish ?? undefined,
-      secondTags: Array.isArray(video.secondTags) ? video.secondTags : [],
-      updatedAt: video.updatedAt?.$date
-        ? new Date(video.updatedAt.$date).toISOString()
-        : video.updatedAt
-          ? new Date(video.updatedAt).toISOString()
-          : undefined,
-      filename: video.filename ?? undefined,
-      fieldNameFs: video.fieldNameFs ?? undefined,
-      width: video.width ?? undefined,
-      height: video.height ?? undefined,
-      tags: Array.isArray(video.tags) ? video.tags : [],
-      preFileName: video.preFileName ?? undefined,
-      actors: Array.isArray(video.actors) ? video.actors : [],
-      size:
-        video.size !== undefined && video.size !== null
-          ? String(video.size)
-          : undefined,
-    };
-  }
-
-  /**
    * Get the cache key for recommended videos.
    */
   getCacheKey(): string {
@@ -146,11 +100,13 @@ export class RecommendedVideosCacheBuilder extends BaseCacheBuilder {
         break;
       }
 
-      items.push(...batch.map((video) => this.mapVideoToItem(video)));
+      items.push(
+        ...batch.map((video) => mapVideoToRecommendedVideoItem(video)),
+      );
       lastId = batch[batch.length - 1].id;
     }
 
-    await this.redis.setJson(tsCacheKeys.video.list(), items, this.CACHE_TTL);
+    await this.redis.setJson(tsCacheKeys.video.list(), items);
     this.logger.log(
       `Video list cache built from ${items.length} completed videos`,
     );

+ 48 - 0
libs/core/src/cache/video/video-item-mapper.ts

@@ -0,0 +1,48 @@
+export interface RecommendedVideoItem {
+  id: string;
+  title: string;
+  coverImg?: string;
+  coverImgNew?: string;
+  videoTime?: number;
+  publish?: string;
+  secondTags?: string[];
+  updatedAt?: string;
+  filename?: string;
+  fieldNameFs?: string;
+  width?: number;
+  height?: number;
+  tags?: string[];
+  preFileName?: string;
+  actors?: string[];
+  size?: string;
+}
+
+export function mapVideoToRecommendedVideoItem(
+  video: any,
+): RecommendedVideoItem {
+  return {
+    id: video._id?.$oid ?? video._id?.toString() ?? video.id,
+    title: video.title ?? '',
+    coverImg: video.coverImg ?? undefined,
+    coverImgNew: video.coverImgNew ?? undefined,
+    videoTime: video.videoTime ?? undefined,
+    publish: video.publish ?? undefined,
+    secondTags: Array.isArray(video.secondTags) ? video.secondTags : [],
+    updatedAt: video.updatedAt?.$date
+      ? new Date(video.updatedAt.$date).toISOString()
+      : video.updatedAt
+        ? new Date(video.updatedAt).toISOString()
+        : undefined,
+    filename: video.filename ?? undefined,
+    fieldNameFs: video.fieldNameFs ?? undefined,
+    width: video.width ?? undefined,
+    height: video.height ?? undefined,
+    tags: Array.isArray(video.tags) ? video.tags : [],
+    preFileName: video.preFileName ?? undefined,
+    actors: Array.isArray(video.actors) ? video.actors : [],
+    size:
+      video.size !== undefined && video.size !== null
+        ? String(video.size)
+        : undefined,
+  };
+}