|
|
@@ -0,0 +1,266 @@
|
|
|
+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;
|
|
|
+};
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class VideoService {
|
|
|
+ constructor(
|
|
|
+ private readonly prisma: PrismaMongoService,
|
|
|
+ @Inject(CACHE_MANAGER) private readonly cache: Cache,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 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
|
|
|
+ */
|
|
|
+ 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',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+ 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
|
|
|
+ */
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 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,
|
|
|
+ );
|
|
|
+ return fallback;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (tagsFlat) {
|
|
|
+ // textual similarity in tagsFlat
|
|
|
+ orConditions.push({
|
|
|
+ tagsFlat: {
|
|
|
+ contains: tagsFlat,
|
|
|
+ mode: 'insensitive',
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (orConditions.length > 0) {
|
|
|
+ where.OR = orConditions;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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));
|
|
|
+ }
|
|
|
+
|
|
|
+ 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('|');
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildRecommendCacheKey(videoId: string, limit: number): string {
|
|
|
+ return ['video:recommend', `id=${videoId}`, `limit=${limit}`].join('|');
|
|
|
+ }
|
|
|
+
|
|
|
+ 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),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Convert DB timestamp (BigInt | number | Date | null) to milliseconds number.
|
|
|
+ * DB layer can use BigInt; here we flatten to number for JSON.
|
|
|
+ */
|
|
|
+ private toMillis(value: unknown): number {
|
|
|
+ if (typeof value === 'bigint') {
|
|
|
+ return Number(value);
|
|
|
+ }
|
|
|
+ if (typeof value === 'number') {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+ if (value instanceof Date) {
|
|
|
+ return value.getTime();
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+}
|