import { NextResponse } from "next/server" import { getServerSession } from "next-auth/next" import { authOptions } from "@/lib/auth" import { prisma } from "@/lib/prisma" import connectToMongoDB from "@/lib/mongoose" import { Chapter } from "@/lib/models/chapter" import path from "path" import os from "os" import { promises as fs } from "fs" import { convert } from "html-to-text" import { slugify } from "@/lib/utils" import { deleteR2ObjectByUrl, uploadBufferToR2 } from "@/lib/r2" export const maxDuration = 900 type SplitMode = "toc" | "regex" type SeriesMode = "none" | "existing" | "new" type UserRole = "USER" | "MOD" | "ADMIN" interface EpubSection { sourceTitle: string content: string } interface ParsedChapter { title: string content: string detectedChapterNumber: number | null finalNumber?: number volumeNumber: number | null volumeTitle: string | null volumeChapterNumber: number | null isPlaceholder?: boolean } interface EpubCoverAsset { buffer: Buffer | null mimeType: string | null sourceId: string | null } type EpubCtor = new (epubfile: string, imagewebroot: string, chapterwebroot: string) => any function sanitizeEpubNavMapBranch(branch: any): any { if (Array.isArray(branch)) { branch.forEach((item) => sanitizeEpubNavMapBranch(item)) return branch } if (!branch || typeof branch !== "object") { return branch } if ("navLabel" in branch) { const navLabel = (branch as any).navLabel if (typeof navLabel === "string") { ;(branch as any).navLabel = navLabel } else if (navLabel && typeof navLabel === "object") { if (typeof navLabel.text === "string") { ;(branch as any).navLabel = navLabel.text } else if (navLabel.text !== undefined && navLabel.text !== null) { ;(branch as any).navLabel = String(navLabel.text) } else { ;(branch as any).navLabel = "" } } else if (navLabel === null || navLabel === undefined) { ;(branch as any).navLabel = "" } else { ;(branch as any).navLabel = String(navLabel) } } if ((branch as any).navPoint) { sanitizeEpubNavMapBranch((branch as any).navPoint) } return branch } function getPatchedEpubCtor(): EpubCtor { const loaded = require("epub2") const EPub = (loaded?.EPub || loaded) as EpubCtor const proto = (EPub as any)?.prototype if (proto && !proto.__readerSafeNavLabelPatchApplied && typeof proto.walkNavMap === "function") { const originalWalkNavMap = proto.walkNavMap proto.walkNavMap = function patchedWalkNavMap(branch: any, ...args: any[]) { const safeBranch = sanitizeEpubNavMapBranch(branch) return originalWalkNavMap.call(this, safeBranch, ...args) } proto.__readerSafeNavLabelPatchApplied = true } return EPub } function isRequestAbortedError(error: unknown): boolean { if (!error) return false const candidate = error as { code?: unknown; message?: unknown; name?: unknown } const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : "" const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "" const name = typeof candidate.name === "string" ? candidate.name.toLowerCase() : "" return ( code === "ECONNRESET" || code === "ABORT_ERR" || message.includes("aborted") || message.includes("connection reset") || name.includes("abort") ) } const CHAPTER_REGEX_PRESETS: Record = { vi_chuong: "^(?:Chương|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$", en_chapter: "^(?:Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$", mix_chapter: "^(?:Chương|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$", bracket_chapter: "^\\[?\\s*(?:Chương|Chapter)\\s*\\d+(?:\\.\\d+)?\\s*\\]?[^\\n]*$", } const NOISE_TITLE_REGEX = /^(?:mục lục|table of contents|toc|cover|bìa|copyright)$/i const SIMPLE_CHAPTER_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*:?$/i const GENERIC_SECTION_TITLE_REGEX = /^(?:m[uụ]c|section|sec\.?|part|ph[aầ]n)\s*[0-9ivxlcdm]+$/i const WEAK_CHAPTER_TOC_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*[:\-–—]\s*(?:m[uụ]c|section|sec\.?|part)\s*[0-9ivxlcdm]+$/i const CHAPTER_HEADING_LINE_REGEX = /^(?:\[?\s*)?(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+)(?:\.[0-9]+)?(?:\s*\]?)*(?:(?:\s*[:\-–—\.]\s*|\s+)(.+))?$/i const GENERIC_GENRE_TOKENS = new Set([ "book", "books", "ebook", "fiction", "literature", "novel", "story", "truyen", "tiểu thuyết", ]) function isWeakTOCTitle(title: string): boolean { const normalized = title.trim() return ( SIMPLE_CHAPTER_TITLE_REGEX.test(normalized) || GENERIC_SECTION_TITLE_REGEX.test(normalized) || WEAK_CHAPTER_TOC_TITLE_REGEX.test(normalized) ) } function pickChapterHeadingFromContent(content: string): { heading: string chapterNumber: number | null consumedLineIndexes: number[] } | null { const lines = content.split(/\r?\n/) const nonEmptyIndexes = lines .map((line, index) => ({ line: line.trim(), index })) .filter((item) => item.line.length > 0) for (let i = 0; i < nonEmptyIndexes.length && i < 12; i++) { const current = nonEmptyIndexes[i] const headingLine = current.line.replace(/\s+/g, " ").trim() if (!headingLine || headingLine.length > 180) continue if (NOISE_TITLE_REGEX.test(headingLine) || isVolumeHeading(headingLine)) continue const matched = headingLine.match(CHAPTER_HEADING_LINE_REGEX) if (!matched) continue const parsed = Number(matched[1]) const chapterNumber = Number.isInteger(parsed) && parsed > 0 && parsed <= 50000 ? parsed : null const consumedLineIndexes = [current.index] let heading = headingLine const trailingTitle = (matched[2] || "").trim() if (!trailingTitle) { const next = nonEmptyIndexes[i + 1] if (next) { const subtitle = next.line.replace(/\s+/g, " ").trim() if ( subtitle.length > 0 && subtitle.length <= 120 && !NOISE_TITLE_REGEX.test(subtitle) && !isVolumeHeading(subtitle) && !CHAPTER_HEADING_LINE_REGEX.test(subtitle) ) { heading = `${headingLine.replace(/[:\-–—\.\s]+$/g, "")}: ${subtitle}` consumedLineIndexes.push(next.index) } } } return { heading, chapterNumber, consumedLineIndexes } } return null } function normalizeMetaText(value: any, fallback: string) { if (typeof value === "string" && value.trim().length > 0) return value.trim() if (Array.isArray(value)) { const first = value.find((v) => typeof v === "string" && v.trim().length > 0) if (first) return first.trim() } return fallback } function canReplaceNovelByRole(userRole: UserRole, userId: string, novel: { uploaderId: string | null }): boolean { if (userRole === "ADMIN") return true return novel.uploaderId === userId || novel.uploaderId === null } function collectTextValues(input: any, bucket: string[]) { if (input === null || input === undefined) return if (typeof input === "string") { bucket.push(input) return } if (typeof input === "number" || typeof input === "boolean") { bucket.push(String(input)) return } if (Array.isArray(input)) { input.forEach((item) => collectTextValues(item, bucket)) return } if (typeof input === "object") { Object.values(input).forEach((value) => collectTextValues(value, bucket)) } } function normalizeGenreCandidate(name: string): string { return name .replace(/\s+/g, " ") .replace(/^[\s\-–—:;,.\/|]+|[\s\-–—:;,.\/|]+$/g, "") .trim() } function extractGenreCandidatesFromMetadata(metadata: any): string[] { if (!metadata || typeof metadata !== "object") return [] const rawValues: string[] = [] const keys = Object.keys(metadata) const candidateKeys = keys.filter((key) => /subject|genre|tag|category/i.test(key)) for (const key of candidateKeys) { collectTextValues((metadata as Record)[key], rawValues) } const uniqueNames: string[] = [] const seen = new Set() for (const raw of rawValues) { const chunks = raw .split(/[,;|/\n]+/) .map((chunk) => normalizeGenreCandidate(chunk)) .filter(Boolean) for (const name of chunks) { const normalized = name.toLowerCase() if (name.length < 2 || name.length > 80) continue if (GENERIC_GENRE_TOKENS.has(normalized)) continue if (seen.has(normalized)) continue seen.add(normalized) uniqueNames.push(name) if (uniqueNames.length >= 12) { return uniqueNames } } } return uniqueNames } async function resolveGenreIdsFromNames(genreNames: string[], createIfMissing: boolean): Promise { const ids: string[] = [] for (const genreName of genreNames) { const existing = await prisma.genre.findFirst({ where: { name: { equals: genreName, mode: "insensitive" } }, select: { id: true }, }) if (existing) { ids.push(existing.id) continue } if (!createIfMissing) continue const baseSlug = slugify(genreName) || `genre-${Date.now()}` let slug = baseSlug let counter = 1 while (await prisma.genre.findUnique({ where: { slug } })) { slug = `${baseSlug}-${counter}` counter += 1 } try { const created = await prisma.genre.create({ data: { name: genreName, slug, }, select: { id: true }, }) ids.push(created.id) } catch (error: any) { if (error?.code === "P2002") { const fallback = await prisma.genre.findFirst({ where: { name: { equals: genreName, mode: "insensitive" } }, select: { id: true }, }) if (fallback) { ids.push(fallback.id) continue } } throw error } } return Array.from(new Set(ids)) } async function findNovelByTitleInsensitive(title: string) { return prisma.novel.findFirst({ where: { title: { equals: title, mode: "insensitive" } }, orderBy: { updatedAt: "desc" }, select: { id: true, title: true, slug: true, coverUrl: true, uploaderId: true, }, }) } function extractVolumeNumber(title: string): number | null { const matched = title.match(/(?:quy[eê]n|vol(?:ume)?|t[aạ]p|book|arc|hồi)\s*([0-9]+)/i) if (!matched) return null const parsed = Number(matched[1]) return Number.isFinite(parsed) ? parsed : null } function extractChapterNumber(title: string): number | null { const matched = title.match(/(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+(?:\.[0-9]+)?)/i) if (!matched) return null const parsed = Number(matched[1]) return Number.isFinite(parsed) ? parsed : null } function extractStrictChapterNumber(title: string): number | null { const number = extractChapterNumber(title) if (number === null) return null if (!Number.isInteger(number)) return null if (number <= 0) return null if (number > 50000) return null return number } function enhanceChapterTitleFromContent(title: string, content: string): { title: string; content: string; detectedChapterNumber: number | null } { const lines = content.split(/\r?\n/) const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim().length > 0) const baseTitle = title.trim() const baseDetectedChapterNumber = extractStrictChapterNumber(baseTitle) if (firstNonEmptyLineIndex < 0) { return { title, content, detectedChapterNumber: baseDetectedChapterNumber, } } const firstLineRaw = lines[firstNonEmptyLineIndex] const firstLine = firstLineRaw.trim() if (!firstLine) { return { title, content, detectedChapterNumber: baseDetectedChapterNumber, } } const detectedHeading = pickChapterHeadingFromContent(content) if (detectedHeading) { const shouldUseDetectedHeading = isWeakTOCTitle(baseTitle) || baseDetectedChapterNumber === null || detectedHeading.chapterNumber === baseDetectedChapterNumber if (shouldUseDetectedHeading) { const nextLines = [...lines] detectedHeading.consumedLineIndexes .sort((a, b) => b - a) .forEach((index) => { nextLines.splice(index, 1) }) const nextContent = nextLines.join("\n").trim() return { title: detectedHeading.heading, content: nextContent.length > 0 ? nextContent : content, detectedChapterNumber: detectedHeading.chapterNumber, } } } if (firstLine.length > 140) { return { title, content, detectedChapterNumber: baseDetectedChapterNumber, } } const isSimpleBaseTitle = SIMPLE_CHAPTER_TITLE_REGEX.test(baseTitle) if (!isSimpleBaseTitle) { return { title, content, detectedChapterNumber: baseDetectedChapterNumber, } } let nextTitle = baseTitle // Case 1: The first line already contains full chapter heading, use it directly. if (/^(?:ch(?:ương|apter)?|ch\.)\s*\d+/i.test(firstLine) && firstLine.length > baseTitle.length + 2) { nextTitle = firstLine } else if (!SIMPLE_CHAPTER_TITLE_REGEX.test(firstLine) && !isVolumeHeading(firstLine) && !NOISE_TITLE_REGEX.test(firstLine)) { // Case 2: TOC title is only "Chương N", subtitle is on next line. nextTitle = `${baseTitle.replace(/[:\s]+$/g, "")}: ${firstLine}` } else { return { title, content, detectedChapterNumber: baseDetectedChapterNumber, } } const newLines = [...lines] newLines.splice(firstNonEmptyLineIndex, 1) const nextContent = newLines.join("\n").trim() return { title: nextTitle, content: nextContent.length > 0 ? nextContent : content, detectedChapterNumber: extractStrictChapterNumber(nextTitle) ?? baseDetectedChapterNumber, } } function isVolumeHeading(title: string): boolean { return /^(?:quy[eê]n|vol(?:ume)?|t[aạ]p|book|arc|hồi)\s*[0-9]+(?:\s*[:-].*)?$/i.test(title.trim()) } function normalizeSplitMode(value: FormDataEntryValue | null): SplitMode { return value === "regex" ? "regex" : "toc" } function normalizeSeriesMode(value: FormDataEntryValue | null): SeriesMode { if (value === "existing") return "existing" if (value === "new") return "new" return "none" } function readFormText(formData: FormData, key: string): string { const value = formData.get(key) return typeof value === "string" ? value.trim() : "" } async function resolveSeriesIdForEpubImport(options: { mode: SeriesMode seriesId: string seriesName: string userRole: "USER" | "MOD" | "ADMIN" userId: string }) { if (options.mode === "none") return null if (options.mode === "existing") { if (!options.seriesId) { throw new Error("Thiếu series để thêm vào") } const targetSeries = await prisma.series.findFirst({ where: options.userRole === "ADMIN" ? { id: options.seriesId } : { id: options.seriesId, OR: [ { novels: { some: { uploaderId: options.userId } } }, { novels: { some: { uploaderId: null } } }, { novels: { none: {} } }, ], }, select: { id: true }, }) if (!targetSeries) { throw new Error("Series không tồn tại hoặc không đủ quyền") } return targetSeries.id } if (!options.seriesName) { throw new Error("Thiếu tên series mới") } const existed = await prisma.series.findFirst({ where: { name: { equals: options.seriesName, mode: "insensitive" } }, select: { id: true }, }) if (existed) return existed.id const baseSlug = slugify(options.seriesName) let slug = baseSlug let counter = 1 while (await prisma.series.findUnique({ where: { slug } })) { slug = `${baseSlug}-${counter}` counter += 1 } const created = await prisma.series.create({ data: { name: options.seriesName, slug, }, select: { id: true }, }) return created.id } function resolveRegexPattern(formData: FormData): { regexInput: string; regexPreset: string | null } { const preset = readFormText(formData, "chapterRegexPreset") const custom = readFormText(formData, "chapterRegex") if (custom) { return { regexInput: custom, regexPreset: preset || "custom" } } if (preset && CHAPTER_REGEX_PRESETS[preset]) { return { regexInput: CHAPTER_REGEX_PRESETS[preset], regexPreset: preset } } return { regexInput: CHAPTER_REGEX_PRESETS.vi_chuong, regexPreset: "vi_chuong" } } function buildRegexFromInput(regexInput: string): { regex: RegExp; normalized: string } { if (!regexInput || regexInput.length > 300) { throw new Error("Regex không hợp lệ") } let pattern = regexInput let flags = "" const slashWrapped = regexInput.match(/^\/(.+)\/([gimsuy]*)$/) if (slashWrapped) { pattern = slashWrapped[1] flags = slashWrapped[2] } const flagSet = new Set(flags.split("")) flagSet.add("g") flagSet.add("m") const normalizedFlags = Array.from(flagSet).join("") const regex = new RegExp(pattern, normalizedFlags) return { regex, normalized: `/${pattern}/${normalizedFlags}` } } function enrichVolumeMetadata(chapters: Array<{ title: string; content: string }>): ParsedChapter[] { let currentVolumeNumber: number | null = null let currentVolumeTitle: string | null = null let volumeChapterCounter = 0 return chapters.map((chapter) => { const title = chapter.title.trim() const explicitVolumeNumber = extractVolumeNumber(title) if (explicitVolumeNumber !== null) { if (currentVolumeNumber !== explicitVolumeNumber) { volumeChapterCounter = 0 } currentVolumeNumber = explicitVolumeNumber currentVolumeTitle = isVolumeHeading(title) ? title : (currentVolumeTitle || `Quyển ${explicitVolumeNumber}`) } const explicitChapterNumber = extractChapterNumber(title) let volumeChapterNumber: number | null = null if (currentVolumeNumber !== null) { if (explicitChapterNumber !== null) { volumeChapterCounter = explicitChapterNumber } else { volumeChapterCounter += 1 } volumeChapterNumber = volumeChapterCounter } return { title, content: chapter.content, detectedChapterNumber: extractStrictChapterNumber(title), volumeNumber: currentVolumeNumber, volumeTitle: currentVolumeTitle, volumeChapterNumber, } }) } function buildChaptersFromTOCSections(sections: EpubSection[]): ParsedChapter[] { const chapters: ParsedChapter[] = [] let currentVolumeNumber: number | null = null let currentVolumeTitle: string | null = null let currentVolumeChapterCounter = 0 let fallbackVolumeCounter = 0 for (let i = 0; i < sections.length; i++) { const section = sections[i] const rawTitle = section.sourceTitle || `Chương ${i + 1}` const cleanTitle = rawTitle.replace(/\s+/g, " ").trim() const cleanContent = section.content.trim() if (!cleanContent) continue if (isVolumeHeading(cleanTitle)) { const extracted = extractVolumeNumber(cleanTitle) if (extracted !== null) { currentVolumeNumber = extracted } else { fallbackVolumeCounter += 1 currentVolumeNumber = fallbackVolumeCounter } currentVolumeTitle = cleanTitle currentVolumeChapterCounter = 0 if (cleanContent.length <= 240) { continue } } if (NOISE_TITLE_REGEX.test(cleanTitle) && cleanContent.length <= 240) { continue } const explicitVolumeFromTitle = extractVolumeNumber(cleanTitle) if (explicitVolumeFromTitle !== null) { if (currentVolumeNumber !== explicitVolumeFromTitle) { currentVolumeChapterCounter = 0 } currentVolumeNumber = explicitVolumeFromTitle if (!currentVolumeTitle || isVolumeHeading(cleanTitle)) { currentVolumeTitle = `Quyển ${explicitVolumeFromTitle}` } } const enhanced = enhanceChapterTitleFromContent(cleanTitle, cleanContent) let volumeChapterNumber: number | null = null const detectedChapterNumber = enhanced.detectedChapterNumber if (currentVolumeNumber !== null) { const explicitChapter = extractChapterNumber(enhanced.title) if (explicitChapter !== null) { currentVolumeChapterCounter = explicitChapter } else { currentVolumeChapterCounter += 1 } volumeChapterNumber = currentVolumeChapterCounter } chapters.push({ title: enhanced.title, content: enhanced.content, detectedChapterNumber, volumeNumber: currentVolumeNumber, volumeTitle: currentVolumeTitle, volumeChapterNumber, }) } return chapters } function buildChaptersFromRegexSections(sections: EpubSection[], regex: RegExp): ParsedChapter[] { const combinedText = sections .map((section) => section.content.trim()) .filter(Boolean) .join("\n\n") const matches = Array.from(combinedText.matchAll(regex)) if (matches.length === 0) { return [] } const parsed: Array<{ title: string; content: string }> = [] for (let i = 0; i < matches.length; i++) { const match = matches[i] if (match.index === undefined) continue const nextMatch = matches[i + 1] const headingRaw = (match[1] || match[0] || "").replace(/\s+/g, " ").trim() const sectionStart = match.index + match[0].length const sectionEnd = nextMatch?.index ?? combinedText.length const body = combinedText.slice(sectionStart, sectionEnd).trim() if (!headingRaw || body.length === 0) { continue } const enhanced = enhanceChapterTitleFromContent(headingRaw, body) parsed.push({ title: enhanced.title, content: enhanced.content, }) } return enrichVolumeMetadata(parsed) } function withMissingChapterPlaceholders(chapters: ParsedChapter[]): { chapters: ParsedChapter[] insertedCount: number detectedMax: number detectedNumberAssignments: number } { const detectedNumbers = chapters .map((chapter) => chapter.detectedChapterNumber) .filter((n): n is number => typeof n === "number" && Number.isInteger(n) && n > 0) let insertedCount = 0 let detectedNumberAssignments = 0 let currentNumber = 0 const maxDetected = detectedNumbers.length > 0 ? Math.max(...detectedNumbers) : chapters.length const normalized: ParsedChapter[] = [] const MAX_ALLOWED_GAP = 40 for (const chapter of chapters) { const detected = chapter.detectedChapterNumber const detectedNumber = typeof detected === "number" ? detected : null let canUseDetected = detectedNumber !== null && detectedNumber > currentNumber && detectedNumber - currentNumber <= MAX_ALLOWED_GAP // Recover from noisy leading TOC entries such as "Mục 1" that shift numbering. if (!canUseDetected && detectedNumber !== null && detectedNumber > 0 && detectedNumber <= currentNumber) { while ( normalized.length > 0 && detectedNumber <= currentNumber && normalized[normalized.length - 1].detectedChapterNumber === null && !normalized[normalized.length - 1].isPlaceholder && isWeakTOCTitle(normalized[normalized.length - 1].title) ) { normalized.pop() currentNumber = Math.max(0, currentNumber - 1) } canUseDetected = detectedNumber > currentNumber && detectedNumber - currentNumber <= MAX_ALLOWED_GAP } if (canUseDetected && detectedNumber !== null) { for (let missing = currentNumber + 1; missing < detectedNumber; missing++) { insertedCount += 1 normalized.push({ title: `Chương ${missing} (Thiếu)`, content: `[THIEU CHUONG ${missing}]\n\nNoi dung chuong nay dang thieu tu EPUB goc. Vui long bo sung sau.`, detectedChapterNumber: missing, finalNumber: missing, volumeNumber: null, volumeTitle: null, volumeChapterNumber: null, isPlaceholder: true, }) } detectedNumberAssignments += 1 currentNumber = detectedNumber normalized.push({ ...chapter, finalNumber: currentNumber, volumeChapterNumber: chapter.volumeChapterNumber, }) continue } currentNumber += 1 normalized.push({ ...chapter, finalNumber: currentNumber, volumeChapterNumber: chapter.volumeChapterNumber, }) } return { chapters: normalized, insertedCount, detectedMax: maxDetected, detectedNumberAssignments, } } async function extractCoverFromEpub(epub: any): Promise { const manifest = epub.manifest || {} const metadataCover = epub.metadata?.cover ? String(epub.metadata.cover) : null const candidateIds: string[] = [] if (metadataCover) candidateIds.push(metadataCover) for (const [key, value] of Object.entries(manifest)) { const item = value as any const id = String(item?.id || key) const href = String(item?.href || "") const mediaType = String(item?.mediaType || item?.["media-type"] || "") const properties = String(item?.properties || "") if ( /cover-image/i.test(properties) || /cover/i.test(id) || /cover/i.test(href) ) { candidateIds.push(id) continue } if (/image\//i.test(mediaType) && /cover/i.test(href)) { candidateIds.push(id) } } const uniqueCandidateIds = Array.from(new Set(candidateIds.filter(Boolean))) if (uniqueCandidateIds.length === 0) { return { buffer: null, mimeType: null, sourceId: null } } for (const id of uniqueCandidateIds) { const fromImage = await new Promise((resolve) => { if (typeof epub.getImage !== "function") { resolve({ buffer: null, mimeType: null, sourceId: null }) return } epub.getImage(id, (err: any, data: any, mimeType?: string) => { if (err || !data) { resolve({ buffer: null, mimeType: null, sourceId: null }) return } const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) resolve({ buffer, mimeType: typeof mimeType === "string" ? mimeType : null, sourceId: id }) }) }) if (fromImage.buffer) { return fromImage } const fromFile = await new Promise((resolve) => { if (typeof epub.getFile !== "function") { resolve({ buffer: null, mimeType: null, sourceId: null }) return } epub.getFile(id, (err: any, data: any, mimeType?: string) => { if (err || !data) { resolve({ buffer: null, mimeType: null, sourceId: null }) return } const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) resolve({ buffer, mimeType: typeof mimeType === "string" ? mimeType : null, sourceId: id }) }) }) if (fromFile.buffer) { return fromFile } } return { buffer: null, mimeType: null, sourceId: null } } async function saveCoverBufferToR2(cover: EpubCoverAsset): Promise { if (!cover.buffer) return null return uploadBufferToR2({ buffer: cover.buffer, contentType: cover.mimeType, keyPrefix: "covers/epub", fileNameHint: cover.sourceId || undefined, }) } async function parseEpubSections(tempFilePath: string): Promise<{ metadata: any; sections: EpubSection[]; cover: EpubCoverAsset }> { return new Promise((resolve, reject) => { const EPub = getPatchedEpubCtor() const epub = new EPub(tempFilePath, "", "") epub.on("error", (err: any) => reject(err)) epub.on("end", async () => { try { const metadata = epub.metadata const flow = epub.flow const sections: EpubSection[] = [] const cover = await extractCoverFromEpub(epub) for (let i = 0; i < flow.length; i++) { const item = flow[i] const text = await new Promise((res) => { epub.getChapter(item.id, (err: any, data: string) => { if (err) res("") else res(data) }) }) if (!text || text.trim().length === 0) continue const plainText = convert(text, { wordwrap: false }).trim() if (!plainText) continue sections.push({ sourceTitle: item.title || `Mục ${i + 1}`, content: plainText, }) } resolve({ metadata, sections, cover }) } catch (err) { reject(err) } }) epub.parse() }) } export async function POST(req: Request) { const session = await getServerSession(authOptions) if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } try { const formData = await req.formData() const epubFile = formData.get("file") as File const previewOnly = String(formData.get("preview") || "").toLowerCase() === "true" const splitMode = normalizeSplitMode(formData.get("splitMode")) const seriesMode = normalizeSeriesMode(formData.get("seriesMode")) const seriesIdInput = readFormText(formData, "seriesId") const seriesNameInput = readFormText(formData, "seriesName") const replaceExisting = String(formData.get("replaceExisting") || "").toLowerCase() === "true" if (!epubFile) { return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 }) } const buffer = Buffer.from(await epubFile.arrayBuffer()) const tempFilePath = path.join(os.tmpdir(), `upload-${Date.now()}.epub`) await fs.writeFile(tempFilePath, buffer) let parsedData: any = null try { const { metadata, sections, cover } = await parseEpubSections(tempFilePath) let regexNormalized: string | null = null let regexPreset: string | null = null let chapters: ParsedChapter[] = [] if (splitMode === "regex") { const regexResolved = resolveRegexPattern(formData) const compiled = buildRegexFromInput(regexResolved.regexInput) chapters = buildChaptersFromRegexSections(sections, compiled.regex) regexNormalized = compiled.normalized regexPreset = regexResolved.regexPreset if (chapters.length === 0) { return NextResponse.json( { error: "Regex không tách được chương nào. Hãy thử regex khác hoặc chuyển về TOC.", parserInfo: { splitMode, chapterRegexUsed: regexNormalized, regexPreset, sourceSections: sections.length, chaptersDetected: 0, } }, { status: 400 } ) } } else { chapters = buildChaptersFromTOCSections(sections) if (chapters.length === 0) { return NextResponse.json( { error: "Không tìm thấy chương hợp lệ từ TOC. Bạn có thể thử chế độ Regex." }, { status: 400 } ) } } const gapFilled = withMissingChapterPlaceholders(chapters) parsedData = { metadata, sections, chapters: gapFilled.chapters, cover, parserInfo: { splitMode, chapterRegexUsed: regexNormalized, regexPreset, sourceSections: sections.length, chaptersDetected: chapters.length, chaptersFinal: gapFilled.chapters.length, insertedMissingChapters: gapFilled.insertedCount, detectedMaxChapterNumber: gapFilled.detectedMax, detectedNumberAssignments: gapFilled.detectedNumberAssignments, } } } finally { // Xóa file tạm await fs.unlink(tempFilePath).catch(() => { }) } const { metadata, chapters, parserInfo, cover } = parsedData const metadataTitle = normalizeMetaText(metadata?.title, "Truyện chưa đặt tên") const metadataAuthor = normalizeMetaText(metadata?.creator, "Khuyết danh") const metadataDescRaw = normalizeMetaText(metadata?.description, "Chưa có giới thiệu") const metadataDesc = convert(metadataDescRaw, { wordwrap: false }) const detectedGenreNames = extractGenreCandidatesFromMetadata(metadata) const novelTitle = normalizeMetaText(readFormText(formData, "title"), metadataTitle) const novelAuthor = normalizeMetaText(readFormText(formData, "authorName"), metadataAuthor) const novelDesc = normalizeMetaText(readFormText(formData, "description"), metadataDesc) const importDefaultStatus = "Hoàn thành" const hasDetectedVolumes = chapters.some((ch: any) => ch.volumeNumber !== null) if (previewOnly) { return NextResponse.json({ preview: true, fileName: epubFile.name, splitMode, detectedStructureType: hasDetectedVolumes ? "light_novel" : "standard", parserInfo, hasCoverFromEpub: !!cover?.buffer, novel: { title: novelTitle, authorName: novelAuthor, description: novelDesc, detectedGenres: detectedGenreNames, totalChapters: chapters.length, }, chaptersPreview: chapters.slice(0, 20).map((ch: any, i: number) => ({ number: ch.finalNumber || i + 1, title: ch.title, isPlaceholder: !!ch.isPlaceholder, volumeNumber: ch.volumeNumber, volumeTitle: ch.volumeTitle, volumeChapterNumber: ch.volumeChapterNumber, excerpt: (ch.content || "").slice(0, 180), })), }) } const duplicatedNovel = await findNovelByTitleInsensitive(novelTitle) const canReplaceDuplicated = duplicatedNovel ? canReplaceNovelByRole(session.user.role as UserRole, session.user.id, duplicatedNovel) : false if (duplicatedNovel && !replaceExisting) { return NextResponse.json({ code: "DUPLICATE_TITLE", error: `Truyện \"${duplicatedNovel.title}\" đã tồn tại`, canReplace: canReplaceDuplicated, existingNovel: { id: duplicatedNovel.id, title: duplicatedNovel.title, slug: duplicatedNovel.slug, }, }, { status: 409 }) } if (duplicatedNovel && replaceExisting && !canReplaceDuplicated) { return NextResponse.json({ code: "DUPLICATE_TITLE", error: "Bạn không có quyền replace truyện đã tồn tại", canReplace: false, existingNovel: { id: duplicatedNovel.id, title: duplicatedNovel.title, slug: duplicatedNovel.slug, }, }, { status: 403 }) } const resolvedGenreIds = await resolveGenreIdsFromNames(detectedGenreNames, true) const selectedSeriesId = await resolveSeriesIdForEpubImport({ mode: seriesMode, seriesId: seriesIdInput, seriesName: seriesNameInput, userRole: session.user.role, userId: session.user.id, }) const coverUrl = await saveCoverBufferToR2(cover) let targetNovelId = duplicatedNovel?.id || "" let responseStatus = 201 let replaced = false if (duplicatedNovel && replaceExisting) { const updatedNovel = await prisma.$transaction(async (tx) => { await tx.novel.update({ where: { id: duplicatedNovel.id }, data: { title: novelTitle, authorName: novelAuthor, description: novelDesc, status: importDefaultStatus, coverUrl, seriesId: selectedSeriesId, totalChapters: chapters.length, ...(session.user.role === "MOD" ? { uploaderId: session.user.id } : {}), }, }) await tx.novelGenre.deleteMany({ where: { novelId: duplicatedNovel.id }, }) if (resolvedGenreIds.length > 0) { await tx.novelGenre.createMany({ data: resolvedGenreIds.map((genreId) => ({ novelId: duplicatedNovel.id, genreId, })), skipDuplicates: true, }) } return tx.novel.findUnique({ where: { id: duplicatedNovel.id } }) }) if (!updatedNovel) { throw new Error("Không thể replace truyện đã tồn tại") } targetNovelId = updatedNovel.id responseStatus = 200 replaced = true if (duplicatedNovel.coverUrl && duplicatedNovel.coverUrl !== coverUrl) { await deleteR2ObjectByUrl(duplicatedNovel.coverUrl).catch(() => { }) } } else { // Generate base slug const baseSlug = slugify(novelTitle) let slug = baseSlug let slugCounter = 1 // Đảm bảo slug là duy nhất while (await prisma.novel.findUnique({ where: { slug } })) { slug = `${baseSlug}-${slugCounter}` slugCounter++ } const createData: any = { title: novelTitle, slug, authorName: novelAuthor, description: novelDesc, status: importDefaultStatus, coverUrl, seriesId: selectedSeriesId, uploaderId: session.user.id, totalChapters: chapters.length, } if (resolvedGenreIds.length > 0) { createData.genres = { create: resolvedGenreIds.map((genreId) => ({ genre: { connect: { id: genreId } }, })), } } const createdNovel = await prisma.novel.create({ data: createData }) targetNovelId = createdNovel.id } // Lưu chapters xuống MongoDB await connectToMongoDB() await Chapter.deleteMany({ novelId: targetNovelId }) const chapterDocs = chapters.map((ch: any, i: number) => ({ novelId: targetNovelId, number: ch.finalNumber || (i + 1), volumeNumber: ch.volumeNumber ?? null, volumeTitle: ch.volumeTitle ?? null, volumeChapterNumber: ch.volumeChapterNumber ?? null, title: ch.title, content: ch.content, views: 0, })) if (chapterDocs.length > 0) { await Chapter.insertMany(chapterDocs) } const novelAfterWrite = await prisma.novel.findUnique({ where: { id: targetNovelId } }) if (!novelAfterWrite) { throw new Error("Không thể tải lại thông tin truyện sau khi import") } return NextResponse.json({ ...novelAfterWrite, parserInfo, hasCoverFromEpub: !!coverUrl, detectedGenres: detectedGenreNames, replaced, }, { status: responseStatus }) } catch (error: any) { if (isRequestAbortedError(error)) { console.warn("EPUB upload aborted by client or network interruption") return NextResponse.json({ error: "Kết nối upload bị ngắt trong lúc xử lý" }, { status: 499 }) } console.error("EPUB upload error:", error) return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 }) } }