|
|
@@ -9,6 +9,10 @@ import {
|
|
|
VideoTagDto,
|
|
|
VideoDetailDto,
|
|
|
VideoPageDto,
|
|
|
+ VideoCategoryWithTagsResponseDto,
|
|
|
+ VideoListRequestDto,
|
|
|
+ VideoListResponseDto,
|
|
|
+ VideoSearchByTagRequestDto,
|
|
|
} from './dto';
|
|
|
|
|
|
/**
|
|
|
@@ -665,4 +669,660 @@ export class VideoService {
|
|
|
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);
|
|
|
+ // Tag metadata is stored as a LIST of JSON strings, not a single JSON string
|
|
|
+ const tagJsonStrings = await this.redis.lrange(tagKey, 0, -1);
|
|
|
+
|
|
|
+ const tags: Array<{ name: string; seq: number }> = [];
|
|
|
+ if (tagJsonStrings && tagJsonStrings.length > 0) {
|
|
|
+ for (const jsonStr of tagJsonStrings) {
|
|
|
+ try {
|
|
|
+ const tag = JSON.parse(jsonStr);
|
|
|
+ tags.push({
|
|
|
+ name: tag.name,
|
|
|
+ seq: tag.seq,
|
|
|
+ });
|
|
|
+ } catch (parseErr) {
|
|
|
+ this.logger.debug(
|
|
|
+ `Failed to parse tag JSON for category ${category.id}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.
|
|
|
+ */
|
|
|
+ async getVideoList(dto: VideoListRequestDto): Promise<VideoListResponseDto> {
|
|
|
+ const { page, size, categoryId, tagName } = dto;
|
|
|
+ let key: string;
|
|
|
+ let tagId: string | undefined;
|
|
|
+
|
|
|
+ // Step 1: Resolve the Redis key
|
|
|
+ if (!tagName) {
|
|
|
+ // No tag filter - use category list
|
|
|
+ key = tsCacheKeys.video.categoryList(categoryId);
|
|
|
+ } else {
|
|
|
+ // Tag filter - need to find tag ID first
|
|
|
+ try {
|
|
|
+ const tagKey = tsCacheKeys.tag.metadataByCategory(categoryId);
|
|
|
+ const tags =
|
|
|
+ await this.redis.getJson<
|
|
|
+ Array<{ id: string; name: string; seq: number }>
|
|
|
+ >(tagKey);
|
|
|
+
|
|
|
+ if (!tags || tags.length === 0) {
|
|
|
+ this.logger.debug(
|
|
|
+ `No tags found for categoryId=${categoryId}, tagName=${tagName}`,
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const tag = tags.find((t) => t.name === tagName);
|
|
|
+ if (!tag) {
|
|
|
+ this.logger.debug(
|
|
|
+ `Tag not found: categoryId=${categoryId}, tagName=${tagName}`,
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ tagId = tag.id;
|
|
|
+ key = tsCacheKeys.video.tagList(categoryId, tagId);
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching tag for categoryId=${categoryId}, tagName=${tagName}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 2: Get total count and compute pagination
|
|
|
+ let total: number;
|
|
|
+ try {
|
|
|
+ total = await this.redis.llen(key);
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error getting list length for key=${key}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (total === 0) {
|
|
|
+ this.logger.debug(`Empty video list for key=${key}`);
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 3: Compute pagination indices
|
|
|
+ const start = (page - 1) * size;
|
|
|
+ const stop = start + size - 1;
|
|
|
+
|
|
|
+ // Check if page is out of range
|
|
|
+ if (start >= total) {
|
|
|
+ this.logger.debug(
|
|
|
+ `Page out of range: page=${page}, size=${size}, total=${total}, key=${key}`,
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 4: Fetch video IDs from Redis
|
|
|
+ let videoIds: string[];
|
|
|
+ try {
|
|
|
+ videoIds = await this.redis.lrange(key, start, stop);
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching video IDs from key=${key}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!videoIds || videoIds.length === 0) {
|
|
|
+ this.logger.debug(`No video IDs found for key=${key}`);
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 5: Fetch video details from MongoDB
|
|
|
+ let videos: Awaited<
|
|
|
+ ReturnType<typeof this.mongoPrisma.videoMedia.findMany>
|
|
|
+ >;
|
|
|
+ try {
|
|
|
+ videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
+ where: {
|
|
|
+ id: { in: videoIds },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching videos from MongoDB for ids=${videoIds.join(',')}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 6: Fetch category info
|
|
|
+ let category;
|
|
|
+ try {
|
|
|
+ category = await this.mongoPrisma.category.findUnique({
|
|
|
+ where: { id: categoryId },
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching category for categoryId=${categoryId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ // Continue without category info
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 7: Fetch all tags for these videos
|
|
|
+ const allTagIds = new Set<string>();
|
|
|
+ for (const video of videos) {
|
|
|
+ if (video.tagIds && Array.isArray(video.tagIds)) {
|
|
|
+ for (const tid of video.tagIds) {
|
|
|
+ allTagIds.add(tid);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let tagsById = new Map<string, string>();
|
|
|
+ if (allTagIds.size > 0) {
|
|
|
+ try {
|
|
|
+ const tagsList = await this.mongoPrisma.tag.findMany({
|
|
|
+ where: {
|
|
|
+ id: { in: Array.from(allTagIds) },
|
|
|
+ },
|
|
|
+ select: { id: true, name: true },
|
|
|
+ });
|
|
|
+
|
|
|
+ tagsById = new Map(tagsList.map((t) => [t.id, t.name]));
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching tags from MongoDB`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ // Continue without tag names
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 8: Create a map for O(1) lookup and maintain order
|
|
|
+ const videoMap = new Map(videos.map((v) => [v.id, v]));
|
|
|
+
|
|
|
+ // Step 9: Map to VideoListItemDto in the order of videoIds
|
|
|
+ const items = videoIds
|
|
|
+ .map((videoId) => {
|
|
|
+ const video = videoMap.get(videoId);
|
|
|
+ if (!video) {
|
|
|
+ this.logger.debug(
|
|
|
+ `Video not found in MongoDB for videoId=${videoId}`,
|
|
|
+ );
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Map tag IDs to tag names
|
|
|
+ const tags: string[] = [];
|
|
|
+ if (video.tagIds && Array.isArray(video.tagIds)) {
|
|
|
+ for (const tid of video.tagIds) {
|
|
|
+ const tagName = tagsById.get(tid);
|
|
|
+ if (tagName) {
|
|
|
+ tags.push(tagName);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: video.id,
|
|
|
+ title: video.title ?? '',
|
|
|
+ coverImg: video.coverImg ?? undefined,
|
|
|
+ duration: video.videoTime ?? undefined,
|
|
|
+ categoryId: video.categoryId ?? categoryId,
|
|
|
+ name: category?.name ?? '',
|
|
|
+ subtitle: category?.subtitle ?? undefined,
|
|
|
+ channelId: category?.channelId ?? '',
|
|
|
+ tags,
|
|
|
+ updateAt: video.updatedAt?.toString() ?? new Date().toISOString(),
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .filter((item): item is NonNullable<typeof item> => item !== null);
|
|
|
+
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 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> {
|
|
|
+ const { page, size, tagName } = dto;
|
|
|
+
|
|
|
+ // Step 1: Load all categories
|
|
|
+ let categories: Array<{
|
|
|
+ id: string;
|
|
|
+ name: string;
|
|
|
+ subtitle?: string;
|
|
|
+ channelId: string;
|
|
|
+ }>;
|
|
|
+ try {
|
|
|
+ const categoriesKey = tsCacheKeys.category.all();
|
|
|
+ categories = await this.redis.getJson<
|
|
|
+ Array<{
|
|
|
+ id: string;
|
|
|
+ name: string;
|
|
|
+ subtitle?: string;
|
|
|
+ channelId: string;
|
|
|
+ }>
|
|
|
+ >(categoriesKey);
|
|
|
+
|
|
|
+ if (!categories || categories.length === 0) {
|
|
|
+ // Fallback to MongoDB if Redis cache is empty
|
|
|
+ this.logger.debug(
|
|
|
+ 'Categories not found in Redis, fetching from MongoDB',
|
|
|
+ );
|
|
|
+ const categoriesFromDb = await this.mongoPrisma.category.findMany({
|
|
|
+ select: {
|
|
|
+ id: true,
|
|
|
+ name: true,
|
|
|
+ subtitle: true,
|
|
|
+ channelId: true,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ categories = categoriesFromDb.map((c) => ({
|
|
|
+ id: c.id,
|
|
|
+ name: c.name ?? '',
|
|
|
+ subtitle: c.subtitle ?? undefined,
|
|
|
+ channelId: c.channelId ?? '',
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ 'Error loading categories for tag search',
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!categories || categories.length === 0) {
|
|
|
+ this.logger.debug('No categories found');
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 2 & 3: For each category, find matching tags and collect (categoryId, tagId) pairs
|
|
|
+ const categoryTagPairs: Array<{ categoryId: string; tagId: string }> = [];
|
|
|
+
|
|
|
+ for (const category of categories) {
|
|
|
+ try {
|
|
|
+ const tagKey = tsCacheKeys.tag.metadataByCategory(category.id);
|
|
|
+ // Tag metadata is stored as a LIST of JSON strings, not a single JSON string
|
|
|
+ const tagJsonStrings = await this.redis.lrange(tagKey, 0, -1);
|
|
|
+
|
|
|
+ if (tagJsonStrings && tagJsonStrings.length > 0) {
|
|
|
+ const tags: Array<{ id: string; name: string; seq: number }> = [];
|
|
|
+ for (const jsonStr of tagJsonStrings) {
|
|
|
+ try {
|
|
|
+ const tag = JSON.parse(jsonStr);
|
|
|
+ tags.push(tag);
|
|
|
+ } catch (parseErr) {
|
|
|
+ this.logger.debug(
|
|
|
+ `Failed to parse tag JSON for category ${category.id}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const matchingTags = tags.filter((t) => t.name === tagName);
|
|
|
+ for (const tag of matchingTags) {
|
|
|
+ categoryTagPairs.push({
|
|
|
+ categoryId: category.id,
|
|
|
+ tagId: tag.id,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.debug(
|
|
|
+ `Error fetching tags for categoryId=${category.id}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ // Continue with next category
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (categoryTagPairs.length === 0) {
|
|
|
+ this.logger.debug(`No categories found with tag: ${tagName}`);
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 4: For each (categoryId, tagId) pair, read all video IDs
|
|
|
+ const allVideoIds: string[] = [];
|
|
|
+
|
|
|
+ for (const pair of categoryTagPairs) {
|
|
|
+ try {
|
|
|
+ const key = tsCacheKeys.video.tagList(pair.categoryId, pair.tagId);
|
|
|
+ const videoIds = await this.redis.lrange(key, 0, -1);
|
|
|
+ if (videoIds && videoIds.length > 0) {
|
|
|
+ allVideoIds.push(...videoIds);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.debug(
|
|
|
+ `Error reading video IDs for categoryId=${pair.categoryId}, tagId=${pair.tagId}`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ // Continue with next pair
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (allVideoIds.length === 0) {
|
|
|
+ this.logger.debug(`No videos found for tag: ${tagName}`);
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total: 0,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 5: Deduplicate and compute total
|
|
|
+ const uniqueVideoIds = Array.from(new Set(allVideoIds));
|
|
|
+ const total = uniqueVideoIds.length;
|
|
|
+
|
|
|
+ // Step 6: Apply in-memory pagination
|
|
|
+ const start = (page - 1) * size;
|
|
|
+ const end = start + size;
|
|
|
+ const pagedIds = uniqueVideoIds.slice(start, end);
|
|
|
+
|
|
|
+ if (pagedIds.length === 0) {
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 7: Fetch videos from MongoDB
|
|
|
+ let videos: Awaited<
|
|
|
+ ReturnType<typeof this.mongoPrisma.videoMedia.findMany>
|
|
|
+ >;
|
|
|
+ try {
|
|
|
+ videos = await this.mongoPrisma.videoMedia.findMany({
|
|
|
+ where: {
|
|
|
+ id: { in: pagedIds },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ `Error fetching videos from MongoDB for tag search`,
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!videos || videos.length === 0) {
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items: [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fetch category data for each video
|
|
|
+ const categoryIds = Array.from(
|
|
|
+ new Set(
|
|
|
+ videos.map((v) => v.categoryId).filter((id): id is string => !!id),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ const categoriesMap = new Map(
|
|
|
+ categories
|
|
|
+ .filter((c) => categoryIds.includes(c.id))
|
|
|
+ .map((c) => [c.id, c]),
|
|
|
+ );
|
|
|
+
|
|
|
+ // Fetch all tags for mapping tag IDs to names
|
|
|
+ const allTagIds = Array.from(
|
|
|
+ new Set(
|
|
|
+ videos.flatMap((v) =>
|
|
|
+ v.tagIds && Array.isArray(v.tagIds) ? v.tagIds : [],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ const tagsById = new Map<string, string>();
|
|
|
+ try {
|
|
|
+ if (allTagIds.length > 0) {
|
|
|
+ const tags = await this.mongoPrisma.tag.findMany({
|
|
|
+ where: {
|
|
|
+ id: { in: allTagIds },
|
|
|
+ },
|
|
|
+ select: {
|
|
|
+ id: true,
|
|
|
+ name: true,
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ for (const tag of tags) {
|
|
|
+ if (tag.name) {
|
|
|
+ tagsById.set(tag.id, tag.name);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.error(
|
|
|
+ 'Error fetching tags for search results',
|
|
|
+ err instanceof Error ? err.stack : String(err),
|
|
|
+ );
|
|
|
+ // Continue without tag names
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 8: Map to VideoListItemDto (maintain order of pagedIds)
|
|
|
+ const videoMap = new Map(videos.map((v) => [v.id, v]));
|
|
|
+
|
|
|
+ const items = pagedIds
|
|
|
+ .map((videoId) => {
|
|
|
+ const video = videoMap.get(videoId);
|
|
|
+ if (!video) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const category = video.categoryId
|
|
|
+ ? categoriesMap.get(video.categoryId)
|
|
|
+ : undefined;
|
|
|
+
|
|
|
+ // Map tag IDs to tag names
|
|
|
+ const tags: string[] = [];
|
|
|
+ if (video.tagIds && Array.isArray(video.tagIds)) {
|
|
|
+ for (const tid of video.tagIds) {
|
|
|
+ const tagName = tagsById.get(tid);
|
|
|
+ if (tagName) {
|
|
|
+ tags.push(tagName);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: video.id,
|
|
|
+ title: video.title ?? '',
|
|
|
+ coverImg: video.coverImg ?? undefined,
|
|
|
+ duration: video.videoTime ?? undefined,
|
|
|
+ categoryId: video.categoryId ?? '',
|
|
|
+ name: category?.name ?? '',
|
|
|
+ subtitle: category?.subtitle ?? undefined,
|
|
|
+ channelId: category?.channelId ?? '',
|
|
|
+ tags,
|
|
|
+ updateAt: video.updatedAt?.toString() ?? new Date().toISOString(),
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .filter((item): item is NonNullable<typeof item> => item !== null);
|
|
|
+
|
|
|
+ return {
|
|
|
+ page,
|
|
|
+ size,
|
|
|
+ total,
|
|
|
+ tagName,
|
|
|
+ items,
|
|
|
+ };
|
|
|
+ }
|
|
|
}
|