|
|
@@ -1,266 +1,294 @@
|
|
|
-import { Inject, Injectable } from '@nestjs/common';
|
|
|
-import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
|
|
|
-import { VideoMediaDto } from './dto/video-media.dto';
|
|
|
-import type { Cache } from 'cache-manager';
|
|
|
-import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
|
-
|
|
|
-const VIDEO_LIST_CACHE_TTL_SECONDS = 30; // list cache
|
|
|
-const VIDEO_RECOMMEND_CACHE_TTL_SECONDS = 60; // recommendations cache
|
|
|
-
|
|
|
-type VideoListOptions = {
|
|
|
- limit?: number;
|
|
|
- tag?: string;
|
|
|
- kw?: string;
|
|
|
-};
|
|
|
-
|
|
|
+import { Injectable, Logger } from '@nestjs/common';
|
|
|
+import { RedisService } from '@box/db/redis/redis.service';
|
|
|
+import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
|
|
|
+import type { VideoHomeSectionKey } from '@box/common/cache/ts-cache-key.provider';
|
|
|
+import {
|
|
|
+ VideoCategoryDto,
|
|
|
+ VideoTagDto,
|
|
|
+ VideoDetailDto,
|
|
|
+ VideoPageDto,
|
|
|
+} from './dto';
|
|
|
+
|
|
|
+/**
|
|
|
+ * VideoService provides read-only access to video data from Redis cache.
|
|
|
+ * All data is prebuilt and maintained by box-mgnt-api cache builders.
|
|
|
+ * Follows the same pattern as AdService.
|
|
|
+ */
|
|
|
@Injectable()
|
|
|
export class VideoService {
|
|
|
- constructor(
|
|
|
- private readonly prisma: PrismaMongoService,
|
|
|
- @Inject(CACHE_MANAGER) private readonly cache: Cache,
|
|
|
- ) {}
|
|
|
+ private readonly logger = new Logger(VideoService.name);
|
|
|
+
|
|
|
+ constructor(private readonly redis: RedisService) {}
|
|
|
|
|
|
/**
|
|
|
- * Homepage / general list:
|
|
|
- * - Non-deleted videos
|
|
|
- * - Optional filters: tag, kw (searches title + tagsFlat)
|
|
|
- * - Ordered by sortOrder (or createdAt if you change it)
|
|
|
- * - Limited by `limit` (default 20)
|
|
|
- * - Cached in Redis
|
|
|
+ * Get video detail by videoId.
|
|
|
+ * Reads from appVideoDetailKey (JSON).
|
|
|
*/
|
|
|
- async findHomepageList(
|
|
|
- options: VideoListOptions = {},
|
|
|
- ): Promise<VideoMediaDto[]> {
|
|
|
- const safeLimit = this.normalizeLimit(options.limit);
|
|
|
- const tag = (options.tag ?? '').trim();
|
|
|
- const kw = (options.kw ?? '').trim();
|
|
|
-
|
|
|
- const cacheKey = this.buildListCacheKey({ limit: safeLimit, tag, kw });
|
|
|
-
|
|
|
- const cached = await this.cache.get<VideoMediaDto[]>(cacheKey);
|
|
|
- if (cached && Array.isArray(cached)) {
|
|
|
- return cached;
|
|
|
- }
|
|
|
-
|
|
|
- const where: any = {
|
|
|
- // adjust to your schema: e.g. status: 'ONLINE'
|
|
|
- // isDeleted: false,
|
|
|
- };
|
|
|
-
|
|
|
- // Filter by tag (array field)
|
|
|
- if (tag) {
|
|
|
- // Assuming `tags` is String[]
|
|
|
- where.tags = {
|
|
|
- has: tag,
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- // Keyword search: title OR tagsFlat
|
|
|
- const orConditions: any[] = [];
|
|
|
- if (kw) {
|
|
|
- orConditions.push(
|
|
|
- {
|
|
|
- title: {
|
|
|
- contains: kw,
|
|
|
- mode: 'insensitive',
|
|
|
- },
|
|
|
- },
|
|
|
- {
|
|
|
- tagsFlat: {
|
|
|
- contains: kw,
|
|
|
- mode: 'insensitive',
|
|
|
- },
|
|
|
- },
|
|
|
+ async getVideoDetail(videoId: string): Promise<VideoDetailDto | null> {
|
|
|
+ try {
|
|
|
+ const key = tsCacheKeys.video.detail(videoId);
|
|
|
+ const cached = await this.redis.getJson<VideoDetailDto | null>(key);
|
|
|
+ return cached ?? null;
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching video detail for videoId=${videoId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
);
|
|
|
+ return null;
|
|
|
}
|
|
|
- if (orConditions.length > 0) {
|
|
|
- where.OR = orConditions;
|
|
|
- }
|
|
|
-
|
|
|
- const rows = await this.prisma.videoMedia.findMany({
|
|
|
- where,
|
|
|
- orderBy: {
|
|
|
- // adjust if your schema uses `sort` / `order` / `createdAt`
|
|
|
- editedAt: 'asc',
|
|
|
- },
|
|
|
- take: safeLimit,
|
|
|
- });
|
|
|
-
|
|
|
- const items = rows.map((row) => this.toDto(row));
|
|
|
-
|
|
|
- // Store in Redis
|
|
|
- await this.cache.set(cacheKey, items, VIDEO_LIST_CACHE_TTL_SECONDS);
|
|
|
-
|
|
|
- return items;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * "You might also like" recommendations:
|
|
|
- * - Based on tags of current video
|
|
|
- * - Excludes the current video
|
|
|
- * - Fallback: homepage list without filter
|
|
|
+ * Get category list for a channel.
|
|
|
+ * Reads from appVideoCategoryListKey (LIST of JSON strings).
|
|
|
*/
|
|
|
- async findRecommendations(
|
|
|
- videoId: string,
|
|
|
- limit = 10,
|
|
|
- ): Promise<VideoMediaDto[]> {
|
|
|
- const safeLimit = this.normalizeLimit(limit);
|
|
|
- const trimmedId = videoId.trim();
|
|
|
-
|
|
|
- if (!trimmedId) {
|
|
|
- // fallback: no id provided
|
|
|
- return this.findHomepageList({ limit: safeLimit });
|
|
|
- }
|
|
|
-
|
|
|
- const cacheKey = this.buildRecommendCacheKey(trimmedId, safeLimit);
|
|
|
- const cached = await this.cache.get<VideoMediaDto[]>(cacheKey);
|
|
|
- if (cached && Array.isArray(cached)) {
|
|
|
- return cached;
|
|
|
+ async getCategoryListForChannel(
|
|
|
+ channelId: string,
|
|
|
+ ): Promise<VideoCategoryDto[]> {
|
|
|
+ try {
|
|
|
+ const key = tsCacheKeys.video.categoryList(channelId);
|
|
|
+ const items = await this.redis.lrange(key, 0, -1);
|
|
|
+
|
|
|
+ if (!items || items.length === 0) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ const categories: VideoCategoryDto[] = [];
|
|
|
+ for (const item of items) {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(item) as VideoCategoryDto;
|
|
|
+ categories.push(parsed);
|
|
|
+ } catch (parseErr) {
|
|
|
+ this.logger.warn(
|
|
|
+ `Failed to parse category item from ${key}: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return categories;
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching category list for channelId=${channelId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return [];
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- // 1) Get current video
|
|
|
- const current = await this.prisma.videoMedia.findUnique({
|
|
|
- where: {
|
|
|
- id: trimmedId,
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- if (!current) {
|
|
|
- // if video not found, fallback to generic list
|
|
|
- const fallback = await this.findHomepageList({ limit: safeLimit });
|
|
|
- await this.cache.set(
|
|
|
- cacheKey,
|
|
|
- fallback,
|
|
|
- VIDEO_RECOMMEND_CACHE_TTL_SECONDS,
|
|
|
+ /**
|
|
|
+ * Get tag list for a category within a channel.
|
|
|
+ * Reads from appVideoTagListKey (LIST of JSON strings).
|
|
|
+ */
|
|
|
+ async getTagListForCategory(
|
|
|
+ channelId: string,
|
|
|
+ categoryId: string,
|
|
|
+ ): Promise<VideoTagDto[]> {
|
|
|
+ try {
|
|
|
+ const key = tsCacheKeys.video.tagList(channelId, categoryId);
|
|
|
+ const items = await this.redis.lrange(key, 0, -1);
|
|
|
+
|
|
|
+ if (!items || items.length === 0) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ const tags: VideoTagDto[] = [];
|
|
|
+ for (const item of items) {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(item) as VideoTagDto;
|
|
|
+ tags.push(parsed);
|
|
|
+ } catch (parseErr) {
|
|
|
+ this.logger.warn(
|
|
|
+ `Failed to parse tag item from ${key}: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return tags;
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching tag list for channelId=${channelId}, categoryId=${categoryId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
);
|
|
|
- return fallback;
|
|
|
+ return [];
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- const tags: string[] = Array.isArray(current.tags) ? current.tags : [];
|
|
|
- const tagsFlat: string | null = current.tagsFlat ?? null;
|
|
|
-
|
|
|
- const where: any = {
|
|
|
- id: {
|
|
|
- not: trimmedId,
|
|
|
- },
|
|
|
- };
|
|
|
-
|
|
|
- const orConditions: any[] = [];
|
|
|
-
|
|
|
- if (tags.length > 0) {
|
|
|
- // share ANY tags
|
|
|
- orConditions.push({
|
|
|
- tags: {
|
|
|
- hasSome: tags,
|
|
|
- },
|
|
|
- });
|
|
|
- }
|
|
|
+ /**
|
|
|
+ * Get videos under a category with pagination.
|
|
|
+ * Uses ZREVRANGE on appVideoCategoryPoolKey to get latest videos first.
|
|
|
+ * Then fetches details for each videoId.
|
|
|
+ */
|
|
|
+ async getVideosByCategoryWithPaging(params: {
|
|
|
+ channelId: string;
|
|
|
+ categoryId: string;
|
|
|
+ page?: number;
|
|
|
+ pageSize?: number;
|
|
|
+ }): Promise<VideoPageDto<VideoDetailDto>> {
|
|
|
+ const { channelId, categoryId, page = 1, pageSize = 20 } = params;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const key = tsCacheKeys.video.categoryPool(
|
|
|
+ channelId,
|
|
|
+ categoryId,
|
|
|
+ 'latest',
|
|
|
+ );
|
|
|
|
|
|
- if (tagsFlat) {
|
|
|
- // textual similarity in tagsFlat
|
|
|
- orConditions.push({
|
|
|
- tagsFlat: {
|
|
|
- contains: tagsFlat,
|
|
|
- mode: 'insensitive',
|
|
|
- },
|
|
|
- });
|
|
|
- }
|
|
|
+ // Calculate offset and limit for ZREVRANGE
|
|
|
+ const offset = (page - 1) * pageSize;
|
|
|
+ const limit = pageSize;
|
|
|
|
|
|
- if (orConditions.length > 0) {
|
|
|
- where.OR = orConditions;
|
|
|
- }
|
|
|
+ // ZREVRANGE returns items in descending order by score (latest first)
|
|
|
+ const videoIds = await this.redis.zrevrange(
|
|
|
+ key,
|
|
|
+ offset,
|
|
|
+ offset + limit - 1,
|
|
|
+ );
|
|
|
|
|
|
- const rows = await this.prisma.videoMedia.findMany({
|
|
|
- where,
|
|
|
- orderBy: {
|
|
|
- editedAt: 'desc',
|
|
|
- },
|
|
|
- take: safeLimit,
|
|
|
- });
|
|
|
-
|
|
|
- let items = rows.map((row) => this.toDto(row));
|
|
|
-
|
|
|
- // If no strong recommendations, fallback to generic list (but still exclude current id)
|
|
|
- if (items.length === 0) {
|
|
|
- const fallback = await this.prisma.videoMedia.findMany({
|
|
|
- where: {
|
|
|
- // listStatus: 1,
|
|
|
- id: {
|
|
|
- not: trimmedId,
|
|
|
- },
|
|
|
- },
|
|
|
- orderBy: {
|
|
|
- editedAt: 'desc',
|
|
|
- },
|
|
|
- take: safeLimit,
|
|
|
- });
|
|
|
- items = fallback.map((row) => this.toDto(row));
|
|
|
+ if (!videoIds || videoIds.length === 0) {
|
|
|
+ return {
|
|
|
+ items: [],
|
|
|
+ total: 0,
|
|
|
+ page,
|
|
|
+ pageSize,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fetch details for all videoIds
|
|
|
+ const details = await this.getVideoDetailsBatch(videoIds);
|
|
|
+
|
|
|
+ return {
|
|
|
+ items: details.filter((d) => d !== null) as VideoDetailDto[],
|
|
|
+ total: undefined, // Could add ZCARD to get total count
|
|
|
+ page,
|
|
|
+ pageSize,
|
|
|
+ };
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching videos by category for channelId=${channelId}, categoryId=${categoryId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ items: [],
|
|
|
+ total: 0,
|
|
|
+ page: page ?? 1,
|
|
|
+ pageSize: pageSize ?? 20,
|
|
|
+ };
|
|
|
}
|
|
|
-
|
|
|
- await this.cache.set(cacheKey, items, VIDEO_RECOMMEND_CACHE_TTL_SECONDS);
|
|
|
-
|
|
|
- return items;
|
|
|
}
|
|
|
|
|
|
- // ------------------------
|
|
|
- // Helpers
|
|
|
- // ------------------------
|
|
|
-
|
|
|
- private normalizeLimit(limit?: number): number {
|
|
|
- if (!Number.isFinite(limit as number)) return 20;
|
|
|
- const n = Number(limit);
|
|
|
- if (n <= 0) return 20;
|
|
|
- if (n > 100) return 100;
|
|
|
- return n;
|
|
|
- }
|
|
|
-
|
|
|
- private buildListCacheKey(input: {
|
|
|
- limit: number;
|
|
|
- tag?: string;
|
|
|
- kw?: string;
|
|
|
- }): string {
|
|
|
- const parts = [
|
|
|
- 'video:list',
|
|
|
- `limit=${input.limit}`,
|
|
|
- input.tag ? `tag=${input.tag}` : 'tag=_',
|
|
|
- input.kw ? `kw=${input.kw}` : 'kw=_',
|
|
|
- ];
|
|
|
- return parts.join('|');
|
|
|
- }
|
|
|
+ /**
|
|
|
+ * Get videos under a tag with pagination.
|
|
|
+ * Uses ZREVRANGE on appVideoTagPoolKey to get latest videos first.
|
|
|
+ */
|
|
|
+ async getVideosByTagWithPaging(params: {
|
|
|
+ channelId: string;
|
|
|
+ tagId: string;
|
|
|
+ page?: number;
|
|
|
+ pageSize?: number;
|
|
|
+ }): Promise<VideoPageDto<VideoDetailDto>> {
|
|
|
+ const { channelId, tagId, page = 1, pageSize = 20 } = params;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const key = tsCacheKeys.video.tagPool(channelId, tagId, 'latest');
|
|
|
+
|
|
|
+ const offset = (page - 1) * pageSize;
|
|
|
+ const limit = pageSize;
|
|
|
+
|
|
|
+ const videoIds = await this.redis.zrevrange(
|
|
|
+ key,
|
|
|
+ offset,
|
|
|
+ offset + limit - 1,
|
|
|
+ );
|
|
|
|
|
|
- private buildRecommendCacheKey(videoId: string, limit: number): string {
|
|
|
- return ['video:recommend', `id=${videoId}`, `limit=${limit}`].join('|');
|
|
|
+ if (!videoIds || videoIds.length === 0) {
|
|
|
+ return {
|
|
|
+ items: [],
|
|
|
+ total: 0,
|
|
|
+ page,
|
|
|
+ pageSize,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const details = await this.getVideoDetailsBatch(videoIds);
|
|
|
+
|
|
|
+ return {
|
|
|
+ items: details.filter((d) => d !== null) as VideoDetailDto[],
|
|
|
+ total: undefined,
|
|
|
+ page,
|
|
|
+ pageSize,
|
|
|
+ };
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching videos by tag for channelId=${channelId}, tagId=${tagId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ items: [],
|
|
|
+ total: 0,
|
|
|
+ page: page ?? 1,
|
|
|
+ pageSize: pageSize ?? 20,
|
|
|
+ };
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- private toDto(row: any): VideoMediaDto {
|
|
|
- return {
|
|
|
- id: row.id?.toString?.() ?? row.id,
|
|
|
- title: row.title,
|
|
|
- description: row.description ?? null,
|
|
|
- videoCdn: row.videoCdn ?? row.cdnUrl ?? null,
|
|
|
- coverCdn: row.coverCdn ?? row.coverUrl ?? null,
|
|
|
- tags: Array.isArray(row.tags) ? row.tags : [],
|
|
|
- tagsFlat: row.tagsFlat ?? null,
|
|
|
- duration: row.duration ?? null,
|
|
|
- createdAt: this.toMillis(row.createdAt),
|
|
|
- updatedAt: this.toMillis(row.updatedAt),
|
|
|
- };
|
|
|
+ /**
|
|
|
+ * Get home section videos for a channel.
|
|
|
+ * Reads from appVideoHomeSectionKey (LIST of videoIds).
|
|
|
+ * Returns video details for each ID.
|
|
|
+ */
|
|
|
+ async getHomeSectionVideos(
|
|
|
+ channelId: string,
|
|
|
+ section: VideoHomeSectionKey,
|
|
|
+ ): Promise<VideoDetailDto[]> {
|
|
|
+ try {
|
|
|
+ const key = tsCacheKeys.video.homeSection(channelId, section);
|
|
|
+
|
|
|
+ // Read all videoIds from the LIST
|
|
|
+ const videoIds = await this.redis.lrange(key, 0, -1);
|
|
|
+
|
|
|
+ if (!videoIds || videoIds.length === 0) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fetch details for all videoIds
|
|
|
+ const details = await this.getVideoDetailsBatch(videoIds);
|
|
|
+ return details.filter((d) => d !== null) as VideoDetailDto[];
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching home section videos for channelId=${channelId}, section=${section}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return [];
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Convert DB timestamp (BigInt | number | Date | null) to milliseconds number.
|
|
|
- * DB layer can use BigInt; here we flatten to number for JSON.
|
|
|
+ * Fetch video details for multiple videoIds using Redis pipeline for efficiency.
|
|
|
*/
|
|
|
- private toMillis(value: unknown): number {
|
|
|
- if (typeof value === 'bigint') {
|
|
|
- return Number(value);
|
|
|
+ private async getVideoDetailsBatch(
|
|
|
+ videoIds: string[],
|
|
|
+ ): Promise<(VideoDetailDto | null)[]> {
|
|
|
+ if (!videoIds || videoIds.length === 0) {
|
|
|
+ return [];
|
|
|
}
|
|
|
- if (typeof value === 'number') {
|
|
|
- return value;
|
|
|
- }
|
|
|
- if (value instanceof Date) {
|
|
|
- return value.getTime();
|
|
|
+
|
|
|
+ try {
|
|
|
+ const keys = videoIds.map((id) => tsCacheKeys.video.detail(id));
|
|
|
+ const results: (VideoDetailDto | null)[] = [];
|
|
|
+
|
|
|
+ // Fetch all in parallel
|
|
|
+ for (const key of keys) {
|
|
|
+ const cached = await this.redis.getJson<VideoDetailDto | null>(key);
|
|
|
+ results.push(cached ?? null);
|
|
|
+ }
|
|
|
+
|
|
|
+ return results;
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching video details batch`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return videoIds.map(() => null);
|
|
|
}
|
|
|
- return 0;
|
|
|
}
|
|
|
}
|