video-cache.helper.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import { Injectable, Logger } from '@nestjs/common';
  2. import { RedisService } from '@box/db/redis/redis.service';
  3. /**
  4. * Tag metadata interface for parsing from cache.
  5. * Matches the structure stored in box:app:tag:list:{categoryId} keys.
  6. *
  7. * This represents Tag entities, NOT Category entities.
  8. * Tags do not have a subtitle field (that's in Category).
  9. * NOTE: Tags no longer have channelId (removed from schema).
  10. */
  11. export interface TagMetadata {
  12. id: string;
  13. name: string;
  14. seq: number;
  15. status: number;
  16. createAt: string; // ISO string or numeric string
  17. updateAt: string; // ISO string or numeric string
  18. categoryId: string;
  19. }
  20. export interface VideoPayload {
  21. id: string;
  22. title: string;
  23. coverImg: string;
  24. coverImgNew: string;
  25. videoTime: number;
  26. country: string;
  27. firstTag: string;
  28. secondTags: string[];
  29. preFileName: string;
  30. desc: string;
  31. size: string;
  32. updatedAt: string;
  33. filename: string;
  34. fieldNameFs: string;
  35. ext: string;
  36. }
  37. export interface RawVideoPayloadRow {
  38. id: string;
  39. title: string;
  40. coverImg: string;
  41. coverImgNew: string;
  42. videoTime: number;
  43. country: string;
  44. firstTag: string;
  45. secondTags: string[];
  46. preFileName: string;
  47. desc: string;
  48. size: bigint;
  49. updatedAt: Date;
  50. filename: string;
  51. fieldNameFs: string;
  52. ext: string;
  53. }
  54. export function toVideoPayload(row: RawVideoPayloadRow): VideoPayload {
  55. return {
  56. id: row.id,
  57. title: row.title ?? '',
  58. coverImg: row.coverImg ?? '',
  59. coverImgNew: row.coverImgNew ?? '',
  60. videoTime: row.videoTime ?? 0,
  61. country: row.country ?? '',
  62. firstTag: row.firstTag ?? '',
  63. secondTags: Array.isArray(row.secondTags) ? row.secondTags : [],
  64. preFileName: row.preFileName ?? '',
  65. desc: row.desc ?? '',
  66. size: row.size?.toString() ?? '0',
  67. updatedAt: row.updatedAt?.toISOString() ?? new Date().toISOString(),
  68. filename: row.filename ?? '',
  69. fieldNameFs: row.fieldNameFs ?? '',
  70. ext: row.ext ?? '',
  71. };
  72. }
  73. export function parseVideoPayload(value: string | null): VideoPayload | null {
  74. if (!value) return null;
  75. try {
  76. return JSON.parse(value) as VideoPayload;
  77. } catch {
  78. return null;
  79. }
  80. }
  81. /**
  82. * VideoCacheHelper provides type-safe, centralized Redis operations for video cache keys.
  83. *
  84. * KEY TYPE EXPECTATIONS:
  85. * - Video ID lists (category, tag, pool): Redis LIST containing video IDs (24-char hex strings)
  86. * - Tag metadata lists: Redis LIST containing Tag JSON objects
  87. * - Video details: Redis STRING containing Video JSON object
  88. *
  89. * This helper ensures:
  90. * 1. Consistent Redis command usage (LIST commands for lists, not GET/SET)
  91. * 2. Type safety for video IDs vs metadata
  92. * 3. Centralized error handling and logging
  93. * 4. TTL management
  94. *
  95. * @example
  96. * // Save video IDs to a category list
  97. * await helper.saveVideoIdList('box:app:video:category:list:123', ['vid1', 'vid2'], 3600);
  98. *
  99. * // Read video IDs with pagination
  100. * const videoIds = await helper.getVideoIdList('box:app:video:category:list:123', 0, 19);
  101. *
  102. * // Read tag metadata
  103. * const tags = await helper.getTagListForCategory('box:app:tag:list:123');
  104. */
  105. @Injectable()
  106. export class VideoCacheHelper {
  107. private readonly logger = new Logger(VideoCacheHelper.name);
  108. constructor(private readonly redis: RedisService) {}
  109. /**
  110. * Save a list of video IDs to a Redis LIST key.
  111. *
  112. * REDIS OPERATIONS:
  113. * 1. DEL key (remove existing data)
  114. * 2. RPUSH key videoId1 videoId2 ... (push all IDs)
  115. * 3. EXPIRE key ttlSeconds (set TTL if provided)
  116. *
  117. * KEY TYPE: LIST
  118. * VALUE TYPE: Video IDs (24-character hex strings, MongoDB ObjectId format)
  119. *
  120. * @param key - Redis key (e.g., 'box:app:video:category:list:{categoryId}')
  121. * @param videoIds - Array of video IDs to store
  122. * @param ttlSeconds - Optional TTL in seconds (no expiration if omitted)
  123. *
  124. * @throws Error if Redis operations fail
  125. *
  126. * @example
  127. * await helper.saveVideoIdList(
  128. * 'box:app:video:category:list:abc123',
  129. * ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'],
  130. * 3600
  131. * );
  132. */
  133. async saveVideoIdList(
  134. key: string,
  135. videoIds: string[],
  136. ttlSeconds?: number,
  137. ): Promise<void> {
  138. try {
  139. if (!videoIds || videoIds.length === 0) {
  140. await this.redis.del(key);
  141. this.logger.debug(`[SaveVideoIdList] Cleared empty key: ${key}`);
  142. return;
  143. }
  144. // Delete existing key first
  145. await this.redis.del(key);
  146. // Push all video IDs to the list
  147. await this.redis.rpush(key, ...videoIds);
  148. // Set TTL if provided
  149. if (ttlSeconds && ttlSeconds > 0) {
  150. await this.redis.expire(key, ttlSeconds);
  151. }
  152. this.logger.debug(
  153. `[SaveVideoIdList] Saved key=${key} count=${videoIds.length} ttl=${ttlSeconds ? ttlSeconds + 's' : 'none'}`,
  154. );
  155. } catch (err) {
  156. this.logger.error(
  157. `[SaveVideoIdList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
  158. err instanceof Error ? err.stack : undefined,
  159. );
  160. throw err;
  161. }
  162. }
  163. /**
  164. * Get a range of video IDs from a Redis LIST key.
  165. *
  166. * REDIS OPERATION: LRANGE key start stop
  167. * KEY TYPE: LIST
  168. * VALUE TYPE: Video IDs (24-character hex strings)
  169. *
  170. * @param key - Redis key (e.g., 'box:app:video:category:list:{categoryId}')
  171. * @param start - Start index (0-based, default 0)
  172. * @param stop - Stop index (-1 for all, default -1)
  173. * @returns Array of video IDs
  174. *
  175. * @example
  176. * // Get all video IDs
  177. * const allIds = await helper.getVideoIdList('box:app:video:category:list:abc123');
  178. *
  179. * // Get first 20 video IDs (pagination)
  180. * const pageIds = await helper.getVideoIdList('box:app:video:category:list:abc123', 0, 19);
  181. */
  182. async getVideoIdList(key: string, start = 0, stop = -1): Promise<string[]> {
  183. try {
  184. const videoIds = await this.readListRangeWithLegacy(
  185. key,
  186. start,
  187. stop,
  188. async (legacyData) => {
  189. await this.saveVideoIdList(key, legacyData);
  190. },
  191. );
  192. return videoIds;
  193. } catch (err) {
  194. this.logger.error(
  195. `Failed to get video ID list from ${key}`,
  196. err instanceof Error ? err.stack : String(err),
  197. );
  198. return [];
  199. }
  200. }
  201. /**
  202. * Get the length of a video ID list.
  203. *
  204. * REDIS OPERATION: LLEN key
  205. * KEY TYPE: LIST
  206. *
  207. * @param key - Redis key
  208. * @returns Number of items in the list
  209. *
  210. * @example
  211. * const count = await helper.getVideoIdListLength('box:app:video:category:list:abc123');
  212. */
  213. async getVideoIdListLength(key: string): Promise<number> {
  214. try {
  215. const length = await this.redis.llen(key);
  216. if (length > 0) {
  217. return length;
  218. }
  219. const legacyList = await this.readListRangeWithLegacy(
  220. key,
  221. 0,
  222. -1,
  223. async (legacyData) => {
  224. await this.saveVideoIdList(key, legacyData);
  225. },
  226. );
  227. return legacyList.length;
  228. } catch (err) {
  229. this.logger.error(
  230. `Failed to get list length for ${key}`,
  231. err instanceof Error ? err.stack : String(err),
  232. );
  233. return 0;
  234. }
  235. }
  236. /**
  237. * Save tag metadata list to Redis.
  238. *
  239. * REDIS OPERATIONS:
  240. * 1. DEL key
  241. * 2. RPUSH key (JSON.stringify(tag1)) (JSON.stringify(tag2)) ...
  242. * 3. EXPIRE key ttlSeconds (if provided)
  243. *
  244. * KEY TYPE: LIST
  245. * VALUE TYPE: Tag JSON objects (stringified)
  246. *
  247. * @param key - Redis key (e.g., 'box:app:tag:list:{categoryId}')
  248. * @param tags - Array of tag metadata objects
  249. * @param ttlSeconds - Optional TTL in seconds
  250. *
  251. * @example
  252. * await helper.saveTagList('box:app:tag:list:abc123', [
  253. * { id: '1', name: 'Action', seq: 1, ... },
  254. * { id: '2', name: 'Drama', seq: 2, ... }
  255. * ], 3600);
  256. */
  257. async saveTagList(
  258. key: string,
  259. tags: TagMetadata[],
  260. ttlSeconds?: number,
  261. ): Promise<void> {
  262. try {
  263. if (!tags || tags.length === 0) {
  264. await this.redis.del(key);
  265. this.logger.debug(`[SaveTagList] Cleared empty key: ${key}`);
  266. return;
  267. }
  268. // Delete existing key
  269. await this.redis.del(key);
  270. // Push all tags as JSON strings
  271. const tagJsonStrings = tags.map((tag) => JSON.stringify(tag));
  272. await this.redis.rpush(key, ...tagJsonStrings);
  273. // Set TTL if provided
  274. if (ttlSeconds && ttlSeconds > 0) {
  275. await this.redis.expire(key, ttlSeconds);
  276. }
  277. this.logger.debug(
  278. `[SaveTagList] Saved key=${key} count=${tags.length} ttl=${ttlSeconds ? ttlSeconds + 's' : 'none'}`,
  279. );
  280. } catch (err) {
  281. this.logger.error(
  282. `[SaveTagList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
  283. err instanceof Error ? err.stack : undefined,
  284. );
  285. throw err;
  286. }
  287. }
  288. /**
  289. * Get tag metadata list for a category.
  290. *
  291. * REDIS OPERATION: LRANGE key 0 -1
  292. * KEY TYPE: LIST
  293. * VALUE TYPE: Tag JSON objects (stringified)
  294. *
  295. * Parses each JSON string into a TagMetadata object.
  296. * Skips items that fail to parse (logs warning).
  297. *
  298. * @param key - Redis key (e.g., 'box:app:tag:list:{categoryId}')
  299. * @returns Array of parsed tag metadata objects
  300. *
  301. * @example
  302. * const tags = await helper.getTagListForCategory('box:app:tag:list:abc123');
  303. * // Returns: [{ id: '1', name: 'Action', ... }, { id: '2', name: 'Drama', ... }]
  304. */
  305. async getTagListForCategory(key: string): Promise<TagMetadata[]> {
  306. try {
  307. const items = await this.readListRangeWithLegacy(
  308. key,
  309. 0,
  310. -1,
  311. async (legacyData) => {
  312. const tags = this.parseTagMetadataStrings(legacyData, key);
  313. if (tags.length > 0) {
  314. await this.saveTagList(key, tags);
  315. }
  316. },
  317. );
  318. return this.parseTagMetadataStrings(items, key);
  319. } catch (err) {
  320. this.logger.error(
  321. `[GetTagList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
  322. err instanceof Error ? err.stack : undefined,
  323. );
  324. return [];
  325. }
  326. }
  327. /**
  328. * Check if a key exists in Redis.
  329. *
  330. * REDIS OPERATION: EXISTS key
  331. *
  332. * @param key - Redis key to check
  333. * @returns true if key exists, false otherwise
  334. *
  335. * @example
  336. * const exists = await helper.keyExists('box:app:video:category:list:abc123');
  337. */
  338. async keyExists(key: string): Promise<boolean> {
  339. try {
  340. const result = await this.redis.exists(key);
  341. return result > 0;
  342. } catch (err) {
  343. this.logger.error(
  344. `Failed to check existence of ${key}`,
  345. err instanceof Error ? err.stack : String(err),
  346. );
  347. return false;
  348. }
  349. }
  350. /**
  351. * Delete a key from Redis.
  352. *
  353. * REDIS OPERATION: DEL key
  354. *
  355. * @param key - Redis key to delete
  356. * @returns Number of keys deleted (0 or 1)
  357. *
  358. * @example
  359. * await helper.deleteKey('box:app:video:category:list:abc123');
  360. */
  361. async deleteKey(key: string): Promise<number> {
  362. try {
  363. return await this.redis.del(key);
  364. } catch (err) {
  365. this.logger.error(
  366. `Failed to delete key ${key}`,
  367. err instanceof Error ? err.stack : String(err),
  368. );
  369. return 0;
  370. }
  371. }
  372. private legacyPrefixedKey(key: string): string {
  373. return `box:${key}`;
  374. }
  375. private async readListRangeWithLegacy(
  376. key: string,
  377. start: number,
  378. stop: number,
  379. migrate: (legacyData: string[]) => Promise<void>,
  380. ): Promise<string[]> {
  381. const result = await this.redis.lrange(key, start, stop);
  382. if (result.length > 0) {
  383. return result;
  384. }
  385. const legacyKey = this.legacyPrefixedKey(key);
  386. const legacyData = await this.redis.lrange(legacyKey, start, stop);
  387. if (!legacyData.length) {
  388. return [];
  389. }
  390. this.logger.warn(
  391. `[VideoCacheHelper] Legacy key triggered: ${legacyKey}, rehydrating ${key}`,
  392. );
  393. await migrate(legacyData);
  394. return legacyData;
  395. }
  396. private parseTagMetadataStrings(raw: string[], key: string): TagMetadata[] {
  397. const tags: TagMetadata[] = [];
  398. let malformedCount = 0;
  399. for (const item of raw) {
  400. try {
  401. const parsed = JSON.parse(item) as TagMetadata;
  402. tags.push(parsed);
  403. } catch {
  404. malformedCount++;
  405. }
  406. }
  407. if (malformedCount > 0) {
  408. this.logger.warn(
  409. `[GetTagList] key=${key}: parsed ${tags.length} tags, skipped ${malformedCount} malformed items`,
  410. );
  411. } else {
  412. this.logger.debug(
  413. `[GetTagList] key=${key}: parsed ${tags.length} tags`,
  414. );
  415. }
  416. return tags;
  417. }
  418. }