video-media.service.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. import {
  2. Injectable,
  3. NotFoundException,
  4. BadRequestException,
  5. Inject,
  6. } from '@nestjs/common';
  7. import type { MultipartFile } from '@fastify/multipart';
  8. import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
  9. import { CacheSyncService } from '../../../cache-sync/cache-sync.service';
  10. import { CacheEntityType } from '../../../cache-sync/cache-sync.types';
  11. import { MediaManagerService } from '@box/core/media-manager/media-manager.service';
  12. import type { StorageStrategy } from '@box/core/media-manager/types';
  13. import { randomUUID } from 'crypto';
  14. import {
  15. VideoMediaListQueryDto,
  16. UpdateVideoMediaManageDto,
  17. UpdateVideoMediaStatusDto,
  18. BatchUpdateVideoMediaStatusDto,
  19. } from './video-media.dto';
  20. import { MEDIA_STORAGE_STRATEGY } from '../../../shared/tokens';
  21. type MongoAggregateResult = {
  22. cursor?: {
  23. firstBatch?: any[];
  24. };
  25. };
  26. @Injectable()
  27. export class VideoMediaService {
  28. constructor(
  29. private readonly prisma: MongoPrismaService,
  30. private readonly cacheSyncService: CacheSyncService,
  31. private readonly mediaManagerService: MediaManagerService,
  32. @Inject(MEDIA_STORAGE_STRATEGY)
  33. private readonly mediaStorageStrategy: StorageStrategy,
  34. ) {}
  35. async findAll(query: VideoMediaListQueryDto): Promise<any> {
  36. const page = query.page ?? 1;
  37. const pageSize = query.size ?? 20;
  38. const skip = (page - 1) * pageSize;
  39. const take = pageSize;
  40. const baseWhere = this.buildVideoListBaseFilter(query);
  41. const keyword = query.keyword?.trim();
  42. let total: number;
  43. let rows: any[];
  44. if (!keyword) {
  45. [total, rows] = await Promise.all([
  46. this.prisma.videoMedia.count({ where: baseWhere }),
  47. this.prisma.videoMedia.findMany({
  48. where: baseWhere,
  49. skip,
  50. take,
  51. orderBy: { addedTime: 'desc' },
  52. }),
  53. ]);
  54. } else {
  55. const regexSource = this.escapeRegex(keyword);
  56. const matchFilter = this.buildKeywordMatchFilter(baseWhere, regexSource);
  57. // Prisma Mongo cannot express regex searches inside array elements, so we fall back to a raw aggregate that uses sanitizedSecondTags.
  58. const countRes = (await this.prisma.$runCommandRaw({
  59. aggregate: 'videoMedia',
  60. pipeline: [{ $match: matchFilter }, { $count: 'total' }],
  61. cursor: {},
  62. })) as unknown as MongoAggregateResult;
  63. total = Number(countRes.cursor.firstBatch?.[0]?.total ?? 0);
  64. const dataRes = (await this.prisma.$runCommandRaw({
  65. aggregate: 'videoMedia',
  66. pipeline: [
  67. { $match: matchFilter },
  68. { $sort: { addedTime: -1 } },
  69. { $skip: skip },
  70. { $limit: take },
  71. ],
  72. cursor: {},
  73. })) as unknown as MongoAggregateResult;
  74. rows = (dataRes.cursor.firstBatch ?? []).map((doc: any) => ({
  75. ...doc,
  76. id: doc.id ?? doc._id,
  77. }));
  78. }
  79. return {
  80. total,
  81. page,
  82. pageSize,
  83. items: rows.map((row) => ({
  84. id: row.id,
  85. title: row.title,
  86. filename: row.filename,
  87. videoTime: row.videoTime,
  88. size: row.size?.toString?.() ?? '0',
  89. coverImg: row.coverImg ?? '',
  90. categoryIds: row.categoryIds ?? [],
  91. tagIds: row.tagIds ?? [],
  92. listStatus: row.listStatus ?? 0,
  93. editedAt: Number(row.editedAt ?? 0),
  94. updatedAt: row.updatedAt ?? null,
  95. tags: row.tags ?? [],
  96. tagsFlat: row.tagsFlat ?? '',
  97. secondTags: row.secondTags ?? [],
  98. // NOTE: We keep list DTO backward compatible.
  99. // If you later want to show tag names in list, we can add e.g. `tagsFlat` or `tagNames` here.
  100. })),
  101. };
  102. }
  103. private buildVideoListBaseFilter(
  104. query: VideoMediaListQueryDto,
  105. ): Record<string, any> {
  106. const where: Record<string, any> = {};
  107. if (typeof query.listStatus === 'number') {
  108. where.listStatus = query.listStatus;
  109. }
  110. if (
  111. typeof query.editedFrom === 'number' ||
  112. typeof query.editedTo === 'number'
  113. ) {
  114. const editedAt: Record<string, bigint> = {};
  115. if (typeof query.editedFrom === 'number') {
  116. editedAt.gte = BigInt(query.editedFrom);
  117. }
  118. if (typeof query.editedTo === 'number') {
  119. editedAt.lte = BigInt(query.editedTo);
  120. }
  121. where.editedAt = editedAt;
  122. }
  123. return where;
  124. }
  125. private buildKeywordMatchFilter(
  126. baseFilter: Record<string, any>,
  127. regexSource: string,
  128. ): Record<string, any> {
  129. const matchFilter = { ...baseFilter };
  130. if (Array.isArray(matchFilter.$and)) {
  131. matchFilter.$and = [...matchFilter.$and];
  132. }
  133. const keywordClause = {
  134. $or: [
  135. {
  136. title: {
  137. $regex: regexSource,
  138. $options: 'i',
  139. },
  140. },
  141. {
  142. sanitizedSecondTags: {
  143. $elemMatch: {
  144. $regex: regexSource,
  145. $options: 'i',
  146. },
  147. },
  148. },
  149. ],
  150. };
  151. matchFilter.$and = matchFilter.$and ?? [];
  152. matchFilter.$and.push(keywordClause);
  153. return matchFilter;
  154. }
  155. private escapeRegex(input: string): string {
  156. return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  157. }
  158. async findOne(id: string): Promise<any> {
  159. const video = await this.prisma.videoMedia.findUnique({
  160. where: { id },
  161. });
  162. if (!video) {
  163. throw new NotFoundException('Video not found');
  164. }
  165. const [category, tags] = await Promise.all([
  166. video.categoryIds && video.categoryIds.length > 0
  167. ? this.prisma.category.findUnique({
  168. where: { id: video.categoryIds[0] },
  169. })
  170. : null,
  171. video.tagIds && video.tagIds.length
  172. ? this.prisma.tag.findMany({
  173. where: { id: { in: video.tagIds } },
  174. orderBy: { seq: 'asc' },
  175. })
  176. : [],
  177. ]);
  178. return {
  179. id: video.id,
  180. title: video.title,
  181. filename: video.filename,
  182. videoTime: video.videoTime,
  183. size: video.size?.toString?.() ?? '0',
  184. coverImg: video.coverImg ?? '',
  185. type: video.type,
  186. formatType: video.formatType,
  187. contentType: video.contentType,
  188. country: video.country,
  189. status: video.status,
  190. desc: video.desc ?? '',
  191. categoryIds: video.categoryIds ?? [],
  192. tagIds: video.tagIds ?? [],
  193. listStatus: video.listStatus ?? 0,
  194. editedAt: Number(video.editedAt ?? 0),
  195. updatedAt: video.updatedAt ?? null,
  196. categoryName: category?.name ?? null,
  197. // Existing DTO: tags as {id, name}[]
  198. tags: video.tags ?? [],
  199. tagsFlat: video.tagsFlat ?? '',
  200. secondTags: video.secondTags ?? [],
  201. };
  202. }
  203. async updateManage(id: string, dto: UpdateVideoMediaManageDto) {
  204. const video = await this.prisma.videoMedia.findUnique({
  205. where: { id },
  206. });
  207. if (!video) {
  208. throw new NotFoundException('Video not found');
  209. }
  210. const updateData: any = {};
  211. if (typeof dto.title === 'string') {
  212. updateData.title = dto.title.trim();
  213. }
  214. let categoryId: string | null | undefined = dto.categoryId;
  215. const tagIds: string[] | undefined = dto.tagIds;
  216. if (dto.categoryId === null) {
  217. categoryId = null;
  218. }
  219. if (typeof categoryId !== 'undefined' || typeof tagIds !== 'undefined') {
  220. const { finalCategoryIds, finalTagIds, tags, tagsFlat } =
  221. await this.validateCategoryAndTags(categoryId, tagIds);
  222. updateData.categoryIds = finalCategoryIds;
  223. updateData.tagIds = finalTagIds;
  224. updateData.tags = tags; // NEW: store denormalised tag names (lowercased)
  225. updateData.tagsFlat = tagsFlat; // existing: text for search
  226. }
  227. if (typeof dto.listStatus === 'number') {
  228. if (dto.listStatus !== 0 && dto.listStatus !== 1) {
  229. throw new BadRequestException('Invalid listStatus value');
  230. }
  231. updateData.listStatus = dto.listStatus;
  232. }
  233. updateData.editedAt = BigInt(Date.now());
  234. updateData.updatedAt = new Date();
  235. await this.prisma.videoMedia.update({
  236. where: { id },
  237. data: updateData,
  238. });
  239. // Refresh category video lists cache if category changed or affected
  240. if (video.categoryIds && video.categoryIds.length > 0) {
  241. for (const cid of video.categoryIds) {
  242. await this.cacheSyncService.scheduleAction({
  243. entityType: CacheEntityType.VIDEO_LIST,
  244. operation: 'REFRESH',
  245. payload: { categoryId: cid },
  246. } as any);
  247. }
  248. }
  249. if (updateData.categoryIds && updateData.categoryIds.length > 0) {
  250. const oldCategoryIds = new Set(video.categoryIds || []);
  251. for (const cid of updateData.categoryIds) {
  252. if (!oldCategoryIds.has(cid)) {
  253. await this.cacheSyncService.scheduleAction({
  254. entityType: CacheEntityType.VIDEO_LIST,
  255. operation: 'REFRESH',
  256. payload: { categoryId: cid },
  257. } as any);
  258. }
  259. }
  260. }
  261. return this.findOne(id);
  262. }
  263. async updateStatus(id: string, dto: UpdateVideoMediaStatusDto) {
  264. const video = await this.prisma.videoMedia.findUnique({
  265. where: { id },
  266. });
  267. if (!video) {
  268. throw new NotFoundException('Video not found');
  269. }
  270. if (dto.listStatus !== 0 && dto.listStatus !== 1) {
  271. throw new BadRequestException('Invalid listStatus value');
  272. }
  273. const editedAt = BigInt(Date.now());
  274. const updatedAt = new Date();
  275. await this.prisma.videoMedia.update({
  276. where: { id },
  277. data: {
  278. listStatus: dto.listStatus,
  279. editedAt,
  280. updatedAt,
  281. },
  282. });
  283. // Refresh category video lists cache if video has a category
  284. if (video.categoryIds && video.categoryIds.length > 0) {
  285. for (const categoryId of video.categoryIds) {
  286. await this.cacheSyncService.scheduleAction({
  287. entityType: CacheEntityType.VIDEO_LIST,
  288. operation: 'REFRESH',
  289. payload: { categoryId },
  290. } as any);
  291. }
  292. }
  293. return {
  294. id,
  295. listStatus: dto.listStatus,
  296. editedAt: editedAt.toString(),
  297. };
  298. }
  299. async batchUpdateStatus(dto: BatchUpdateVideoMediaStatusDto) {
  300. if (!dto.ids?.length) {
  301. throw new BadRequestException('ids cannot be empty');
  302. }
  303. if (dto.listStatus !== 0 && dto.listStatus !== 1) {
  304. throw new BadRequestException('Invalid listStatus value');
  305. }
  306. const editedAt = BigInt(Date.now());
  307. const updatedAt = new Date();
  308. // Fetch affected videos to get their categories for cache refresh
  309. const affectedVideos = await this.prisma.videoMedia.findMany({
  310. where: { id: { in: dto.ids } },
  311. select: { categoryIds: true },
  312. });
  313. const result = await this.prisma.videoMedia.updateMany({
  314. where: { id: { in: dto.ids } },
  315. data: {
  316. listStatus: dto.listStatus,
  317. editedAt,
  318. updatedAt,
  319. },
  320. });
  321. // Refresh cache for all affected categories (fire-and-forget)
  322. const allAffectedCategoryIds = new Set<string>();
  323. for (const video of affectedVideos) {
  324. if (Array.isArray(video.categoryIds)) {
  325. for (const cid of video.categoryIds) {
  326. allAffectedCategoryIds.add(cid);
  327. }
  328. }
  329. }
  330. for (const categoryId of allAffectedCategoryIds) {
  331. await this.cacheSyncService.scheduleAction({
  332. entityType: CacheEntityType.VIDEO_LIST,
  333. operation: 'REFRESH',
  334. payload: { categoryId },
  335. } as any);
  336. }
  337. return {
  338. affected: result.count,
  339. listStatus: dto.listStatus,
  340. editedAt: editedAt.toString(),
  341. };
  342. }
  343. // create an async function to delete a video media by id and return the deleted id also update Redis cache
  344. async delete(id: string) {
  345. const video = await this.prisma.videoMedia.findUnique({
  346. where: { id },
  347. });
  348. if (!video) {
  349. throw new NotFoundException('Video not found');
  350. }
  351. await this.prisma.videoMedia.delete({
  352. where: { id },
  353. });
  354. // Refresh category video lists cache if video has a category
  355. if (video.categoryIds && video.categoryIds.length > 0) {
  356. for (const categoryId of video.categoryIds) {
  357. await this.cacheSyncService.scheduleAction({
  358. entityType: CacheEntityType.VIDEO_LIST,
  359. operation: 'REFRESH',
  360. payload: { categoryId },
  361. } as any);
  362. }
  363. }
  364. return {
  365. id,
  366. };
  367. }
  368. /**
  369. * Upload and update VideoMedia cover image.
  370. */
  371. async updateCover(id: string, file: MultipartFile) {
  372. const video = await this.prisma.videoMedia.findUnique({ where: { id } });
  373. if (!video) {
  374. throw new NotFoundException('Video not found');
  375. }
  376. const previous = {
  377. path: video.coverImg,
  378. strategy: video.imgSource as StorageStrategy | undefined,
  379. };
  380. const filename = this.sanitizeFilename(file.filename);
  381. const relativePath = this.buildRelativePath(
  382. 'videos',
  383. 'images',
  384. id,
  385. filename,
  386. );
  387. const strategy = this.mediaStorageStrategy;
  388. const uploadResult = await this.mediaManagerService.upload({
  389. storageStrategy: strategy,
  390. relativePath: [relativePath],
  391. localStoragePrefix: 'local',
  392. fileStreams: [file.file],
  393. });
  394. if (uploadResult.status !== 1) {
  395. throw new BadRequestException('Failed to upload cover image');
  396. }
  397. const editedAt = BigInt(Math.floor(Date.now() / 1000));
  398. const updatedAt = new Date();
  399. const updated = await this.prisma.videoMedia.update({
  400. where: { id },
  401. data: {
  402. coverImg: relativePath,
  403. imgSource: uploadResult.storageStrategy,
  404. editedAt,
  405. updatedAt,
  406. },
  407. });
  408. if (video.categoryIds && video.categoryIds.length > 0) {
  409. for (const categoryId of video.categoryIds) {
  410. await this.cacheSyncService.scheduleAction({
  411. entityType: CacheEntityType.VIDEO_LIST,
  412. operation: 'REFRESH',
  413. payload: { categoryId },
  414. } as any);
  415. }
  416. }
  417. await this.cleanupPreviousCover(previous);
  418. return {
  419. id: updated.id,
  420. coverImg: updated.coverImg,
  421. imgSource: updated.imgSource,
  422. editedAt: editedAt.toString(),
  423. };
  424. }
  425. private async cleanupPreviousCover(previous: {
  426. path?: string | null;
  427. strategy?: StorageStrategy;
  428. }) {
  429. if (!previous.path || !previous.strategy) return;
  430. await this.mediaManagerService
  431. .cleanup(previous.strategy, [previous.path], 'local')
  432. .catch(() => undefined);
  433. }
  434. private buildRelativePath(
  435. domain: string,
  436. type: 'images' | 'videos' | 'others',
  437. id: string,
  438. filename: string,
  439. ): string {
  440. return `${domain}/${type}/${id}/${filename}`;
  441. }
  442. private sanitizeFilename(name?: string | null): string {
  443. const raw = (name || 'file').trim();
  444. const cleaned = raw.replace(/[\\/]+/g, '');
  445. return cleaned || `${randomUUID()}.jpg`;
  446. }
  447. private async validateCategoryAndTags(
  448. categoryId: string | null | undefined,
  449. tagIds: string[] | undefined,
  450. ): Promise<{
  451. finalCategoryIds: string[];
  452. finalTagIds: string[];
  453. tags: string[]; // NEW: denormalised tag names (lowercased)
  454. tagsFlat: string; // NEW: concatenated names for search
  455. }> {
  456. let finalCategoryIds: string[] =
  457. typeof categoryId === 'undefined' || categoryId === null
  458. ? []
  459. : [categoryId];
  460. let finalTagIds: string[] = [];
  461. let tags: string[] = []; // NEW
  462. let tagsFlat = '';
  463. // Normalize tagIds: remove duplicates
  464. if (Array.isArray(tagIds)) {
  465. const unique = [...new Set(tagIds)];
  466. if (unique.length > 5) {
  467. throw new BadRequestException('Tag count cannot exceed 5');
  468. }
  469. finalTagIds = unique;
  470. }
  471. // If tags are provided but categoryId is null/undefined -> error
  472. if (finalTagIds.length > 0 && finalCategoryIds.length === 0) {
  473. throw new BadRequestException(
  474. 'Category is required when tags are provided.',
  475. );
  476. }
  477. // Validate category if present
  478. if (finalCategoryIds.length > 0) {
  479. const category = await this.prisma.category.findUnique({
  480. where: { id: finalCategoryIds[0] },
  481. });
  482. if (!category) {
  483. throw new BadRequestException('Category not found');
  484. }
  485. if (category.status !== 1) {
  486. throw new BadRequestException('Category is disabled');
  487. }
  488. }
  489. if (finalTagIds.length > 0) {
  490. const tagEntities = await this.prisma.tag.findMany({
  491. where: { id: { in: finalTagIds } },
  492. });
  493. if (tagEntities.length !== finalTagIds.length) {
  494. throw new BadRequestException('Some tags do not exist');
  495. }
  496. const distinctCategoryIds = [
  497. ...new Set(tagEntities.map((t) => t.categoryId.toString())),
  498. ];
  499. if (distinctCategoryIds.length > 1) {
  500. throw new BadRequestException(
  501. 'All tags must belong to the same category',
  502. );
  503. }
  504. const tagCategoryId = distinctCategoryIds[0];
  505. if (
  506. finalCategoryIds.length > 0 &&
  507. tagCategoryId !== finalCategoryIds[0]
  508. ) {
  509. throw new BadRequestException(
  510. 'Tags do not belong to the specified category',
  511. );
  512. }
  513. // If categoryId was not provided but tags exist, infer from tags
  514. if (finalCategoryIds.length === 0) {
  515. finalCategoryIds = [tagCategoryId];
  516. }
  517. // Build tags & tagsFlat: lowercased names
  518. const tagNames = tagEntities
  519. .map((t) => t.name?.trim())
  520. .filter(Boolean) as string[];
  521. tags = tagNames.map((name) => name.toLowerCase()); // NEW
  522. tagsFlat = tags.join(' '); // e.g. "funny hot 2025"
  523. }
  524. return {
  525. finalCategoryIds,
  526. finalTagIds,
  527. tags,
  528. tagsFlat,
  529. };
  530. }
  531. }