video-media.service.ts 20 KB

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