// box-app-api/src/feature/video/video.service.ts import { Injectable, Logger } from '@nestjs/common'; import { RedisService } from '@box/db/redis/redis.service'; import { PrismaMongoService } from '../../prisma/prisma-mongo.service'; import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider'; import type { VideoHomeSectionKey } from '@box/common/cache/ts-cache-key.provider'; import { RawVideoPayloadRow, toVideoPayload, VideoPayload, parseVideoPayload, VideoCacheHelper, } from '@box/common/cache/video-cache.helper'; import { VideoCategoryDto, VideoTagDto, VideoDetailDto, VideoPageDto, VideoCategoryWithTagsResponseDto, VideoListRequestDto, VideoListResponseDto, VideoSearchByTagRequestDto, VideoClickDto, RecommendedVideosDto, VideoItemDto, } from './dto'; import { RabbitmqPublisherService, StatsVideoClickEventPayload, } from '../../rabbitmq/rabbitmq-publisher.service'; import { randomUUID } from 'crypto'; import { nowEpochMsBigInt } from '@box/common/time/time.util'; import { CategoryType, RECOMMENDED_CATEGORY_ID, RECOMMENDED_CATEGORY_NAME, } from '../homepage/homepage.constants'; import { CategoryDto } from '../homepage/dto/homepage.dto'; import { VideoListItemDto } from './dto/video-list-response.dto'; /** * VideoService provides read-only access to video data from Redis cache. * All data is prebuilt and maintained by box-mgnt-api cache builders. * Follows the new Redis cache semantics where: * - Video list keys store video IDs only (not JSON objects) * - Tag metadata keys store tag JSON objects * - Video details are fetched separately using video IDs */ @Injectable() export class VideoService { private readonly logger = new Logger(VideoService.name); private readonly cacheHelper: VideoCacheHelper; constructor( private readonly redis: RedisService, private readonly mongoPrisma: PrismaMongoService, private readonly rabbitmqPublisher: RabbitmqPublisherService, ) { this.cacheHelper = new VideoCacheHelper(redis); } /** * Get home section videos for a channel. * Reads from appVideoHomeSectionKey (LIST of videoIds). * Returns video details for each ID. */ async getHomeSectionVideos(channelId: string): Promise { try { const channel = await this.mongoPrisma.channel.findUnique({ where: { channelId }, }); const result: { tag: string; records: VideoListItemDto[] }[] = []; for (const tag of channel.tagNames) { const records = await this.getVideoList( { random: true, tag, size: 7, }, 3600 * 24, ); result.push({ tag, records, }); } return result; } catch (err) { this.logger.error( `Error fetching home section videos for channelId=${channelId}`, err instanceof Error ? err.stack : String(err), ); return []; } } /** * 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. */ async getVideoList( dto: VideoListRequestDto, ttl?: number, ): Promise { const { page, size, tag, keyword, random } = dto; const start = (page - 1) * size; const cacheKey = `video:list:${Buffer.from(JSON.stringify(dto)).toString( 'base64', )}`; if (!ttl) { ttl = random ? 15 : 300; } let fallbackRecords: VideoListItemDto[] = []; try { const cache = await this.redis.getJson(cacheKey); if (cache) { return cache; } const where: any = { status: 'Completed', }; if (random) { if (tag) { where.secondTags = tag; } if (keyword) { where.title = { $regex: keyword, $options: 'i', }; } fallbackRecords = (await this.mongoPrisma.videoMedia.aggregateRaw({ pipeline: [ { $match: where }, { $sample: { size } }, { $project: { id: 1, title: 1, coverImg: 1, videoTime: 1, secondTags: 1, preFileName: 1, }, }, ], })) as unknown as VideoListItemDto[]; } else { if (tag) { where.secondTags = { has: tag, }; } if (keyword) { where.title = { contains: keyword, mode: 'insensitive', }; } fallbackRecords = (await this.mongoPrisma.videoMedia.findMany({ where, orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }], skip: start, take: size, select: { id: true, title: true, coverImg: true, videoTime: true, secondTags: true, preFileName: true, }, })) as VideoListItemDto[]; } if (fallbackRecords.length > 0) { await this.redis.setJson(cacheKey, fallbackRecords, ttl); } return fallbackRecords; } catch (err) { this.logger.error( `Error fetching videos from MongoDB`, err instanceof Error ? err.stack : String(err), ); return []; } } /** * Record video click event. * Publishes a stats.video.click event to RabbitMQ for analytics processing. * Uses fire-and-forget pattern for non-blocking operation. * * @param uid - User ID from JWT * @param body - Video click data from client * @param ip - Client IP address * @param userAgent - User agent string (unused but kept for compatibility) */ async recordVideoClick( uid: string, body: VideoClickDto, ip: string, userAgent: string, ): Promise { const clickedAt = nowEpochMsBigInt(); const payload: StatsVideoClickEventPayload = { messageId: randomUUID(), uid, videoId: body.videoId, clickedAt, ip, }; // Fire-and-forget: don't await, log errors asynchronously this.rabbitmqPublisher.publishStatsVideoClick(payload).catch((error) => { const message = error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : undefined; this.logger.error( `Failed to publish stats.video.click for videoId=${body.videoId}, uid=${uid}: ${message}`, stack, ); }); this.logger.debug( `Initiated stats.video.click publish for videoId=${body.videoId}, uid=${uid}`, ); } /** * Fisher-Yates shuffle for random ordering */ private shuffle(array: T[]): T[] { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } /** * Get video categories for homepage * Returns shuffled categories with "推荐" as first item */ async getCategories(): Promise { try { const categories = await this.mongoPrisma.category.findMany({ where: { status: 1, // active only }, orderBy: { seq: 'asc', }, }); // Shuffle regular categories (keep recommended first) const recommended: CategoryDto = { id: RECOMMENDED_CATEGORY_ID, name: RECOMMENDED_CATEGORY_NAME, type: CategoryType.RECOMMENDED, isDefault: true, seq: 0, }; const regular = this.shuffle( categories.map((c, idx) => ({ id: c.id, name: c.name, type: CategoryType.REGULAR, isDefault: false, seq: idx + 1, })), ); return [recommended, ...regular]; } catch (error) { this.logger.warn( 'Category collection not found or error fetching categories', ); return [ { id: RECOMMENDED_CATEGORY_ID, name: RECOMMENDED_CATEGORY_NAME, type: CategoryType.RECOMMENDED, isDefault: true, seq: 0, }, ]; } } /** * Get recommended videos (7 random videos for homepage) */ async getRecommendedVideos(): Promise { try { // Try to fetch from Redis cache first const cached = await this.redis.getJson( tsCacheKeys.video.recommended(), ); if (cached && Array.isArray(cached) && cached.length > 0) { this.logger.debug( `[getRecommendedVideos] Returning ${cached.length} videos from cache`, ); return { items: cached, total: cached.length, }; } // Fallback to MongoDB if cache miss this.logger.warn( '[getRecommendedVideos] Cache miss, falling back to MongoDB', ); const videos = await this.mongoPrisma.videoMedia.aggregateRaw({ pipeline: [ { $match: { status: 'Completed' } }, { $sample: { size: 7 } }, ], }); const items = (Array.isArray(videos) ? videos : []).map((v: any) => this.mapVideoToDto(v), ); return { items, total: items.length, }; } catch (error) { this.logger.warn('Error fetching recommended videos, returning empty'); return { items: [], total: 0, }; } } /** * Map raw video from MongoDB to VideoItemDto */ mapVideoToDto(video: any): VideoItemDto { 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) : video.updatedAt ? new Date(video.updatedAt) : 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, }; } /** * Read the cached video list key built by box-mgnt-api. */ async getVideoListFromCache(): Promise { const key = tsCacheKeys.video.list(); try { const raw = await this.redis.get(key); if (!raw) { return []; } const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { return parsed; } } catch (err) { this.logger.error( `Failed to read video list cache (${key})`, err instanceof Error ? err.stack : String(err), ); } return []; } /** * Read the cached latest video list built by box-mgnt-api. */ async getLatestVideosFromCache(): Promise { const key = tsCacheKeys.video.latest(); return this.readCachedVideoList(key, 'latest videos'); } async getRecommendedVideosFromCache(): Promise { const key = tsCacheKeys.video.recommended(); return this.readCachedVideoList(key, 'recommended videos'); } private async readCachedVideoList( key: string, label: string, ): Promise { try { const raw = await this.redis.get(key); if (!raw) { return []; } const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { return parsed; } this.logger.warn(`${label} cache (${key}) returned non-array payload`); } catch (err) { this.logger.error( `Failed to read ${label} cache (${key})`, err instanceof Error ? err.stack : String(err), ); } return []; } /** * Search the cached video list by secondTags, with fallback for videos that have no secondTags. */ async searchVideosBySecondTags(tags?: string): Promise { const videos = await this.getVideoListFromCache(); if (!tags) { return videos; } const requestedTags = tags .split(',') .map((tag) => tag.trim()) .filter((tag) => tag.length > 0); if (requestedTags.length === 0) { return videos; } const tagSet = new Set(requestedTags); return videos.filter((video) => this.matchesSecondTags(video, tagSet)); } async getGuessLikeVideos(tag: string): Promise { try { // Try to fetch from Redis cache first const cached = await this.readCachedVideoList( tsCacheKeys.video.guess() + encodeURIComponent(tag), 'guess like videos', ); if (cached && Array.isArray(cached) && cached.length > 0) { return cached; } // Fallback to MongoDB if cache miss this.logger.warn( '[getGuessLikeVideos] Cache miss, falling back to MongoDB', ); const videos = await this.mongoPrisma.videoMedia.aggregateRaw({ pipeline: [ { $match: { status: 'Completed' } }, { $sample: { size: 20 } }, ], }); const items = (Array.isArray(videos) ? videos : []).map((v: any) => this.mapVideoToDto(v), ); this.redis .setJson( tsCacheKeys.video.guess() + encodeURIComponent(tag), items, 3600, ) .catch((err) => { this.logger.warn('Redis setJson video.guess failed', err); }); return items; } catch (error) { this.logger.warn('Error fetching guess like videos, returning empty'); return []; } } private matchesSecondTags( video: VideoItemDto, filters: Set, ): boolean { const secondTags = Array.isArray(video.secondTags) ? video.secondTags .map((tag) => tag?.trim()) .filter( (tag): tag is string => typeof tag === 'string' && tag.length > 0, ) : []; if (secondTags.length === 0) { // return true; } return secondTags.some((tag) => filters.has(tag)); } }