|
|
@@ -47,47 +47,6 @@ import { CategoryDto } from '../homepage/dto/homepage.dto';
|
|
|
*/
|
|
|
@Injectable()
|
|
|
export class VideoService {
|
|
|
- /**
|
|
|
- * Get latest videos for a category from Redis (for controller endpoint).
|
|
|
- * Uses key: box:app:video:list:category:{channelId}:{categoryId}:latest
|
|
|
- */
|
|
|
- async getLatestVideosByCategory(
|
|
|
- // channelId: string,
|
|
|
- categoryId: string,
|
|
|
- ): Promise<VideoDetailDto[]> {
|
|
|
- try {
|
|
|
- const key = tsCacheKeys.video.categoryList(categoryId);
|
|
|
- const videoIds = await this.cacheHelper.getVideoIdList(key);
|
|
|
- if (!videoIds || videoIds.length === 0) {
|
|
|
- return [];
|
|
|
- }
|
|
|
- const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
- where: { id: { in: videoIds } },
|
|
|
- });
|
|
|
- const videoMap = new Map(videos.map((v) => [v.id, v]));
|
|
|
- return videoIds
|
|
|
- .map((id) => {
|
|
|
- const video = videoMap.get(id);
|
|
|
- if (!video) return null;
|
|
|
- return {
|
|
|
- id: video.id,
|
|
|
- title: video.title ?? '',
|
|
|
- categoryIds: video.categoryIds,
|
|
|
- tagIds: video.tagIds,
|
|
|
- listStatus: video.listStatus,
|
|
|
- editedAt: video.editedAt?.toString() ?? '',
|
|
|
- updatedAt: video.updatedAt?.toISOString() ?? '',
|
|
|
- } as VideoDetailDto;
|
|
|
- })
|
|
|
- .filter((v): v is VideoDetailDto => v !== null);
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching latest videos for categoryId=${categoryId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- return [];
|
|
|
- }
|
|
|
- }
|
|
|
private readonly logger = new Logger(VideoService.name);
|
|
|
private readonly cacheHelper: VideoCacheHelper;
|
|
|
|
|
|
@@ -118,490 +77,6 @@ export class VideoService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Get category list for a channel.
|
|
|
- *
|
|
|
- * NEW SEMANTICS:
|
|
|
- * - Key box:app:video:category:list:{categoryId} stores VIDEO IDs only (not category JSON)
|
|
|
- * - This method needs to query MongoDB for category metadata
|
|
|
- *
|
|
|
- * For backward compatibility during migration:
|
|
|
- * - If the key contains JSON objects (old format), detect and warn
|
|
|
- * - Fall back to MongoDB query
|
|
|
- */
|
|
|
- async getCategoryListForChannel(
|
|
|
- channelId: string,
|
|
|
- ): Promise<VideoCategoryDto[]> {
|
|
|
- try {
|
|
|
- // Fetch categories from MongoDB (primary source for metadata)
|
|
|
- // NOTE: Categories are no longer tied to Channel, returning all active categories
|
|
|
- const categories = await this.mongoPrisma.category.findMany({
|
|
|
- where: { status: 1 },
|
|
|
- orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
|
|
|
- });
|
|
|
-
|
|
|
- // Transform to DTOs
|
|
|
- return categories.map((cat) => ({
|
|
|
- id: cat.id,
|
|
|
- name: cat.name,
|
|
|
- subtitle: cat.subtitle ?? null,
|
|
|
- seq: cat.seq,
|
|
|
- status: cat.status,
|
|
|
- createAt: cat.createAt.toString(),
|
|
|
- updateAt: cat.updateAt.toString(),
|
|
|
- }));
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching category list for channelId=${channelId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- return [];
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Get tag list for a category.
|
|
|
- *
|
|
|
- * NEW SEMANTICS:
|
|
|
- * - Key box:app:tag:list:{categoryId} stores TAG JSON objects (correct format)
|
|
|
- * - Key box:app:video:tag:list:{categoryId}:{tagId} stores VIDEO IDs only
|
|
|
- *
|
|
|
- * This method reads from box:app:tag:list:{categoryId} to get tag metadata.
|
|
|
- * Falls back to MongoDB if cache miss.
|
|
|
- */
|
|
|
- async getTagListForCategory(categoryId: string): Promise<VideoTagDto[]> {
|
|
|
- try {
|
|
|
- // Use helper to read tag metadata from cache
|
|
|
- const key = tsCacheKeys.tag.metadataByCategory(categoryId);
|
|
|
- const tags = await this.cacheHelper.getTagListForCategory(key);
|
|
|
-
|
|
|
- if (!tags || tags.length === 0) {
|
|
|
- this.logger.debug(
|
|
|
- `Cache miss for tag list, falling back to DB: ${key}`,
|
|
|
- );
|
|
|
- return this.getTagListFromDb(categoryId);
|
|
|
- }
|
|
|
-
|
|
|
- // Transform to DTOs
|
|
|
- return tags.map((tag) => ({
|
|
|
- id: tag.id,
|
|
|
- name: tag.name,
|
|
|
- seq: tag.seq,
|
|
|
- status: tag.status,
|
|
|
- createAt: tag.createAt,
|
|
|
- updateAt: tag.updateAt,
|
|
|
- categoryId: tag.categoryId,
|
|
|
- }));
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching tag list for categoryId=${categoryId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- // Fall back to DB on error
|
|
|
- return this.getTagListFromDb(categoryId);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Fallback: Get tag list from MongoDB when cache is unavailable.
|
|
|
- */
|
|
|
- private async getTagListFromDb(categoryId: string): Promise<VideoTagDto[]> {
|
|
|
- try {
|
|
|
- const tags = await this.mongoPrisma.tag.findMany({
|
|
|
- where: { status: 1, categoryId },
|
|
|
- orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
|
|
|
- });
|
|
|
-
|
|
|
- return tags.map((tag) => ({
|
|
|
- id: tag.id,
|
|
|
- name: tag.name,
|
|
|
- seq: tag.seq,
|
|
|
- status: tag.status,
|
|
|
- createAt: tag.createAt.toString(),
|
|
|
- updateAt: tag.updateAt.toString(),
|
|
|
- categoryId: tag.categoryId,
|
|
|
- }));
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching tags from DB for categoryId=${categoryId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- return [];
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Get videos under a category with pagination.
|
|
|
- *
|
|
|
- * NEW SEMANTICS:
|
|
|
- * - Key box:app:video:category:list:{categoryId} stores VIDEO IDs only
|
|
|
- * - Read IDs from LIST, then fetch video details from MongoDB
|
|
|
- * - Preserve the order of IDs from Redis
|
|
|
- *
|
|
|
- * Uses ZREVRANGE on appVideoCategoryPoolKey for score-based pagination (pool keys).
|
|
|
- * For simple listing, could use the categoryList key directly.
|
|
|
- */
|
|
|
- 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',
|
|
|
- );
|
|
|
-
|
|
|
- // Calculate offset and limit for ZREVRANGE
|
|
|
- const offset = (page - 1) * pageSize;
|
|
|
- const limit = pageSize;
|
|
|
-
|
|
|
- // ZREVRANGE returns items in descending order by score (latest first)
|
|
|
- const videoIds = await this.redis.zrevrange(
|
|
|
- key,
|
|
|
- offset,
|
|
|
- offset + limit - 1,
|
|
|
- );
|
|
|
-
|
|
|
- if (!videoIds || videoIds.length === 0) {
|
|
|
- this.logger.debug(
|
|
|
- `Cache miss for category pool, trying category list: ${key}`,
|
|
|
- );
|
|
|
- return this.getVideosByCategoryListFallback({
|
|
|
- categoryId,
|
|
|
- page,
|
|
|
- pageSize,
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // Fetch details for all videoIds from MongoDB (preserving order)
|
|
|
- const details = await this.getVideoDetailsBatchFromDb(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,
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Fallback: Get videos by category using the category video list (not pool).
|
|
|
- * NEW SEMANTICS: Reads box:app:video:category:list:{categoryId} which contains video IDs.
|
|
|
- */
|
|
|
- private async getVideosByCategoryListFallback(params: {
|
|
|
- categoryId: string;
|
|
|
- page: number;
|
|
|
- pageSize: number;
|
|
|
- }): Promise<VideoPageDto<VideoDetailDto>> {
|
|
|
- const { categoryId, page, pageSize } = params;
|
|
|
-
|
|
|
- const key = tsCacheKeys.video.categoryList(categoryId);
|
|
|
- const start = (page - 1) * pageSize;
|
|
|
- const stop = start + pageSize - 1;
|
|
|
-
|
|
|
- let listExists = false;
|
|
|
- let videoIds: string[] = [];
|
|
|
-
|
|
|
- try {
|
|
|
- listExists = (await this.redis.exists(key)) > 0;
|
|
|
- videoIds = await this.cacheHelper.getVideoIdList(key, start, stop);
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error reading category list key=${key}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- listExists = false;
|
|
|
- videoIds = [];
|
|
|
- }
|
|
|
-
|
|
|
- let usedCache = false;
|
|
|
- if (listExists && videoIds.length > 0) {
|
|
|
- if (videoIds[0] && this.isLegacyJsonFormat(videoIds[0])) {
|
|
|
- this.logger.warn(
|
|
|
- `Detected legacy JSON format in ${key}, falling back to DB query`,
|
|
|
- );
|
|
|
- } else {
|
|
|
- usedCache = true;
|
|
|
- const details = await this.getVideoDetailsBatchFromDb(videoIds);
|
|
|
- return {
|
|
|
- items: details.filter((d) => d !== null) as VideoDetailDto[],
|
|
|
- total: undefined,
|
|
|
- page,
|
|
|
- pageSize,
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (!usedCache) {
|
|
|
- const reason = listExists ? 'empty list' : 'missing key';
|
|
|
- this.logger.debug(
|
|
|
- `Cache miss for category list (${reason}), falling back to DB: ${key}`,
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
- where: { categoryIds: { has: categoryId }, listStatus: 1 },
|
|
|
- orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
|
|
|
- skip: start,
|
|
|
- take: pageSize,
|
|
|
- });
|
|
|
-
|
|
|
- const items = videos.map((v) => ({
|
|
|
- id: v.id,
|
|
|
- title: v.title,
|
|
|
- categoryIds: v.categoryIds,
|
|
|
- tagIds: v.tagIds,
|
|
|
- listStatus: v.listStatus,
|
|
|
- editedAt: v.editedAt.toString(),
|
|
|
- updatedAt: v.updatedAt.toISOString(),
|
|
|
- }));
|
|
|
-
|
|
|
- const cachedIds = videos.map((video) => video.id);
|
|
|
- try {
|
|
|
- await this.cacheHelper.saveVideoIdList(key, cachedIds);
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error saving video ID list for key=${key}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- const entries = videos.map((video) => ({
|
|
|
- key: tsCacheKeys.video.payload(video.id),
|
|
|
- value: toVideoPayload(video as RawVideoPayloadRow),
|
|
|
- }));
|
|
|
-
|
|
|
- try {
|
|
|
- await this.redis.pipelineSetJson(entries);
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error writing payload cache for category list fallback key=${key}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- items,
|
|
|
- total: undefined,
|
|
|
- page,
|
|
|
- pageSize,
|
|
|
- };
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching videos from DB for categoryId=${categoryId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- return {
|
|
|
- items: [],
|
|
|
- total: 0,
|
|
|
- page,
|
|
|
- pageSize,
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Get videos under a tag with pagination.
|
|
|
- *
|
|
|
- * NEW SEMANTICS:
|
|
|
- * - Key box:app:video:tag:list:{categoryId}:{tagId} stores VIDEO IDs only
|
|
|
- * - Read IDs from LIST, then fetch video details from MongoDB
|
|
|
- *
|
|
|
- * Uses ZREVRANGE on appVideoTagPoolKey for score-based pagination (pool keys).
|
|
|
- */
|
|
|
- async getVideosByTagWithPaging(params: {
|
|
|
- channelId: string;
|
|
|
- categoryId: string;
|
|
|
- tagId: string;
|
|
|
- page?: number;
|
|
|
- pageSize?: number;
|
|
|
- }): Promise<VideoPageDto<VideoDetailDto>> {
|
|
|
- const { channelId, categoryId, 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,
|
|
|
- );
|
|
|
-
|
|
|
- if (!videoIds || videoIds.length === 0) {
|
|
|
- this.logger.debug(`Cache miss for tag pool, trying tag list: ${key}`);
|
|
|
- return this.getVideosByTagListFallback({
|
|
|
- categoryId,
|
|
|
- tagId,
|
|
|
- page,
|
|
|
- pageSize,
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- const details = await this.getVideoDetailsBatchFromDb(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,
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Fallback: Get videos by tag using the tag video list (not pool).
|
|
|
- * NEW SEMANTICS: Reads box:app:video:tag:list:{categoryId}:{tagId} which contains video IDs.
|
|
|
- */
|
|
|
- private async getVideosByTagListFallback(params: {
|
|
|
- categoryId: string;
|
|
|
- tagId: string;
|
|
|
- page: number;
|
|
|
- pageSize: number;
|
|
|
- }): Promise<VideoPageDto<VideoDetailDto>> {
|
|
|
- const { categoryId, tagId, page, pageSize } = params;
|
|
|
-
|
|
|
- const key = tsCacheKeys.video.tagList(categoryId, tagId);
|
|
|
- const start = (page - 1) * pageSize;
|
|
|
- const stop = start + pageSize - 1;
|
|
|
-
|
|
|
- let listExists = false;
|
|
|
- let videoIds: string[] = [];
|
|
|
-
|
|
|
- try {
|
|
|
- listExists = (await this.redis.exists(key)) > 0;
|
|
|
- videoIds = await this.cacheHelper.getVideoIdList(key, start, stop);
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error reading tag list key=${key}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- listExists = false;
|
|
|
- videoIds = [];
|
|
|
- }
|
|
|
-
|
|
|
- let usedCache = false;
|
|
|
- if (listExists && videoIds.length > 0) {
|
|
|
- if (videoIds[0] && this.isLegacyJsonFormat(videoIds[0])) {
|
|
|
- this.logger.warn(
|
|
|
- `Detected legacy JSON format in ${key}, falling back to DB query`,
|
|
|
- );
|
|
|
- } else {
|
|
|
- usedCache = true;
|
|
|
- const details = await this.getVideoDetailsBatchFromDb(videoIds);
|
|
|
- return {
|
|
|
- items: details.filter((d) => d !== null) as VideoDetailDto[],
|
|
|
- total: undefined,
|
|
|
- page,
|
|
|
- pageSize,
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (!usedCache) {
|
|
|
- const reason = listExists ? 'empty list' : 'missing key';
|
|
|
- this.logger.debug(
|
|
|
- `Cache miss for tag list (${reason}), falling back to DB: ${key}`,
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
- where: {
|
|
|
- categoryIds: { has: categoryId },
|
|
|
- status: 'Completed',
|
|
|
- tagIds: { has: tagId },
|
|
|
- },
|
|
|
- orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
|
|
|
- skip: start,
|
|
|
- take: pageSize,
|
|
|
- });
|
|
|
-
|
|
|
- const items = videos.map((v) => ({
|
|
|
- id: v.id,
|
|
|
- title: v.title,
|
|
|
- categoryIds: v.categoryIds,
|
|
|
- tagIds: v.tagIds,
|
|
|
- listStatus: v.listStatus,
|
|
|
- editedAt: v.editedAt.toString(),
|
|
|
- updatedAt: v.updatedAt.toISOString(),
|
|
|
- }));
|
|
|
-
|
|
|
- const cachedIds = videos.map((video) => video.id);
|
|
|
- try {
|
|
|
- await this.cacheHelper.saveVideoIdList(key, cachedIds);
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error saving video ID list for key=${key}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- const entries = videos.map((video) => ({
|
|
|
- key: tsCacheKeys.video.payload(video.id),
|
|
|
- value: toVideoPayload(video as RawVideoPayloadRow),
|
|
|
- }));
|
|
|
-
|
|
|
- try {
|
|
|
- await this.redis.pipelineSetJson(entries);
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error writing payload cache for tag list fallback key=${key}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- items,
|
|
|
- total: undefined,
|
|
|
- page,
|
|
|
- pageSize,
|
|
|
- };
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching videos from DB for categoryId=${categoryId}, tagId=${tagId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- return {
|
|
|
- items: [],
|
|
|
- total: 0,
|
|
|
- page,
|
|
|
- pageSize,
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
* Get home section videos for a channel.
|
|
|
* Reads from appVideoHomeSectionKey (LIST of videoIds).
|
|
|
* Returns video details for each ID.
|
|
|
@@ -632,10 +107,6 @@ export class VideoService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Fetch video details for multiple videoIds using Redis pipeline for efficiency.
|
|
|
- * DEPRECATED: Use getVideoDetailsBatchFromDb instead for new semantics.
|
|
|
- */
|
|
|
private async getVideoDetailsBatch(
|
|
|
videoIds: string[],
|
|
|
): Promise<(VideoDetailDto | null)[]> {
|
|
|
@@ -663,53 +134,6 @@ export class VideoService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Fetch video details from MongoDB for multiple videoIds.
|
|
|
- * NEW: Primary method for fetching video details in new semantics.
|
|
|
- * Preserves the order of videoIds.
|
|
|
- */
|
|
|
- private async getVideoDetailsBatchFromDb(
|
|
|
- videoIds: string[],
|
|
|
- ): Promise<(VideoDetailDto | null)[]> {
|
|
|
- if (!videoIds || videoIds.length === 0) {
|
|
|
- return [];
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- // Fetch all videos in one query
|
|
|
- const videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
- where: { id: { in: videoIds } },
|
|
|
- });
|
|
|
-
|
|
|
- // Create a map for O(1) lookup
|
|
|
- const videoMap = new Map(videos.map((v) => [v.id, v]));
|
|
|
-
|
|
|
- // Preserve original order
|
|
|
- return videoIds.map((id) => {
|
|
|
- const video = videoMap.get(id);
|
|
|
- if (!video) {
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- id: video.id,
|
|
|
- title: video.title,
|
|
|
- categoryIds: video.categoryIds,
|
|
|
- tagIds: video.tagIds,
|
|
|
- listStatus: video.listStatus,
|
|
|
- editedAt: video.editedAt.toString(),
|
|
|
- updatedAt: video.updatedAt.toISOString(),
|
|
|
- };
|
|
|
- });
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching video details from DB`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- }
|
|
|
- return videoIds.map(() => null);
|
|
|
- }
|
|
|
-
|
|
|
private async getVideoPayloadsByIds(
|
|
|
videoIds: string[],
|
|
|
): Promise<VideoPayload[]> {
|
|
|
@@ -789,113 +213,6 @@ export class VideoService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Detect legacy JSON format in Redis cache.
|
|
|
- * Legacy format: Category/Tag JSON objects with "name" field.
|
|
|
- * New format: Video IDs (ObjectId strings, no JSON structure).
|
|
|
- *
|
|
|
- * Returns true if the value looks like legacy JSON format.
|
|
|
- */
|
|
|
- private isLegacyJsonFormat(value: string): boolean {
|
|
|
- try {
|
|
|
- // Video IDs are 24-character hex strings (MongoDB ObjectId)
|
|
|
- if (/^[0-9a-f]{24}$/i.test(value)) {
|
|
|
- return false; // This is a video ID, not JSON
|
|
|
- }
|
|
|
-
|
|
|
- // Try to parse as JSON
|
|
|
- const parsed = JSON.parse(value);
|
|
|
-
|
|
|
- // If it has "name" but no typical video fields, it's likely legacy format
|
|
|
- if (
|
|
|
- parsed &&
|
|
|
- typeof parsed === 'object' &&
|
|
|
- 'name' in parsed &&
|
|
|
- !('videoUrl' in parsed) &&
|
|
|
- !('title' in parsed)
|
|
|
- ) {
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- return false;
|
|
|
- } catch {
|
|
|
- // Not valid JSON, assume it's a video ID
|
|
|
- return false;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Get all categories with their associated tags.
|
|
|
- * Reads categories from Redis cache (app:category:all).
|
|
|
- * For each category, reads tags from Redis (box:app:tag:list:{categoryId}).
|
|
|
- * If cache is missing/empty, treats as empty list.
|
|
|
- */
|
|
|
- async getCategoriesWithTags(): Promise<VideoCategoryWithTagsResponseDto> {
|
|
|
- try {
|
|
|
- const key = tsCacheKeys.category.all();
|
|
|
- const rawCategories = await this.redis.getJson<
|
|
|
- Array<{
|
|
|
- id: string;
|
|
|
- name: string;
|
|
|
- subtitle?: string | null;
|
|
|
- seq: number;
|
|
|
- channelId: string;
|
|
|
- }>
|
|
|
- >(key);
|
|
|
-
|
|
|
- if (!rawCategories || rawCategories.length === 0) {
|
|
|
- this.logger.debug(`Cache miss for categories list key: ${key}`);
|
|
|
- return { items: [] };
|
|
|
- }
|
|
|
-
|
|
|
- // For each category, fetch its tags from cache
|
|
|
- const items = await Promise.all(
|
|
|
- rawCategories.map(async (category) => {
|
|
|
- try {
|
|
|
- const tagKey = tsCacheKeys.tag.metadataByCategory(category.id);
|
|
|
- const tagMetadata =
|
|
|
- await this.cacheHelper.getTagListForCategory(tagKey);
|
|
|
- const tags = (tagMetadata ?? []).map((tag) => ({
|
|
|
- name: tag.name,
|
|
|
- seq: tag.seq,
|
|
|
- }));
|
|
|
-
|
|
|
- return {
|
|
|
- id: category.id,
|
|
|
- name: category.name,
|
|
|
- subtitle: category.subtitle ?? undefined,
|
|
|
- seq: category.seq,
|
|
|
- channelId: category.channelId,
|
|
|
- tags,
|
|
|
- };
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching tags for categoryId=${category.id}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- // Return category with empty tags on error
|
|
|
- return {
|
|
|
- id: category.id,
|
|
|
- name: category.name,
|
|
|
- subtitle: category.subtitle ?? undefined,
|
|
|
- seq: category.seq,
|
|
|
- channelId: category.channelId,
|
|
|
- tags: [],
|
|
|
- };
|
|
|
- }
|
|
|
- }),
|
|
|
- );
|
|
|
-
|
|
|
- return { items };
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `Error fetching categories with tags`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- return { items: [] };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
* Get paginated list of videos for a category with optional tag filtering.
|
|
|
* Reads video IDs from Redis cache, fetches full details from MongoDB,
|
|
|
* and returns paginated results.
|
|
|
@@ -1297,19 +614,6 @@ export class VideoService {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Search videos by tag name across all categories.
|
|
|
- *
|
|
|
- * Algorithm:
|
|
|
- * 1. Load all categories (from Redis or MongoDB)
|
|
|
- * 2. For each category, read tag metadata and find tags matching tagName
|
|
|
- * 3. Collect (categoryId, tagId) pairs where tag.name === dto.tagName
|
|
|
- * 4. For each pair, read all video IDs from box:app:video:tag:list:{categoryId}:{tagId}
|
|
|
- * 5. Combine all IDs, deduplicate, compute total
|
|
|
- * 6. Apply in-memory pagination on unique ID list
|
|
|
- * 7. Fetch videos from MongoDB, join with category metadata
|
|
|
- * 8. Map to VideoListItemDto and return response
|
|
|
- */
|
|
|
async searchVideosByTagName(
|
|
|
dto: VideoSearchByTagRequestDto,
|
|
|
): Promise<VideoListResponseDto> {
|