|
|
@@ -8,6 +8,8 @@ import {
|
|
|
import type { MultipartFile } from '@fastify/multipart';
|
|
|
import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
|
|
|
import ExcelJS from 'exceljs';
|
|
|
+import * as XLSX from 'xlsx';
|
|
|
+import type { Express } from 'express';
|
|
|
import { CacheSyncService } from '../../../cache-sync/cache-sync.service';
|
|
|
import { CacheEntityType } from '../../../cache-sync/cache-sync.types';
|
|
|
import { MediaManagerService } from '@box/core/media-manager/media-manager.service';
|
|
|
@@ -59,6 +61,47 @@ export class VideoMediaService {
|
|
|
return String(id);
|
|
|
}
|
|
|
|
|
|
+ private parseCommaTags(raw: string): string[] {
|
|
|
+ if (typeof raw !== 'string') return [];
|
|
|
+ const trimmed = raw.trim();
|
|
|
+ if (!trimmed) return [];
|
|
|
+
|
|
|
+ const seen = new Set<string>();
|
|
|
+ const tags: string[] = [];
|
|
|
+
|
|
|
+ for (const part of trimmed.split(',')) {
|
|
|
+ const name = part.trim();
|
|
|
+ if (!name || seen.has(name)) continue;
|
|
|
+ seen.add(name);
|
|
|
+ tags.push(name);
|
|
|
+ }
|
|
|
+
|
|
|
+ return tags;
|
|
|
+ }
|
|
|
+
|
|
|
+ private mergeAppendUnique(existing: string[], incoming: string[]): string[] {
|
|
|
+ const result: string[] = [];
|
|
|
+ const seen = new Set<string>();
|
|
|
+
|
|
|
+ for (const item of existing ?? []) {
|
|
|
+ if (typeof item !== 'string' || seen.has(item)) continue;
|
|
|
+ seen.add(item);
|
|
|
+ result.push(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const item of incoming ?? []) {
|
|
|
+ if (typeof item !== 'string' || seen.has(item)) continue;
|
|
|
+ seen.add(item);
|
|
|
+ result.push(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private isObjectIdString(value: string): boolean {
|
|
|
+ return typeof value === 'string' && /^[a-fA-F0-9]{24}$/.test(value);
|
|
|
+ }
|
|
|
+
|
|
|
// helper to generate next vid
|
|
|
private async generateNextVid(): Promise<number> {
|
|
|
const last = await this.prisma.videoMedia.findFirst({
|
|
|
@@ -259,6 +302,159 @@ export class VideoMediaService {
|
|
|
return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
|
}
|
|
|
|
|
|
+ async importExcelTags(file: Express.Multer.File) {
|
|
|
+ if (!file?.buffer) {
|
|
|
+ throw new BadRequestException('No file uploaded');
|
|
|
+ }
|
|
|
+
|
|
|
+ const workbook = XLSX.read(file.buffer, { type: 'buffer' });
|
|
|
+ const sheetName = workbook.SheetNames[0];
|
|
|
+ const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
|
|
|
+
|
|
|
+ if (!sheet) {
|
|
|
+ throw new BadRequestException('No sheet found in Excel file');
|
|
|
+ }
|
|
|
+
|
|
|
+ const rows = XLSX.utils.sheet_to_json(sheet, {
|
|
|
+ header: 1,
|
|
|
+ defval: '',
|
|
|
+ }) as unknown as any[][];
|
|
|
+
|
|
|
+ const errors: Array<{ rowNumber: number; idRaw: unknown; reason: string }> =
|
|
|
+ [];
|
|
|
+ const candidates: Array<{
|
|
|
+ rowNumber: number;
|
|
|
+ idRaw: unknown;
|
|
|
+ id: string;
|
|
|
+ tags: string[];
|
|
|
+ }> = [];
|
|
|
+
|
|
|
+ const uniqueTagNames = new Set<string>();
|
|
|
+ let candidateRows = 0;
|
|
|
+ let skippedInvalidId = 0;
|
|
|
+ let skippedEmptyTags = 0;
|
|
|
+
|
|
|
+ for (let i = 1; i < rows.length; i += 1) {
|
|
|
+ const row = rows[i] ?? [];
|
|
|
+ const rowNumber = i + 1;
|
|
|
+ const idRaw = row[0];
|
|
|
+ const tagsCell = row[7];
|
|
|
+ const tagsRaw =
|
|
|
+ typeof tagsCell === 'string'
|
|
|
+ ? tagsCell
|
|
|
+ : tagsCell == null
|
|
|
+ ? ''
|
|
|
+ : String(tagsCell);
|
|
|
+
|
|
|
+ const tags = this.parseCommaTags(tagsRaw);
|
|
|
+
|
|
|
+ if (tags.length === 0) {
|
|
|
+ skippedEmptyTags += 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const id = this.normalizeMongoIdToString(idRaw).trim();
|
|
|
+ if (!this.isObjectIdString(id)) {
|
|
|
+ skippedInvalidId += 1;
|
|
|
+ errors.push({ rowNumber, idRaw, reason: 'invalid id' });
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ candidateRows += 1;
|
|
|
+
|
|
|
+ for (const tag of tags) {
|
|
|
+ uniqueTagNames.add(tag);
|
|
|
+ }
|
|
|
+
|
|
|
+ candidates.push({
|
|
|
+ rowNumber,
|
|
|
+ idRaw,
|
|
|
+ id,
|
|
|
+ tags,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const tagDocs =
|
|
|
+ uniqueTagNames.size > 0
|
|
|
+ ? await this.prisma.tag.findMany({
|
|
|
+ where: { name: { in: Array.from(uniqueTagNames) } },
|
|
|
+ select: { id: true, name: true },
|
|
|
+ })
|
|
|
+ : [];
|
|
|
+
|
|
|
+ const tagMap = new Map(tagDocs.map((tag) => [tag.name, tag]));
|
|
|
+
|
|
|
+ let updated = 0;
|
|
|
+
|
|
|
+ for (const row of candidates) {
|
|
|
+ try {
|
|
|
+ const existing = await this.prisma.videoMedia.findUnique({
|
|
|
+ where: { id: row.id },
|
|
|
+ select: { secondTags: true },
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!existing) {
|
|
|
+ errors.push({
|
|
|
+ rowNumber: row.rowNumber,
|
|
|
+ idRaw: row.idRaw,
|
|
|
+ reason: 'video not found',
|
|
|
+ });
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const mergedSecondTags = this.mergeAppendUnique(
|
|
|
+ existing.secondTags ?? [],
|
|
|
+ row.tags,
|
|
|
+ );
|
|
|
+
|
|
|
+ const vTags = row.tags
|
|
|
+ .map((name) => tagMap.get(name))
|
|
|
+ .filter(Boolean)
|
|
|
+ .map((tag) => ({
|
|
|
+ tagId: tag!.id,
|
|
|
+ tagName: tag!.name,
|
|
|
+ }));
|
|
|
+
|
|
|
+ const editedAt = BigInt(Math.floor(Date.now() / 1000));
|
|
|
+ const updatedAt = new Date();
|
|
|
+ const data: Record<string, any> = {
|
|
|
+ tags: { set: row.tags },
|
|
|
+ secondTags: { set: mergedSecondTags },
|
|
|
+ editedAt,
|
|
|
+ updatedAt,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (vTags.length > 0) {
|
|
|
+ data.vTags = { set: vTags };
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.prisma.videoMedia.update({
|
|
|
+ where: { id: row.id },
|
|
|
+ data,
|
|
|
+ });
|
|
|
+
|
|
|
+ updated += 1;
|
|
|
+ } catch (error: any) {
|
|
|
+ errors.push({
|
|
|
+ rowNumber: row.rowNumber,
|
|
|
+ idRaw: row.idRaw,
|
|
|
+ reason: error?.message ?? String(error),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ totalRows: rows.length,
|
|
|
+ candidateRows,
|
|
|
+ updated,
|
|
|
+ skippedInvalidId,
|
|
|
+ skippedEmptyTags,
|
|
|
+ tagLookupTotalUnique: uniqueTagNames.size,
|
|
|
+ tagLookupFound: tagDocs.length,
|
|
|
+ errors,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
private buildVideoListBaseFilter(
|
|
|
query: VideoMediaListQueryDto,
|
|
|
): Record<string, any> {
|