video-media.service.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. import {
  2. Injectable,
  3. NotFoundException,
  4. BadRequestException,
  5. } from '@nestjs/common';
  6. import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
  7. import { ImageUploadService } from '../image-upload/image-upload.service';
  8. import {
  9. VideoMediaListQueryDto,
  10. UpdateVideoMediaManageDto,
  11. UpdateVideoMediaStatusDto,
  12. BatchUpdateVideoMediaStatusDto,
  13. } from './video-media.dto';
  14. @Injectable()
  15. export class VideoMediaService {
  16. constructor(
  17. private readonly prisma: MongoPrismaService,
  18. private readonly imageUploadService: ImageUploadService,
  19. ) {}
  20. async findAll(query: VideoMediaListQueryDto): Promise<any> {
  21. const page = query.page ?? 1;
  22. const pageSize = query.size ?? 20;
  23. const skip = (page - 1) * pageSize;
  24. const take = pageSize;
  25. const where: any = {};
  26. if (query.keyword) {
  27. where.OR = [
  28. { title: { contains: query.keyword, mode: 'insensitive' } },
  29. { filename: { contains: query.keyword, mode: 'insensitive' } },
  30. ];
  31. }
  32. if (query.categoryId) {
  33. where.categoryId = query.categoryId;
  34. }
  35. if (query.tagId) {
  36. where.tagIds = { has: query.tagId };
  37. }
  38. if (typeof query.listStatus === 'number') {
  39. where.listStatus = query.listStatus;
  40. }
  41. // filter by editedFrom and editedTo
  42. if (
  43. typeof query.editedFrom === 'number' ||
  44. typeof query.editedTo === 'number'
  45. ) {
  46. where.editedAt = {};
  47. if (typeof query.editedFrom === 'number') {
  48. where.editedAt.gte = BigInt(query.editedFrom);
  49. }
  50. if (typeof query.editedTo === 'number') {
  51. where.editedAt.lte = BigInt(query.editedTo);
  52. }
  53. }
  54. const [total, rows] = await Promise.all([
  55. this.prisma.videoMedia.count({ where }),
  56. this.prisma.videoMedia.findMany({
  57. where,
  58. skip,
  59. take,
  60. orderBy: { addedTime: 'desc' },
  61. }),
  62. ]);
  63. return {
  64. total,
  65. page,
  66. pageSize,
  67. items: rows.map((row) => ({
  68. id: row.id,
  69. title: row.title,
  70. filename: row.filename,
  71. videoTime: row.videoTime,
  72. size: row.size?.toString?.() ?? '0',
  73. coverImg: row.coverImg ?? '',
  74. categoryId: row.categoryId ?? null,
  75. tagIds: row.tagIds ?? [],
  76. listStatus: row.listStatus ?? 0,
  77. editedAt: Number(row.editedAt ?? 0),
  78. updatedAt: row.updatedAt ?? null,
  79. tags: row.tags ?? [],
  80. tagsFlat: row.tagsFlat ?? '',
  81. secondTags: row.secondTags ?? [],
  82. // NOTE: We keep list DTO backward compatible.
  83. // If you later want to show tag names in list, we can add e.g. `tagsFlat` or `tagNames` here.
  84. })),
  85. };
  86. }
  87. async findOne(id: string): Promise<any> {
  88. const video = await this.prisma.videoMedia.findUnique({
  89. where: { id },
  90. });
  91. if (!video) {
  92. throw new NotFoundException('Video not found');
  93. }
  94. const [category, tags] = await Promise.all([
  95. video.categoryId
  96. ? this.prisma.category.findUnique({
  97. where: { id: video.categoryId },
  98. })
  99. : null,
  100. video.tagIds && video.tagIds.length
  101. ? this.prisma.tag.findMany({
  102. where: { id: { in: video.tagIds } },
  103. orderBy: { seq: 'asc' },
  104. })
  105. : [],
  106. ]);
  107. return {
  108. id: video.id,
  109. title: video.title,
  110. filename: video.filename,
  111. videoTime: video.videoTime,
  112. size: video.size?.toString?.() ?? '0',
  113. coverImg: video.coverImg ?? '',
  114. type: video.type,
  115. formatType: video.formatType,
  116. contentType: video.contentType,
  117. country: video.country,
  118. status: video.status,
  119. desc: video.desc ?? '',
  120. categoryId: video.categoryId ?? null,
  121. tagIds: video.tagIds ?? [],
  122. listStatus: video.listStatus ?? 0,
  123. editedAt: Number(video.editedAt ?? 0),
  124. updatedAt: video.updatedAt ?? null,
  125. categoryName: category?.name ?? null,
  126. // Existing DTO: tags as {id, name}[]
  127. tags: video.tags ?? [],
  128. tagsFlat: video.tagsFlat ?? '',
  129. secondTags: video.secondTags ?? [],
  130. };
  131. }
  132. async updateManage(id: string, dto: UpdateVideoMediaManageDto) {
  133. const video = await this.prisma.videoMedia.findUnique({
  134. where: { id },
  135. });
  136. if (!video) {
  137. throw new NotFoundException('Video not found');
  138. }
  139. const updateData: any = {};
  140. if (typeof dto.title === 'string') {
  141. updateData.title = dto.title.trim();
  142. }
  143. let categoryId: string | null | undefined = dto.categoryId;
  144. const tagIds: string[] | undefined = dto.tagIds;
  145. if (dto.categoryId === null) {
  146. categoryId = null;
  147. }
  148. if (typeof categoryId !== 'undefined' || typeof tagIds !== 'undefined') {
  149. const { finalCategoryId, finalTagIds, tags, tagsFlat } =
  150. await this.validateCategoryAndTags(categoryId, tagIds);
  151. updateData.categoryId = finalCategoryId;
  152. updateData.tagIds = finalTagIds;
  153. updateData.tags = tags; // NEW: store denormalised tag names (lowercased)
  154. updateData.tagsFlat = tagsFlat; // existing: text for search
  155. }
  156. if (typeof dto.listStatus === 'number') {
  157. if (dto.listStatus !== 0 && dto.listStatus !== 1) {
  158. throw new BadRequestException('Invalid listStatus value');
  159. }
  160. updateData.listStatus = dto.listStatus;
  161. }
  162. updateData.editedAt = BigInt(Date.now());
  163. updateData.updatedAt = new Date();
  164. await this.prisma.videoMedia.update({
  165. where: { id },
  166. data: updateData,
  167. });
  168. return this.findOne(id);
  169. }
  170. async updateStatus(id: string, dto: UpdateVideoMediaStatusDto) {
  171. const video = await this.prisma.videoMedia.findUnique({
  172. where: { id },
  173. });
  174. if (!video) {
  175. throw new NotFoundException('Video not found');
  176. }
  177. if (dto.listStatus !== 0 && dto.listStatus !== 1) {
  178. throw new BadRequestException('Invalid listStatus value');
  179. }
  180. const editedAt = BigInt(Date.now());
  181. const updatedAt = new Date();
  182. await this.prisma.videoMedia.update({
  183. where: { id },
  184. data: {
  185. listStatus: dto.listStatus,
  186. editedAt,
  187. updatedAt,
  188. },
  189. });
  190. return {
  191. id,
  192. listStatus: dto.listStatus,
  193. editedAt: editedAt.toString(),
  194. };
  195. }
  196. async batchUpdateStatus(dto: BatchUpdateVideoMediaStatusDto) {
  197. if (!dto.ids?.length) {
  198. throw new BadRequestException('ids cannot be empty');
  199. }
  200. if (dto.listStatus !== 0 && dto.listStatus !== 1) {
  201. throw new BadRequestException('Invalid listStatus value');
  202. }
  203. const editedAt = BigInt(Date.now());
  204. const updatedAt = new Date();
  205. const result = await this.prisma.videoMedia.updateMany({
  206. where: { id: { in: dto.ids } },
  207. data: {
  208. listStatus: dto.listStatus,
  209. editedAt,
  210. updatedAt,
  211. },
  212. });
  213. return {
  214. affected: result.count,
  215. listStatus: dto.listStatus,
  216. editedAt: editedAt.toString(),
  217. };
  218. }
  219. /**
  220. * Upload and update VideoMedia cover image.
  221. */
  222. async updateCover(id: string, file: Express.Multer.File) {
  223. // Ensure video exists
  224. const video = await this.prisma.videoMedia.findUnique({
  225. where: { id },
  226. });
  227. if (!video) {
  228. throw new NotFoundException('Video not found');
  229. }
  230. // Upload image
  231. const { key, imgSource } = await this.imageUploadService.uploadCoverImage(
  232. 'video-cover',
  233. file,
  234. );
  235. const editedAt = BigInt(Date.now());
  236. const updatedAt = new Date();
  237. // Update VideoMedia record
  238. const updated = await this.prisma.videoMedia.update({
  239. where: { id },
  240. data: {
  241. coverImg: key,
  242. imgSource,
  243. editedAt,
  244. updatedAt,
  245. },
  246. });
  247. return {
  248. id: updated.id,
  249. coverImg: updated.coverImg,
  250. imgSource: updated.imgSource,
  251. editedAt: editedAt.toString(),
  252. };
  253. }
  254. private async validateCategoryAndTags(
  255. categoryId: string | null | undefined,
  256. tagIds: string[] | undefined,
  257. ): Promise<{
  258. finalCategoryId: string | null;
  259. finalTagIds: string[];
  260. tags: string[]; // NEW: denormalised tag names (lowercased)
  261. tagsFlat: string; // NEW: concatenated names for search
  262. }> {
  263. let finalCategoryId: string | null =
  264. typeof categoryId === 'undefined' ? (undefined as any) : categoryId;
  265. let finalTagIds: string[] = [];
  266. let tags: string[] = []; // NEW
  267. let tagsFlat = '';
  268. // Normalize tagIds: remove duplicates
  269. if (Array.isArray(tagIds)) {
  270. const unique = [...new Set(tagIds)];
  271. if (unique.length > 5) {
  272. throw new BadRequestException('Tag count cannot exceed 5');
  273. }
  274. finalTagIds = unique;
  275. }
  276. // If tags are provided but categoryId is null/undefined -> error
  277. if (finalTagIds.length > 0 && !finalCategoryId) {
  278. throw new BadRequestException(
  279. 'Category is required when tags are provided.',
  280. );
  281. }
  282. // Validate category if present
  283. if (typeof finalCategoryId !== 'undefined' && finalCategoryId !== null) {
  284. const category = await this.prisma.category.findUnique({
  285. where: { id: finalCategoryId },
  286. });
  287. if (!category) {
  288. throw new BadRequestException('Category not found');
  289. }
  290. if (category.status !== 1) {
  291. throw new BadRequestException('Category is disabled');
  292. }
  293. }
  294. if (finalTagIds.length > 0) {
  295. const tagEntities = await this.prisma.tag.findMany({
  296. where: { id: { in: finalTagIds } },
  297. });
  298. if (tagEntities.length !== finalTagIds.length) {
  299. throw new BadRequestException('Some tags do not exist');
  300. }
  301. const distinctCategoryIds = [
  302. ...new Set(tagEntities.map((t) => t.categoryId.toString())),
  303. ];
  304. if (distinctCategoryIds.length > 1) {
  305. throw new BadRequestException(
  306. 'All tags must belong to the same category',
  307. );
  308. }
  309. const tagCategoryId = distinctCategoryIds[0];
  310. if (finalCategoryId && tagCategoryId !== finalCategoryId) {
  311. throw new BadRequestException(
  312. 'Tags do not belong to the specified category',
  313. );
  314. }
  315. // If categoryId was not provided but tags exist, infer from tags
  316. if (!finalCategoryId) {
  317. finalCategoryId = tagCategoryId;
  318. }
  319. // Build tags & tagsFlat: lowercased names
  320. const tagNames = tagEntities
  321. .map((t) => t.name?.trim())
  322. .filter(Boolean) as string[];
  323. tags = tagNames.map((name) => name.toLowerCase()); // NEW
  324. tagsFlat = tags.join(' '); // e.g. "funny hot 2025"
  325. }
  326. if (typeof finalCategoryId === 'undefined') {
  327. finalCategoryId = null;
  328. }
  329. return {
  330. finalCategoryId,
  331. finalTagIds,
  332. tags,
  333. tagsFlat,
  334. };
  335. }
  336. }