|
|
@@ -0,0 +1,248 @@
|
|
|
+// libs/core/src/cache/video/video-list-cache.builder.ts
|
|
|
+import { Injectable } from '@nestjs/common';
|
|
|
+import { BaseCacheBuilder } from '@box/common/cache/cache-builder';
|
|
|
+import { RedisService } from '@box/db/redis/redis.service';
|
|
|
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
|
|
|
+import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
|
|
|
+import type {
|
|
|
+ VideoSortKey,
|
|
|
+ VideoHomeSectionKey,
|
|
|
+} from '@box/common/cache/cache-keys';
|
|
|
+
|
|
|
+/**
|
|
|
+ * Video payload for Redis pools (ZSET members).
|
|
|
+ * Contains essential video metadata for display.
|
|
|
+ */
|
|
|
+export interface VideoPoolPayload {
|
|
|
+ id: string;
|
|
|
+ title: string;
|
|
|
+ categoryId?: string | null;
|
|
|
+ tagIds?: string[];
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Cache builder for video pools and home sections.
|
|
|
+ * Builds:
|
|
|
+ * - ZSET pools for videos by category (with sort: 'latest')
|
|
|
+ * - ZSET pools for videos by tag (with sort: 'latest')
|
|
|
+ * - LIST sections for home page (featured, latest, editorPick)
|
|
|
+ *
|
|
|
+ * Only includes videos where listStatus === 1 (on shelf).
|
|
|
+ *
|
|
|
+ * Strategy:
|
|
|
+ * 1. For each channel, fetch all categories
|
|
|
+ * 2. For each category, fetch all on-shelf videos
|
|
|
+ * 3. Build ZSET pools indexed by (channelId, categoryId, 'latest')
|
|
|
+ * 4. For each tag in the channel, fetch all on-shelf videos with that tag
|
|
|
+ * 5. Build ZSET pools indexed by (channelId, tagId, 'latest')
|
|
|
+ * 6. Build home section LISTs (top N recent videos across all categories in channel)
|
|
|
+ */
|
|
|
+@Injectable()
|
|
|
+export class VideoListCacheBuilder extends BaseCacheBuilder {
|
|
|
+ /** Top N videos to include in home section lists. */
|
|
|
+ private readonly HOME_SECTION_LIMIT = 50;
|
|
|
+
|
|
|
+ constructor(redis: RedisService, mongoPrisma: MongoPrismaService) {
|
|
|
+ super(redis, mongoPrisma, VideoListCacheBuilder.name);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Build all video pools and home sections for all channels.
|
|
|
+ *
|
|
|
+ * Process:
|
|
|
+ * 1. Fetch all channels
|
|
|
+ * 2. For each channel:
|
|
|
+ * - Build category pools (ZSET per category)
|
|
|
+ * - Build tag pools (ZSET per tag)
|
|
|
+ * - Build home sections (LIST per section)
|
|
|
+ */
|
|
|
+ async buildAll(): Promise<void> {
|
|
|
+ const channels = await this.mongoPrisma.channel.findMany();
|
|
|
+
|
|
|
+ for (const channel of channels) {
|
|
|
+ try {
|
|
|
+ await this.buildCategoryPoolsForChannel(channel.id);
|
|
|
+ await this.buildTagPoolsForChannel(channel.id);
|
|
|
+ await this.buildHomeSectionsForChannel(channel.id);
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error building video pools/sections for channel ${channel.id}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `Built video pools and home sections for ${channels.length} channels`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Build category pools (ZSET) for all categories in a channel.
|
|
|
+ * Groups videos by categoryId and creates a ZSET per category.
|
|
|
+ * Score: editedAt (converted to ms) or updatedAt as fallback.
|
|
|
+ * Sort order: descending (most recent first).
|
|
|
+ */
|
|
|
+ private async buildCategoryPoolsForChannel(channelId: string): Promise<void> {
|
|
|
+ // Fetch all categories for this channel
|
|
|
+ const categories = await this.mongoPrisma.category.findMany({
|
|
|
+ where: { channelId },
|
|
|
+ });
|
|
|
+
|
|
|
+ for (const category of categories) {
|
|
|
+ // Fetch all on-shelf videos for this category
|
|
|
+ const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
+ where: {
|
|
|
+ categoryId: category.id,
|
|
|
+ listStatus: 1,
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // Sort by editedAt desc (most recent first)
|
|
|
+ videos.sort((a, b) => {
|
|
|
+ const scoreA = this.getVideoScore(a);
|
|
|
+ const scoreB = this.getVideoScore(b);
|
|
|
+ return scoreB - scoreA; // descending
|
|
|
+ });
|
|
|
+
|
|
|
+ // Build ZSET members: { videoId: score }
|
|
|
+ const members: Array<{ member: string; score: number }> = [];
|
|
|
+ for (const video of videos) {
|
|
|
+ const score = this.getVideoScore(video);
|
|
|
+ members.push({ member: video.id, score });
|
|
|
+ }
|
|
|
+
|
|
|
+ const key = tsCacheKeys.video.categoryPool(
|
|
|
+ channelId,
|
|
|
+ category.id,
|
|
|
+ 'latest',
|
|
|
+ );
|
|
|
+ if (members.length > 0) {
|
|
|
+ await this.redis.zadd(key, members);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.debug(
|
|
|
+ `Built category pool: ${category.id} in channel ${channelId} with ${members.length} videos`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Build tag pools (ZSET) for all tags in a channel.
|
|
|
+ * For each tag, fetches all on-shelf videos with that tag and creates a ZSET.
|
|
|
+ * Score: editedAt (converted to ms) or updatedAt as fallback.
|
|
|
+ * Sort order: descending (most recent first).
|
|
|
+ */
|
|
|
+ private async buildTagPoolsForChannel(channelId: string): Promise<void> {
|
|
|
+ // Fetch all tags for this channel
|
|
|
+ const tags = await this.mongoPrisma.tag.findMany({
|
|
|
+ where: { channelId },
|
|
|
+ });
|
|
|
+
|
|
|
+ for (const tag of tags) {
|
|
|
+ // Fetch all on-shelf videos that have this tag
|
|
|
+ // Note: tagIds is an array field, so we check if tag.id is in the array
|
|
|
+ const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
+ where: {
|
|
|
+ listStatus: 1,
|
|
|
+ tagIds: { has: tag.id },
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // Sort by editedAt desc (most recent first)
|
|
|
+ videos.sort((a, b) => {
|
|
|
+ const scoreA = this.getVideoScore(a);
|
|
|
+ const scoreB = this.getVideoScore(b);
|
|
|
+ return scoreB - scoreA; // descending
|
|
|
+ });
|
|
|
+
|
|
|
+ // Build ZSET members: { videoId: score }
|
|
|
+ const members: Array<{ member: string; score: number }> = [];
|
|
|
+ for (const video of videos) {
|
|
|
+ const score = this.getVideoScore(video);
|
|
|
+ members.push({ member: video.id, score });
|
|
|
+ }
|
|
|
+
|
|
|
+ const key = tsCacheKeys.video.tagPool(channelId, tag.id, 'latest');
|
|
|
+ if (members.length > 0) {
|
|
|
+ await this.redis.zadd(key, members);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.debug(
|
|
|
+ `Built tag pool: ${tag.id} in channel ${channelId} with ${members.length} videos`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Build home page sections (LIST) for a channel.
|
|
|
+ * For MVP, all sections return the top N most recent videos.
|
|
|
+ * Sections: featured, latest, editorPick
|
|
|
+ * Type: LIST of videoIds (in descending order by editedAt).
|
|
|
+ *
|
|
|
+ * Process:
|
|
|
+ * 1. Fetch all on-shelf videos for this channel (via categories)
|
|
|
+ * 2. Sort by editedAt desc, take top N
|
|
|
+ * 3. For each section, RPUSH the videoIds
|
|
|
+ */
|
|
|
+ private async buildHomeSectionsForChannel(channelId: string): Promise<void> {
|
|
|
+ // Fetch all categories for this channel to get their IDs
|
|
|
+ const categories = await this.mongoPrisma.category.findMany({
|
|
|
+ where: { channelId },
|
|
|
+ });
|
|
|
+ const categoryIds = categories.map((c) => c.id);
|
|
|
+
|
|
|
+ if (categoryIds.length === 0) {
|
|
|
+ this.logger.debug(
|
|
|
+ `No categories for channel ${channelId}, skipping home sections`,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fetch all on-shelf videos for these categories, sorted by editedAt desc
|
|
|
+ const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
+ where: {
|
|
|
+ categoryId: { in: categoryIds },
|
|
|
+ listStatus: 1,
|
|
|
+ },
|
|
|
+ orderBy: [{ editedAt: 'desc' }, { updatedAt: 'desc' }],
|
|
|
+ take: this.HOME_SECTION_LIMIT,
|
|
|
+ });
|
|
|
+
|
|
|
+ const videoIds = videos.map((v) => v.id);
|
|
|
+
|
|
|
+ const sections: VideoHomeSectionKey[] = [
|
|
|
+ 'featured',
|
|
|
+ 'latest',
|
|
|
+ 'editorPick',
|
|
|
+ ];
|
|
|
+ for (const section of sections) {
|
|
|
+ const key = tsCacheKeys.video.homeSection(channelId, section);
|
|
|
+ if (videoIds.length > 0) {
|
|
|
+ await this.redis.rpushList(key, videoIds);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.debug(
|
|
|
+ `Built home section: ${section} in channel ${channelId} with ${videoIds.length} videos`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Get the score for ZSET ranking.
|
|
|
+ * Prefers editedAt (local edit time) if set and non-zero.
|
|
|
+ * Falls back to updatedAt (provider update time).
|
|
|
+ * Converts BigInt to number (milliseconds).
|
|
|
+ */
|
|
|
+ private getVideoScore(video: any): number {
|
|
|
+ // Prefer editedAt (local edit time) if set and non-zero
|
|
|
+ if (video.editedAt) {
|
|
|
+ const ms = this.toMillis(video.editedAt);
|
|
|
+ if (ms && ms > 0) {
|
|
|
+ return ms;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Fall back to updatedAt (provider update time)
|
|
|
+ return this.toMillis(video.updatedAt) ?? 0;
|
|
|
+ }
|
|
|
+}
|