| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- // 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<any[]> {
- 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<VideoListItemDto[]> {
- 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<VideoListItemDto[]>(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<void> {
- 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<T>(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<CategoryDto[]> {
- 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<RecommendedVideosDto> {
- try {
- // Try to fetch from Redis cache first
- const cached = await this.redis.getJson<VideoItemDto[]>(
- 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<VideoItemDto[]> {
- 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<VideoItemDto[]> {
- const key = tsCacheKeys.video.latest();
- return this.readCachedVideoList(key, 'latest videos');
- }
- async getRecommendedVideosFromCache(): Promise<VideoItemDto[]> {
- const key = tsCacheKeys.video.recommended();
- return this.readCachedVideoList(key, 'recommended videos');
- }
- private async readCachedVideoList(
- key: string,
- label: string,
- ): Promise<VideoItemDto[]> {
- 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<VideoItemDto[]> {
- 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<VideoItemDto[]> {
- 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<string>,
- ): 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));
- }
- }
|