video.service.ts 14 KB

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