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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, ): Promise { 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; } }