video.service.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. // box-app-api/src/feature/video/video.service.ts
  2. import { Injectable, Logger } from '@nestjs/common';
  3. import { RedisService } from '@box/db/redis/redis.service';
  4. import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
  5. import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
  6. import type { VideoHomeSectionKey } from '@box/common/cache/ts-cache-key.provider';
  7. import {
  8. RawVideoPayloadRow,
  9. toVideoPayload,
  10. VideoPayload,
  11. parseVideoPayload,
  12. VideoCacheHelper,
  13. } from '@box/common/cache/video-cache.helper';
  14. import {
  15. VideoCategoryDto,
  16. VideoTagDto,
  17. VideoDetailDto,
  18. VideoPageDto,
  19. VideoCategoryWithTagsResponseDto,
  20. VideoListRequestDto,
  21. VideoListResponseDto,
  22. VideoSearchByTagRequestDto,
  23. VideoClickDto,
  24. RecommendedVideosDto,
  25. VideoItemDto,
  26. } from './dto';
  27. import {
  28. RabbitmqPublisherService,
  29. StatsVideoClickEventPayload,
  30. } from '../../rabbitmq/rabbitmq-publisher.service';
  31. import { randomUUID } from 'crypto';
  32. import { nowEpochMsBigInt } from '@box/common/time/time.util';
  33. import {
  34. CategoryType,
  35. RECOMMENDED_CATEGORY_ID,
  36. RECOMMENDED_CATEGORY_NAME,
  37. } from '../homepage/homepage.constants';
  38. import { CategoryDto } from '../homepage/dto/homepage.dto';
  39. import { VideoListItemDto } from './dto/video-list-response.dto';
  40. /**
  41. * VideoService provides read-only access to video data from Redis cache.
  42. * All data is prebuilt and maintained by box-mgnt-api cache builders.
  43. * Follows the new Redis cache semantics where:
  44. * - Video list keys store video IDs only (not JSON objects)
  45. * - Tag metadata keys store tag JSON objects
  46. * - Video details are fetched separately using video IDs
  47. */
  48. @Injectable()
  49. export class VideoService {
  50. private readonly logger = new Logger(VideoService.name);
  51. private readonly cacheHelper: VideoCacheHelper;
  52. constructor(
  53. private readonly redis: RedisService,
  54. private readonly mongoPrisma: PrismaMongoService,
  55. private readonly rabbitmqPublisher: RabbitmqPublisherService,
  56. ) {
  57. this.cacheHelper = new VideoCacheHelper(redis);
  58. }
  59. /**
  60. * Get home section videos for a channel.
  61. * Reads from appVideoHomeSectionKey (LIST of videoIds).
  62. * Returns video details for each ID.
  63. */
  64. async getHomeSectionVideos(channelId: string): Promise<any[]> {
  65. try {
  66. const channel = await this.mongoPrisma.channel.findUnique({
  67. where: { channelId },
  68. });
  69. const result: { tag: string; records: VideoListItemDto[] }[] = [];
  70. for (const tag of channel.tagNames) {
  71. const records = await this.getVideoList(
  72. {
  73. random: true,
  74. tag,
  75. size: 7,
  76. },
  77. 3600 * 24,
  78. );
  79. result.push({
  80. tag,
  81. records,
  82. });
  83. }
  84. return result;
  85. } catch (err) {
  86. this.logger.error(
  87. `Error fetching home section videos for channelId=${channelId}`,
  88. err instanceof Error ? err.stack : String(err),
  89. );
  90. return [];
  91. }
  92. }
  93. /**
  94. * Get paginated list of videos for a category with optional tag filtering.
  95. * Reads video IDs from Redis cache, fetches full details from MongoDB,
  96. * and returns paginated results.
  97. */
  98. async getVideoList(
  99. dto: VideoListRequestDto,
  100. ttl?: number,
  101. ): Promise<VideoListItemDto[]> {
  102. const { page, size, tag, keyword, random } = dto;
  103. const start = (page - 1) * size;
  104. const cacheKey = `video:list:${Buffer.from(JSON.stringify(dto)).toString(
  105. 'base64',
  106. )}`;
  107. if (!ttl) {
  108. ttl = random ? 15 : 300;
  109. }
  110. let fallbackRecords: VideoListItemDto[] = [];
  111. try {
  112. const cache = await this.redis.getJson<VideoListItemDto[]>(cacheKey);
  113. if (cache) {
  114. return cache;
  115. }
  116. const where: any = {
  117. status: 'Completed',
  118. };
  119. if (random) {
  120. if (tag) {
  121. where.secondTags = tag;
  122. }
  123. if (keyword) {
  124. where.title = {
  125. $regex: keyword,
  126. $options: 'i',
  127. };
  128. }
  129. fallbackRecords = (await this.mongoPrisma.videoMedia.aggregateRaw({
  130. pipeline: [
  131. { $match: where },
  132. { $sample: { size } },
  133. {
  134. $project: {
  135. id: 1,
  136. title: 1,
  137. coverImg: 1,
  138. videoTime: 1,
  139. secondTags: 1,
  140. preFileName: 1,
  141. },
  142. },
  143. ],
  144. })) as unknown as VideoListItemDto[];
  145. } else {
  146. if (tag) {
  147. where.secondTags = {
  148. has: tag,
  149. };
  150. }
  151. if (keyword) {
  152. where.title = {
  153. contains: keyword,
  154. mode: 'insensitive',
  155. };
  156. }
  157. fallbackRecords = (await this.mongoPrisma.videoMedia.findMany({
  158. where,
  159. orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
  160. skip: start,
  161. take: size,
  162. select: {
  163. id: true,
  164. title: true,
  165. coverImg: true,
  166. videoTime: true,
  167. secondTags: true,
  168. preFileName: true,
  169. },
  170. })) as VideoListItemDto[];
  171. }
  172. if (fallbackRecords.length > 0) {
  173. await this.redis.setJson(cacheKey, fallbackRecords, ttl);
  174. }
  175. return fallbackRecords;
  176. } catch (err) {
  177. this.logger.error(
  178. `Error fetching videos from MongoDB`,
  179. err instanceof Error ? err.stack : String(err),
  180. );
  181. return [];
  182. }
  183. }
  184. /**
  185. * Record video click event.
  186. * Publishes a stats.video.click event to RabbitMQ for analytics processing.
  187. * Uses fire-and-forget pattern for non-blocking operation.
  188. *
  189. * @param uid - User ID from JWT
  190. * @param body - Video click data from client
  191. * @param ip - Client IP address
  192. * @param userAgent - User agent string (unused but kept for compatibility)
  193. */
  194. async recordVideoClick(
  195. uid: string,
  196. body: VideoClickDto,
  197. ip: string,
  198. userAgent: string,
  199. ): Promise<void> {
  200. const clickedAt = nowEpochMsBigInt();
  201. const payload: StatsVideoClickEventPayload = {
  202. messageId: randomUUID(),
  203. uid,
  204. videoId: body.videoId,
  205. clickedAt,
  206. ip,
  207. };
  208. // Fire-and-forget: don't await, log errors asynchronously
  209. this.rabbitmqPublisher.publishStatsVideoClick(payload).catch((error) => {
  210. const message = error instanceof Error ? error.message : String(error);
  211. const stack = error instanceof Error ? error.stack : undefined;
  212. this.logger.error(
  213. `Failed to publish stats.video.click for videoId=${body.videoId}, uid=${uid}: ${message}`,
  214. stack,
  215. );
  216. });
  217. this.logger.debug(
  218. `Initiated stats.video.click publish for videoId=${body.videoId}, uid=${uid}`,
  219. );
  220. }
  221. /**
  222. * Fisher-Yates shuffle for random ordering
  223. */
  224. private shuffle<T>(array: T[]): T[] {
  225. const shuffled = [...array];
  226. for (let i = shuffled.length - 1; i > 0; i--) {
  227. const j = Math.floor(Math.random() * (i + 1));
  228. [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  229. }
  230. return shuffled;
  231. }
  232. /**
  233. * Get video categories for homepage
  234. * Returns shuffled categories with "推荐" as first item
  235. */
  236. async getCategories(): Promise<CategoryDto[]> {
  237. try {
  238. const categories = await this.mongoPrisma.category.findMany({
  239. where: {
  240. status: 1, // active only
  241. },
  242. orderBy: {
  243. seq: 'asc',
  244. },
  245. });
  246. // Shuffle regular categories (keep recommended first)
  247. const recommended: CategoryDto = {
  248. id: RECOMMENDED_CATEGORY_ID,
  249. name: RECOMMENDED_CATEGORY_NAME,
  250. type: CategoryType.RECOMMENDED,
  251. isDefault: true,
  252. seq: 0,
  253. };
  254. const regular = this.shuffle(
  255. categories.map((c, idx) => ({
  256. id: c.id,
  257. name: c.name,
  258. type: CategoryType.REGULAR,
  259. isDefault: false,
  260. seq: idx + 1,
  261. })),
  262. );
  263. return [recommended, ...regular];
  264. } catch (error) {
  265. this.logger.warn(
  266. 'Category collection not found or error fetching categories',
  267. );
  268. return [
  269. {
  270. id: RECOMMENDED_CATEGORY_ID,
  271. name: RECOMMENDED_CATEGORY_NAME,
  272. type: CategoryType.RECOMMENDED,
  273. isDefault: true,
  274. seq: 0,
  275. },
  276. ];
  277. }
  278. }
  279. /**
  280. * Get recommended videos (7 random videos for homepage)
  281. */
  282. async getRecommendedVideos(): Promise<RecommendedVideosDto> {
  283. try {
  284. // Try to fetch from Redis cache first
  285. const cached = await this.redis.getJson<VideoItemDto[]>(
  286. tsCacheKeys.video.recommended(),
  287. );
  288. if (cached && Array.isArray(cached) && cached.length > 0) {
  289. this.logger.debug(
  290. `[getRecommendedVideos] Returning ${cached.length} videos from cache`,
  291. );
  292. return {
  293. items: cached,
  294. total: cached.length,
  295. };
  296. }
  297. // Fallback to MongoDB if cache miss
  298. this.logger.warn(
  299. '[getRecommendedVideos] Cache miss, falling back to MongoDB',
  300. );
  301. const videos = await this.mongoPrisma.videoMedia.aggregateRaw({
  302. pipeline: [
  303. { $match: { status: 'Completed' } },
  304. { $sample: { size: 7 } },
  305. ],
  306. });
  307. const items = (Array.isArray(videos) ? videos : []).map((v: any) =>
  308. this.mapVideoToDto(v),
  309. );
  310. return {
  311. items,
  312. total: items.length,
  313. };
  314. } catch (error) {
  315. this.logger.warn('Error fetching recommended videos, returning empty');
  316. return {
  317. items: [],
  318. total: 0,
  319. };
  320. }
  321. }
  322. /**
  323. * Map raw video from MongoDB to VideoItemDto
  324. */
  325. mapVideoToDto(video: any): VideoItemDto {
  326. return {
  327. id: video._id?.$oid ?? video._id?.toString() ?? video.id,
  328. title: video.title ?? '',
  329. coverImg: video.coverImg ?? undefined,
  330. coverImgNew: video.coverImgNew ?? undefined,
  331. videoTime: video.videoTime ?? undefined,
  332. publish: video.publish ?? undefined,
  333. secondTags: Array.isArray(video.secondTags) ? video.secondTags : [],
  334. updatedAt: video.updatedAt?.$date
  335. ? new Date(video.updatedAt.$date)
  336. : video.updatedAt
  337. ? new Date(video.updatedAt)
  338. : undefined,
  339. filename: video.filename ?? undefined,
  340. fieldNameFs: video.fieldNameFs ?? undefined,
  341. width: video.width ?? undefined,
  342. height: video.height ?? undefined,
  343. tags: Array.isArray(video.tags) ? video.tags : [],
  344. preFileName: video.preFileName ?? undefined,
  345. actors: Array.isArray(video.actors) ? video.actors : [],
  346. size:
  347. video.size !== undefined && video.size !== null
  348. ? String(video.size)
  349. : undefined,
  350. };
  351. }
  352. /**
  353. * Read the cached video list key built by box-mgnt-api.
  354. */
  355. async getVideoListFromCache(): Promise<VideoItemDto[]> {
  356. const key = tsCacheKeys.video.list();
  357. try {
  358. const raw = await this.redis.get(key);
  359. if (!raw) {
  360. return [];
  361. }
  362. const parsed = JSON.parse(raw);
  363. if (Array.isArray(parsed)) {
  364. return parsed;
  365. }
  366. } catch (err) {
  367. this.logger.error(
  368. `Failed to read video list cache (${key})`,
  369. err instanceof Error ? err.stack : String(err),
  370. );
  371. }
  372. return [];
  373. }
  374. /**
  375. * Read the cached latest video list built by box-mgnt-api.
  376. */
  377. async getLatestVideosFromCache(): Promise<VideoItemDto[]> {
  378. const key = tsCacheKeys.video.latest();
  379. return this.readCachedVideoList(key, 'latest videos');
  380. }
  381. async getRecommendedVideosFromCache(): Promise<VideoItemDto[]> {
  382. const key = tsCacheKeys.video.recommended();
  383. return this.readCachedVideoList(key, 'recommended videos');
  384. }
  385. private async readCachedVideoList(
  386. key: string,
  387. label: string,
  388. ): Promise<VideoItemDto[]> {
  389. try {
  390. const raw = await this.redis.get(key);
  391. if (!raw) {
  392. return [];
  393. }
  394. const parsed = JSON.parse(raw);
  395. if (Array.isArray(parsed)) {
  396. return parsed;
  397. }
  398. this.logger.warn(`${label} cache (${key}) returned non-array payload`);
  399. } catch (err) {
  400. this.logger.error(
  401. `Failed to read ${label} cache (${key})`,
  402. err instanceof Error ? err.stack : String(err),
  403. );
  404. }
  405. return [];
  406. }
  407. /**
  408. * Search the cached video list by secondTags, with fallback for videos that have no secondTags.
  409. */
  410. async searchVideosBySecondTags(tags?: string): Promise<VideoItemDto[]> {
  411. const videos = await this.getVideoListFromCache();
  412. if (!tags) {
  413. return videos;
  414. }
  415. const requestedTags = tags
  416. .split(',')
  417. .map((tag) => tag.trim())
  418. .filter((tag) => tag.length > 0);
  419. if (requestedTags.length === 0) {
  420. return videos;
  421. }
  422. const tagSet = new Set(requestedTags);
  423. return videos.filter((video) => this.matchesSecondTags(video, tagSet));
  424. }
  425. async getGuessLikeVideos(tag: string): Promise<VideoItemDto[]> {
  426. try {
  427. // Try to fetch from Redis cache first
  428. const cached = await this.readCachedVideoList(
  429. tsCacheKeys.video.guess() + encodeURIComponent(tag),
  430. 'guess like videos',
  431. );
  432. if (cached && Array.isArray(cached) && cached.length > 0) {
  433. return cached;
  434. }
  435. // Fallback to MongoDB if cache miss
  436. this.logger.warn(
  437. '[getGuessLikeVideos] Cache miss, falling back to MongoDB',
  438. );
  439. const videos = await this.mongoPrisma.videoMedia.aggregateRaw({
  440. pipeline: [
  441. { $match: { status: 'Completed' } },
  442. { $sample: { size: 20 } },
  443. ],
  444. });
  445. const items = (Array.isArray(videos) ? videos : []).map((v: any) =>
  446. this.mapVideoToDto(v),
  447. );
  448. this.redis
  449. .setJson(
  450. tsCacheKeys.video.guess() + encodeURIComponent(tag),
  451. items,
  452. 3600,
  453. )
  454. .catch((err) => {
  455. this.logger.warn('Redis setJson video.guess failed', err);
  456. });
  457. return items;
  458. } catch (error) {
  459. this.logger.warn('Error fetching guess like videos, returning empty');
  460. return [];
  461. }
  462. }
  463. private matchesSecondTags(
  464. video: VideoItemDto,
  465. filters: Set<string>,
  466. ): boolean {
  467. const secondTags = Array.isArray(video.secondTags)
  468. ? video.secondTags
  469. .map((tag) => tag?.trim())
  470. .filter(
  471. (tag): tag is string => typeof tag === 'string' && tag.length > 0,
  472. )
  473. : [];
  474. if (secondTags.length === 0) {
  475. // return true;
  476. }
  477. return secondTags.some((tag) => filters.has(tag));
  478. }
  479. }