| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- import { Injectable, Logger } from '@nestjs/common';
- import { RedisService } from '@box/db/redis/redis.service';
- /**
- * Tag metadata interface for parsing from cache.
- * Matches the structure stored in box:app:tag:list:{categoryId} keys.
- *
- * This represents Tag entities, NOT Category entities.
- * Tags do not have a subtitle field (that's in Category).
- * NOTE: Tags no longer have channelId (removed from schema).
- */
- export interface TagMetadata {
- id: string;
- name: string;
- seq: number;
- status: number;
- createAt: string; // ISO string or numeric string
- updateAt: string; // ISO string or numeric string
- categoryId: string;
- }
- export interface VideoPayload {
- id: string;
- title: string;
- coverImg: string;
- coverImgNew: string;
- videoTime: number;
- country: string;
- firstTag: string;
- secondTags: string[];
- preFileName: string;
- desc: string;
- size: string;
- updatedAt: string;
- filename: string;
- fieldNameFs: string;
- ext: string;
- }
- export interface RawVideoPayloadRow {
- id: string;
- title: string;
- coverImg: string;
- coverImgNew: string;
- videoTime: number;
- country: string;
- firstTag: string;
- secondTags: string[];
- preFileName: string;
- desc: string;
- size: bigint;
- updatedAt: Date;
- filename: string;
- fieldNameFs: string;
- ext: string;
- }
- export function toVideoPayload(row: RawVideoPayloadRow): VideoPayload {
- return {
- id: row.id,
- title: row.title ?? '',
- coverImg: row.coverImg ?? '',
- coverImgNew: row.coverImgNew ?? '',
- videoTime: row.videoTime ?? 0,
- country: row.country ?? '',
- firstTag: row.firstTag ?? '',
- secondTags: Array.isArray(row.secondTags) ? row.secondTags : [],
- preFileName: row.preFileName ?? '',
- desc: row.desc ?? '',
- size: row.size?.toString() ?? '0',
- updatedAt: row.updatedAt?.toISOString() ?? new Date().toISOString(),
- filename: row.filename ?? '',
- fieldNameFs: row.fieldNameFs ?? '',
- ext: row.ext ?? '',
- };
- }
- export function parseVideoPayload(value: string | null): VideoPayload | null {
- if (!value) return null;
- try {
- return JSON.parse(value) as VideoPayload;
- } catch {
- return null;
- }
- }
- /**
- * VideoCacheHelper provides type-safe, centralized Redis operations for video cache keys.
- *
- * KEY TYPE EXPECTATIONS:
- * - Video ID lists (category, tag, pool): Redis LIST containing video IDs (24-char hex strings)
- * - Tag metadata lists: Redis LIST containing Tag JSON objects
- * - Video details: Redis STRING containing Video JSON object
- *
- * This helper ensures:
- * 1. Consistent Redis command usage (LIST commands for lists, not GET/SET)
- * 2. Type safety for video IDs vs metadata
- * 3. Centralized error handling and logging
- * 4. TTL management
- *
- * @example
- * // Save video IDs to a category list
- * await helper.saveVideoIdList('box:app:video:category:list:123', ['vid1', 'vid2'], 3600);
- *
- * // Read video IDs with pagination
- * const videoIds = await helper.getVideoIdList('box:app:video:category:list:123', 0, 19);
- *
- * // Read tag metadata
- * const tags = await helper.getTagListForCategory('box:app:tag:list:123');
- */
- @Injectable()
- export class VideoCacheHelper {
- private readonly logger = new Logger(VideoCacheHelper.name);
- constructor(private readonly redis: RedisService) {}
- /**
- * Save a list of video IDs to a Redis LIST key.
- *
- * REDIS OPERATIONS:
- * 1. DEL key (remove existing data)
- * 2. RPUSH key videoId1 videoId2 ... (push all IDs)
- * 3. EXPIRE key ttlSeconds (set TTL if provided)
- *
- * KEY TYPE: LIST
- * VALUE TYPE: Video IDs (24-character hex strings, MongoDB ObjectId format)
- *
- * @param key - Redis key (e.g., 'box:app:video:category:list:{categoryId}')
- * @param videoIds - Array of video IDs to store
- * @param ttlSeconds - Optional TTL in seconds (no expiration if omitted)
- *
- * @throws Error if Redis operations fail
- *
- * @example
- * await helper.saveVideoIdList(
- * 'box:app:video:category:list:abc123',
- * ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'],
- * 3600
- * );
- */
- async saveVideoIdList(
- key: string,
- videoIds: string[],
- ttlSeconds?: number,
- ): Promise<void> {
- try {
- if (!videoIds || videoIds.length === 0) {
- await this.redis.del(key);
- this.logger.debug(`[SaveVideoIdList] Cleared empty key: ${key}`);
- return;
- }
- // Delete existing key first
- await this.redis.del(key);
- // Push all video IDs to the list
- await this.redis.rpush(key, ...videoIds);
- // Set TTL if provided
- if (ttlSeconds && ttlSeconds > 0) {
- await this.redis.expire(key, ttlSeconds);
- }
- this.logger.debug(
- `[SaveVideoIdList] Saved key=${key} count=${videoIds.length} ttl=${ttlSeconds ? ttlSeconds + 's' : 'none'}`,
- );
- } catch (err) {
- this.logger.error(
- `[SaveVideoIdList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
- err instanceof Error ? err.stack : undefined,
- );
- throw err;
- }
- }
- /**
- * Get a range of video IDs from a Redis LIST key.
- *
- * REDIS OPERATION: LRANGE key start stop
- * KEY TYPE: LIST
- * VALUE TYPE: Video IDs (24-character hex strings)
- *
- * @param key - Redis key (e.g., 'box:app:video:category:list:{categoryId}')
- * @param start - Start index (0-based, default 0)
- * @param stop - Stop index (-1 for all, default -1)
- * @returns Array of video IDs
- *
- * @example
- * // Get all video IDs
- * const allIds = await helper.getVideoIdList('box:app:video:category:list:abc123');
- *
- * // Get first 20 video IDs (pagination)
- * const pageIds = await helper.getVideoIdList('box:app:video:category:list:abc123', 0, 19);
- */
- async getVideoIdList(key: string, start = 0, stop = -1): Promise<string[]> {
- try {
- const videoIds = await this.readListRangeWithLegacy(
- key,
- start,
- stop,
- async (legacyData) => {
- await this.saveVideoIdList(key, legacyData);
- },
- );
- return videoIds;
- } catch (err) {
- this.logger.error(
- `Failed to get video ID list from ${key}`,
- err instanceof Error ? err.stack : String(err),
- );
- return [];
- }
- }
- /**
- * Get the length of a video ID list.
- *
- * REDIS OPERATION: LLEN key
- * KEY TYPE: LIST
- *
- * @param key - Redis key
- * @returns Number of items in the list
- *
- * @example
- * const count = await helper.getVideoIdListLength('box:app:video:category:list:abc123');
- */
- async getVideoIdListLength(key: string): Promise<number> {
- try {
- const length = await this.redis.llen(key);
- if (length > 0) {
- return length;
- }
- const legacyList = await this.readListRangeWithLegacy(
- key,
- 0,
- -1,
- async (legacyData) => {
- await this.saveVideoIdList(key, legacyData);
- },
- );
- return legacyList.length;
- } catch (err) {
- this.logger.error(
- `Failed to get list length for ${key}`,
- err instanceof Error ? err.stack : String(err),
- );
- return 0;
- }
- }
- /**
- * Save tag metadata list to Redis.
- *
- * REDIS OPERATIONS:
- * 1. DEL key
- * 2. RPUSH key (JSON.stringify(tag1)) (JSON.stringify(tag2)) ...
- * 3. EXPIRE key ttlSeconds (if provided)
- *
- * KEY TYPE: LIST
- * VALUE TYPE: Tag JSON objects (stringified)
- *
- * @param key - Redis key (e.g., 'box:app:tag:list:{categoryId}')
- * @param tags - Array of tag metadata objects
- * @param ttlSeconds - Optional TTL in seconds
- *
- * @example
- * await helper.saveTagList('box:app:tag:list:abc123', [
- * { id: '1', name: 'Action', seq: 1, ... },
- * { id: '2', name: 'Drama', seq: 2, ... }
- * ], 3600);
- */
- async saveTagList(
- key: string,
- tags: TagMetadata[],
- ttlSeconds?: number,
- ): Promise<void> {
- try {
- if (!tags || tags.length === 0) {
- await this.redis.del(key);
- this.logger.debug(`[SaveTagList] Cleared empty key: ${key}`);
- return;
- }
- // Delete existing key
- await this.redis.del(key);
- // Push all tags as JSON strings
- const tagJsonStrings = tags.map((tag) => JSON.stringify(tag));
- await this.redis.rpush(key, ...tagJsonStrings);
- // Set TTL if provided
- if (ttlSeconds && ttlSeconds > 0) {
- await this.redis.expire(key, ttlSeconds);
- }
- this.logger.debug(
- `[SaveTagList] Saved key=${key} count=${tags.length} ttl=${ttlSeconds ? ttlSeconds + 's' : 'none'}`,
- );
- } catch (err) {
- this.logger.error(
- `[SaveTagList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
- err instanceof Error ? err.stack : undefined,
- );
- throw err;
- }
- }
- /**
- * Get tag metadata list for a category.
- *
- * REDIS OPERATION: LRANGE key 0 -1
- * KEY TYPE: LIST
- * VALUE TYPE: Tag JSON objects (stringified)
- *
- * Parses each JSON string into a TagMetadata object.
- * Skips items that fail to parse (logs warning).
- *
- * @param key - Redis key (e.g., 'box:app:tag:list:{categoryId}')
- * @returns Array of parsed tag metadata objects
- *
- * @example
- * const tags = await helper.getTagListForCategory('box:app:tag:list:abc123');
- * // Returns: [{ id: '1', name: 'Action', ... }, { id: '2', name: 'Drama', ... }]
- */
- async getTagListForCategory(key: string): Promise<TagMetadata[]> {
- try {
- const items = await this.readListRangeWithLegacy(
- key,
- 0,
- -1,
- async (legacyData) => {
- const tags = this.parseTagMetadataStrings(legacyData, key);
- if (tags.length > 0) {
- await this.saveTagList(key, tags);
- }
- },
- );
- return this.parseTagMetadataStrings(items, key);
- } catch (err) {
- this.logger.error(
- `[GetTagList] Failed for key=${key}: ${err instanceof Error ? err.message : String(err)}`,
- err instanceof Error ? err.stack : undefined,
- );
- return [];
- }
- }
- /**
- * Check if a key exists in Redis.
- *
- * REDIS OPERATION: EXISTS key
- *
- * @param key - Redis key to check
- * @returns true if key exists, false otherwise
- *
- * @example
- * const exists = await helper.keyExists('box:app:video:category:list:abc123');
- */
- async keyExists(key: string): Promise<boolean> {
- try {
- const result = await this.redis.exists(key);
- return result > 0;
- } catch (err) {
- this.logger.error(
- `Failed to check existence of ${key}`,
- err instanceof Error ? err.stack : String(err),
- );
- return false;
- }
- }
- /**
- * Delete a key from Redis.
- *
- * REDIS OPERATION: DEL key
- *
- * @param key - Redis key to delete
- * @returns Number of keys deleted (0 or 1)
- *
- * @example
- * await helper.deleteKey('box:app:video:category:list:abc123');
- */
- async deleteKey(key: string): Promise<number> {
- try {
- return await this.redis.del(key);
- } catch (err) {
- this.logger.error(
- `Failed to delete key ${key}`,
- err instanceof Error ? err.stack : String(err),
- );
- return 0;
- }
- }
- private legacyPrefixedKey(key: string): string {
- return `box:${key}`;
- }
- private async readListRangeWithLegacy(
- key: string,
- start: number,
- stop: number,
- migrate: (legacyData: string[]) => Promise<void>,
- ): Promise<string[]> {
- const result = await this.redis.lrange(key, start, stop);
- if (result.length > 0) {
- return result;
- }
- const legacyKey = this.legacyPrefixedKey(key);
- const legacyData = await this.redis.lrange(legacyKey, start, stop);
- if (!legacyData.length) {
- return [];
- }
- this.logger.warn(
- `[VideoCacheHelper] Legacy key triggered: ${legacyKey}, rehydrating ${key}`,
- );
- await migrate(legacyData);
- return legacyData;
- }
- private parseTagMetadataStrings(raw: string[], key: string): TagMetadata[] {
- const tags: TagMetadata[] = [];
- let malformedCount = 0;
- for (const item of raw) {
- try {
- const parsed = JSON.parse(item) as TagMetadata;
- tags.push(parsed);
- } catch {
- malformedCount++;
- }
- }
- if (malformedCount > 0) {
- this.logger.warn(
- `[GetTagList] key=${key}: parsed ${tags.length} tags, skipped ${malformedCount} malformed items`,
- );
- } else {
- this.logger.debug(
- `[GetTagList] key=${key}: parsed ${tags.length} tags`,
- );
- }
- return tags;
- }
- }
|