|
|
@@ -28,6 +28,8 @@ export interface TagMetadataPayload extends TagMetadata {}
|
|
|
* Key: box:app:video:category:list:{categoryId}
|
|
|
* Type: LIST
|
|
|
* Contents: Video IDs ONLY (strings like "64a2b3c4d5e6f7...")
|
|
|
+ * ✅ CORRECT: Store only video IDs
|
|
|
+ * ❌ WRONG: Never store Tag JSON, Category JSON, or any metadata objects
|
|
|
* WHY: Video IDs are the minimal data needed to:
|
|
|
* - Return to client (they fetch full details separately)
|
|
|
* - Paginate in client (they use ZSET pools for ordering)
|
|
|
@@ -37,12 +39,16 @@ export interface TagMetadataPayload extends TagMetadata {}
|
|
|
* Key: box:app:video:tag:list:{categoryId}:{tagId}
|
|
|
* Type: LIST
|
|
|
* Contents: Video IDs ONLY (subset of category videos with this tag)
|
|
|
+ * ✅ CORRECT: Store only video IDs (filtered by tag membership)
|
|
|
+ * ❌ WRONG: Never store Tag JSON or Video metadata
|
|
|
* WHY: Same as above - minimal data for tag-based filtering
|
|
|
*
|
|
|
- * 3. TAG METADATA LISTS
|
|
|
+ * 3. TAG METADATA LISTS (THE ONLY PLACE FOR TAG JSON)
|
|
|
* Key: box:app:tag:list:{categoryId}
|
|
|
* Type: LIST
|
|
|
* Contents: Tag JSON objects (stringified)
|
|
|
+ * ✅ CORRECT: This is the ONLY key where Tag JSON should be stored
|
|
|
+ * ❌ WRONG: Never store Tag JSON in video list keys (items 1 & 2 above)
|
|
|
* WHY: Tags are used for filter UI (dropdown, checkboxes)
|
|
|
* - Client needs id, name, seq for display
|
|
|
* - Stored separately from videos to avoid duplication
|
|
|
@@ -51,6 +57,12 @@ export interface TagMetadataPayload extends TagMetadata {}
|
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
|
* KEY INVARIANT: NEVER store Tag/Category JSON in video list keys!
|
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
|
+ *
|
|
|
+ * WRITER CONTRACT:
|
|
|
+ * ────────────────
|
|
|
+ * - buildCategoryVideoListForCategory() → uses saveVideoIdList() → writes to box:app:video:category:list:*
|
|
|
+ * - buildTagFilteredVideoListForTag() → uses saveVideoIdList() → writes to box:app:video:tag:list:*
|
|
|
+ * - buildTagMetadataListForCategory() → uses saveTagList() → writes to box:app:tag:list:*
|
|
|
*/
|
|
|
@Injectable()
|
|
|
export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
|
|
|
@@ -64,13 +76,37 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
|
|
|
/**
|
|
|
* Build all video/tag caches for all channels.
|
|
|
* For each channel, builds:
|
|
|
- * 1. Category video lists (video IDs only)
|
|
|
- * 2. Tag-filtered video lists (video IDs only)
|
|
|
- * 3. Tag metadata lists (Tag JSON objects)
|
|
|
+ * 1. Category video lists (video IDs only, using saveVideoIdList)
|
|
|
+ * 2. Tag-filtered video lists (video IDs only, using saveVideoIdList)
|
|
|
+ * 3. Tag metadata lists (Tag JSON objects, using saveTagList)
|
|
|
+ *
|
|
|
+ * ⚠️ IMPORTANT IMPLEMENTATION DETAILS:
|
|
|
+ * ───────────────────────────────────
|
|
|
+ * - All writes go through VideoCacheHelper (never direct RedisService calls)
|
|
|
+ * - saveVideoIdList() is used for both category and tag-filtered video lists
|
|
|
+ * - saveTagList() is ONLY used for tag metadata (box:app:tag:list:{categoryId})
|
|
|
+ * - This ensures atomicity (DEL + RPUSH + EXPIRE) for all operations
|
|
|
+ * - Tag JSON is NEVER written to video list keys
|
|
|
+ *
|
|
|
+ * LOGGING:
|
|
|
+ * ────────
|
|
|
+ * Provides detailed statistics about cache rebuild:
|
|
|
+ * - Number of channels processed
|
|
|
+ * - Number of categories rebuilt (category video lists)
|
|
|
+ * - Number of tag-filtered video lists rebuilt
|
|
|
+ * - Number of tag metadata lists rebuilt
|
|
|
*/
|
|
|
async buildAll(): Promise<void> {
|
|
|
const channels = await this.mongoPrisma.channel.findMany();
|
|
|
|
|
|
+ let totalCategories = 0;
|
|
|
+ let totalTagFilteredLists = 0;
|
|
|
+ let totalTagMetadataLists = 0;
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `🔨 Starting video cache rebuild for ${channels.length} channels...`,
|
|
|
+ );
|
|
|
+
|
|
|
for (const channel of channels) {
|
|
|
try {
|
|
|
// Build video lists for each category
|
|
|
@@ -81,9 +117,14 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
+ this.logger.debug(
|
|
|
+ ` Channel ${channel.id}: Processing ${categories.length} categories`,
|
|
|
+ );
|
|
|
+
|
|
|
for (const category of categories) {
|
|
|
// 1. Build list of all video IDs in this category
|
|
|
await this.buildCategoryVideoListForCategory(category.id);
|
|
|
+ totalCategories++;
|
|
|
|
|
|
// 2. Build tag-filtered video lists
|
|
|
const tags = await this.mongoPrisma.tag.findMany({
|
|
|
@@ -96,125 +137,258 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
|
|
|
|
|
|
for (const tag of tags) {
|
|
|
await this.buildTagFilteredVideoListForTag(category.id, tag.id);
|
|
|
+ totalTagFilteredLists++;
|
|
|
}
|
|
|
|
|
|
// 3. Build tag metadata list for this category
|
|
|
await this.buildTagMetadataListForCategory(category.id);
|
|
|
+ totalTagMetadataLists++;
|
|
|
}
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ ` ✅ Channel ${channel.id}: ${categories.length} categories, ${totalTagFilteredLists} tag filters`,
|
|
|
+ );
|
|
|
} catch (err) {
|
|
|
this.logger.error(
|
|
|
- `Error building video cache for channel ${channel.id}`,
|
|
|
+ ` ❌ Error building video cache for channel ${channel.id}`,
|
|
|
err instanceof Error ? err.stack : String(err),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- this.logger.log(
|
|
|
- `Built video and tag caches for ${channels.length} channels`,
|
|
|
- );
|
|
|
+ this.logger.log(`
|
|
|
+📊 Video Cache Rebuild Summary:
|
|
|
+ ├─ Channels processed: ${channels.length}
|
|
|
+ ├─ Category video lists built: ${totalCategories} (box:app:video:category:list:*)
|
|
|
+ ├─ Tag-filtered video lists built: ${totalTagFilteredLists} (box:app:video:tag:list:*)
|
|
|
+ └─ Tag metadata lists built: ${totalTagMetadataLists} (box:app:tag:list:*)
|
|
|
+`);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Build category video list (LIST of video IDs only).
|
|
|
*
|
|
|
- * Query: All videos in this category (no tag filter)
|
|
|
- * Store: video IDs in order by seq
|
|
|
- * Key: box:app:video:category:list:{categoryId}
|
|
|
+ * QUERY STRATEGY:
|
|
|
+ * ──────────────
|
|
|
+ * - Fetch all videos with listStatus === 1 (on shelf)
|
|
|
+ * - Order by addedTime DESC (newest/most recently added first)
|
|
|
+ * - Fallback to createdAt DESC if addedTime is not set
|
|
|
+ * - Extract ONLY video IDs (no metadata)
|
|
|
+ *
|
|
|
+ * WRITE STRATEGY:
|
|
|
+ * ───────────────
|
|
|
+ * - Key: box:app:video:category:list:{categoryId}
|
|
|
+ * - Type: Redis LIST (ordered set of strings)
|
|
|
+ * - Helper: VideoCacheHelper.saveVideoIdList() for atomic DEL + RPUSH + EXPIRE
|
|
|
+ * - If no videos found: clear the key (DEL) and skip RPUSH
|
|
|
+ *
|
|
|
+ * ⚠️ CRITICAL CONTRACT:
|
|
|
+ * ────────────────────
|
|
|
+ * - ✅ ONLY store Video IDs (strings)
|
|
|
+ * - ❌ NEVER store Tag JSON, Category JSON, or any metadata objects
|
|
|
+ * - ❌ NEVER store Tag JSON in this key (use box:app:tag:list:{categoryId} instead)
|
|
|
*
|
|
|
- * ⚠️ IMPORTANT: Store VIDEO IDs only, never category metadata or JSON objects
|
|
|
+ * ERROR HANDLING:
|
|
|
+ * ───────────────
|
|
|
+ * - If no videos found: log debug message and clear the key
|
|
|
+ * - If Redis operation fails: log error with stack trace and re-throw
|
|
|
+ * - All operations are atomic (DEL + RPUSH + EXPIRE)
|
|
|
*/
|
|
|
async buildCategoryVideoListForCategory(categoryId: string): Promise<void> {
|
|
|
- // Fetch all videos in this category, ordered by addedTime (provider's timestamp)
|
|
|
- const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
- where: {
|
|
|
- categoryId,
|
|
|
- listStatus: 1, // Only "on shelf" videos
|
|
|
- },
|
|
|
- orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
|
|
|
- select: { id: true }, // Only fetch IDs, not full documents
|
|
|
- });
|
|
|
-
|
|
|
- const videoIds = videos.map((v) => v.id);
|
|
|
-
|
|
|
- const key = tsCacheKeys.video.categoryList(categoryId);
|
|
|
- await this.cacheHelper.saveVideoIdList(key, videoIds);
|
|
|
-
|
|
|
- this.logger.debug(
|
|
|
- `Built category video list: ${categoryId} with ${videoIds.length} videos`,
|
|
|
- );
|
|
|
+ try {
|
|
|
+ // Fetch all videos in this category, ordered by addedTime (provider's timestamp)
|
|
|
+ const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
+ where: {
|
|
|
+ categoryId,
|
|
|
+ listStatus: 1, // Only "on shelf" videos
|
|
|
+ },
|
|
|
+ orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
|
|
|
+ select: { id: true }, // Only fetch IDs, not full documents
|
|
|
+ });
|
|
|
+
|
|
|
+ const videoIds = videos.map((v) => v.id);
|
|
|
+ const key = tsCacheKeys.video.categoryList(categoryId);
|
|
|
+
|
|
|
+ if (videoIds.length === 0) {
|
|
|
+ this.logger.debug(
|
|
|
+ `No videos found for category ${categoryId}, clearing cache key`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Atomic write: DEL existing key, RPUSH video IDs, set TTL if configured
|
|
|
+ await this.cacheHelper.saveVideoIdList(key, videoIds);
|
|
|
+
|
|
|
+ this.logger.debug(
|
|
|
+ `Built category video list for categoryId=${categoryId}: ${videoIds.length} videos stored`,
|
|
|
+ );
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Failed to build category video list for categoryId=${categoryId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Build tag-filtered video list (LIST of video IDs only).
|
|
|
*
|
|
|
- * Query: All videos in this category that have this tag
|
|
|
- * Store: video IDs in order by seq
|
|
|
- * Key: box:app:video:tag:list:{categoryId}:{tagId}
|
|
|
+ * QUERY STRATEGY:
|
|
|
+ * ──────────────
|
|
|
+ * - Fetch all videos with listStatus === 1 (on shelf) AND tagIds contains this tagId
|
|
|
+ * - Order by addedTime DESC (newest/most recently added first)
|
|
|
+ * - Fallback to createdAt DESC if addedTime is not set
|
|
|
+ * - Extract ONLY video IDs (no metadata)
|
|
|
*
|
|
|
- * ⚠️ IMPORTANT: Store VIDEO IDs only, never tag metadata or JSON objects
|
|
|
- * This is distinct from box:app:tag:list:{categoryId} which stores tag metadata
|
|
|
+ * WRITE STRATEGY:
|
|
|
+ * ───────────────
|
|
|
+ * - Key: box:app:video:tag:list:{categoryId}:{tagId}
|
|
|
+ * - Type: Redis LIST (ordered set of strings)
|
|
|
+ * - Helper: VideoCacheHelper.saveVideoIdList() for atomic DEL + RPUSH + EXPIRE
|
|
|
+ * - If no videos found: clear the key (DEL) and skip RPUSH
|
|
|
+ *
|
|
|
+ * ⚠️ CRITICAL CONTRACT:
|
|
|
+ * ────────────────────
|
|
|
+ * - ✅ ONLY store Video IDs (strings) that have this tag
|
|
|
+ * - ❌ NEVER store Tag JSON or metadata objects in this key
|
|
|
+ * - ❌ NEVER store Tag JSON (use box:app:tag:list:{categoryId} instead)
|
|
|
+ * - This key is DISTINCT from box:app:tag:list:{categoryId} which stores Tag JSON
|
|
|
+ *
|
|
|
+ * ERROR HANDLING:
|
|
|
+ * ───────────────
|
|
|
+ * - If no videos found with this tag: log debug message and clear the key
|
|
|
+ * - If Redis operation fails: log error with stack trace and re-throw
|
|
|
+ * - All operations are atomic (DEL + RPUSH + EXPIRE)
|
|
|
*/
|
|
|
async buildTagFilteredVideoListForTag(
|
|
|
categoryId: string,
|
|
|
tagId: string,
|
|
|
): Promise<void> {
|
|
|
- // Fetch all videos in this category with this tag
|
|
|
- const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
- where: {
|
|
|
- categoryId,
|
|
|
- listStatus: 1, // Only "on shelf" videos
|
|
|
- tagIds: { has: tagId }, // Has this specific tag
|
|
|
- },
|
|
|
- orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
|
|
|
- select: { id: true }, // Only fetch IDs
|
|
|
- });
|
|
|
-
|
|
|
- const videoIds = videos.map((v) => v.id);
|
|
|
-
|
|
|
- const key = tsCacheKeys.video.tagList(categoryId, tagId);
|
|
|
- await this.cacheHelper.saveVideoIdList(key, videoIds);
|
|
|
-
|
|
|
- this.logger.debug(
|
|
|
- `Built tag video list: ${categoryId}:${tagId} with ${videoIds.length} videos`,
|
|
|
- );
|
|
|
+ try {
|
|
|
+ // Fetch all videos in this category with this tag
|
|
|
+ const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
+ where: {
|
|
|
+ categoryId,
|
|
|
+ listStatus: 1, // Only "on shelf" videos
|
|
|
+ tagIds: { has: tagId }, // Has this specific tag
|
|
|
+ },
|
|
|
+ orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
|
|
|
+ select: { id: true }, // Only fetch IDs
|
|
|
+ });
|
|
|
+
|
|
|
+ const videoIds = videos.map((v) => v.id);
|
|
|
+ const key = tsCacheKeys.video.tagList(categoryId, tagId);
|
|
|
+
|
|
|
+ if (videoIds.length === 0) {
|
|
|
+ this.logger.debug(
|
|
|
+ `No videos found for category ${categoryId} with tag ${tagId}, clearing cache key`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Atomic write: DEL existing key, RPUSH video IDs, set TTL if configured
|
|
|
+ await this.cacheHelper.saveVideoIdList(key, videoIds);
|
|
|
+
|
|
|
+ this.logger.debug(
|
|
|
+ `Built tag video list for categoryId=${categoryId}, tagId=${tagId}: ${videoIds.length} videos stored`,
|
|
|
+ );
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Failed to build tag video list for categoryId=${categoryId}, tagId=${tagId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Build tag metadata list (LIST of Tag JSON objects).
|
|
|
*
|
|
|
- * Query: All enabled tags in this category
|
|
|
- * Store: Tag JSON objects (stringified, one per list element)
|
|
|
- * Key: box:app:tag:list:{categoryId}
|
|
|
+ * QUERY STRATEGY:
|
|
|
+ * ──────────────
|
|
|
+ * - Fetch all tags with status === 1 (enabled) in this category
|
|
|
+ * - Order by seq ASC (business order for dropdown display)
|
|
|
+ * - Fallback to createAt ASC for consistent ordering
|
|
|
+ * - Transform to TagMetadataPayload (convert timestamps to strings)
|
|
|
+ *
|
|
|
+ * WRITE STRATEGY:
|
|
|
+ * ───────────────
|
|
|
+ * - Key: box:app:tag:list:{categoryId}
|
|
|
+ * - Type: Redis LIST (each element is stringified Tag JSON)
|
|
|
+ * - Helper: VideoCacheHelper.saveTagList() for atomic DEL + RPUSH + EXPIRE
|
|
|
+ * - If no tags found: clear the key (DEL) and skip RPUSH
|
|
|
*
|
|
|
- * ✅ CORRECT: This key stores Tag JSON objects
|
|
|
- * ❌ WRONG: Never store video IDs in this key
|
|
|
+ * ⚠️ CRITICAL CONTRACT: THIS IS THE ONLY PLACE WHERE TAG JSON SHOULD BE STORED
|
|
|
+ * ──────────────────────────────────────────────────────────────────────────
|
|
|
+ * - ✅ ONLY store Tag JSON objects in this key
|
|
|
+ * - ✅ Each element is a stringified Tag object with {id, name, seq, status, ...}
|
|
|
+ * - ❌ NEVER store Tag JSON in "box:app:video:category:list:*" keys
|
|
|
+ * - ❌ NEVER store Tag JSON in "box:app:video:tag:list:*" keys
|
|
|
+ * - Video list keys MUST contain ONLY video IDs, never Tag metadata
|
|
|
*
|
|
|
- * Purpose: Used by frontend for tag filter dropdown/UI
|
|
|
+ * PURPOSE:
|
|
|
+ * ────────
|
|
|
+ * Used by frontend for tag filter UI (dropdowns, checkboxes)
|
|
|
* Each tag is a separate list element and parsed as JSON when read
|
|
|
+ *
|
|
|
+ * ERROR HANDLING:
|
|
|
+ * ───────────────
|
|
|
+ * - If no tags found: log debug message and clear the key
|
|
|
+ * - If Redis operation fails: log error with stack trace and re-throw
|
|
|
+ * - All operations are atomic (DEL + RPUSH + EXPIRE)
|
|
|
+ *
|
|
|
+ * FORMAT PER ELEMENT IN LIST:
|
|
|
+ * ──────────────────────────
|
|
|
+ * {
|
|
|
+ * id: string (tag ID),
|
|
|
+ * name: string (display name),
|
|
|
+ * seq: number (sort order),
|
|
|
+ * status: number (0=disabled, 1=enabled),
|
|
|
+ * createAt: string (ISO timestamp),
|
|
|
+ * updateAt: string (ISO timestamp),
|
|
|
+ * channelId: string,
|
|
|
+ * categoryId: string
|
|
|
+ * }
|
|
|
*/
|
|
|
async buildTagMetadataListForCategory(categoryId: string): Promise<void> {
|
|
|
- // Fetch all enabled tags for this category, ordered by seq
|
|
|
- const tags = await this.mongoPrisma.tag.findMany({
|
|
|
- where: { status: 1, categoryId },
|
|
|
- orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
|
|
|
- });
|
|
|
-
|
|
|
- const tagPayloads: TagMetadataPayload[] = tags.map((tag) => ({
|
|
|
- id: tag.id,
|
|
|
- name: tag.name,
|
|
|
- seq: tag.seq,
|
|
|
- status: tag.status,
|
|
|
- createAt: tag.createAt.toString(),
|
|
|
- updateAt: tag.updateAt.toString(),
|
|
|
- channelId: tag.channelId,
|
|
|
- categoryId: tag.categoryId,
|
|
|
- }));
|
|
|
-
|
|
|
- const key = `box:app:tag:list:${categoryId}`; // Direct key construction
|
|
|
- await this.cacheHelper.saveTagList(key, tagPayloads);
|
|
|
-
|
|
|
- this.logger.debug(
|
|
|
- `Built tag metadata list: ${categoryId} with ${tagPayloads.length} tags`,
|
|
|
- );
|
|
|
+ try {
|
|
|
+ // Fetch all enabled tags for this category, ordered by seq
|
|
|
+ const tags = await this.mongoPrisma.tag.findMany({
|
|
|
+ where: { status: 1, categoryId },
|
|
|
+ orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
|
|
|
+ });
|
|
|
+
|
|
|
+ if (tags.length === 0) {
|
|
|
+ this.logger.debug(
|
|
|
+ `No tags found for category ${categoryId}, clearing cache key`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Transform tags to payload format (convert BigInt timestamps to strings)
|
|
|
+ const tagPayloads: TagMetadataPayload[] = tags.map((tag) => ({
|
|
|
+ id: tag.id,
|
|
|
+ name: tag.name,
|
|
|
+ seq: tag.seq,
|
|
|
+ status: tag.status,
|
|
|
+ createAt: tag.createAt.toString(), // BigInt to string
|
|
|
+ updateAt: tag.updateAt.toString(), // BigInt to string
|
|
|
+ channelId: tag.channelId,
|
|
|
+ categoryId: tag.categoryId,
|
|
|
+ }));
|
|
|
+
|
|
|
+ const key = `box:app:tag:list:${categoryId}`; // Direct key construction
|
|
|
+
|
|
|
+ // Atomic write: DEL existing key, RPUSH tag JSON, set TTL if configured
|
|
|
+ await this.cacheHelper.saveTagList(key, tagPayloads);
|
|
|
+
|
|
|
+ this.logger.debug(
|
|
|
+ `Built tag metadata list for categoryId=${categoryId}: ${tagPayloads.length} tags stored`,
|
|
|
+ );
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Failed to build tag metadata list for categoryId=${categoryId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
}
|
|
|
}
|