import { getLocalMovies, saveLocalMovie, syncFromMovies } from '../../../lib/localDb.js'; // Cấu hình timeout cho fetch async function fetchWithTimeout(resource, options = {}) { const { timeout = 8000 } = options; const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(resource, { ...options, signal: controller.signal }); return response; } finally { clearTimeout(id); } } // Trích xuất slug từ URL hoặc chuỗi nhập vào function extractSlug(input) { if (!input) return null; const trimmed = input.trim(); if (!trimmed) return null; try { if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { const url = new URL(trimmed); const pathname = url.pathname; const parts = pathname.split('/').filter(Boolean); if (parts.length > 0) { return parts[parts.length - 1]; } } } catch (e) {} const parts = trimmed.split('/').filter(Boolean); if (parts.length > 0) { return parts[parts.length - 1]; } return trimmed; } // Định dạng tên tập phim thành 2 chữ số (ví dụ: Tập 01, Tập 02, Tập 09 thay vì Tập 1, Tập 2) function formatEpisodeName(name) { if (!name) return name; let str = name.trim(); // Check if name is pure number, e.g. "1", "2", "03" if (/^\d+$/.test(str)) { const num = parseInt(str, 10); return `Tập ${num.toString().padStart(2, '0')}`; } // Check if name starts with "tập" followed by space and numbers, e.g., "Tập 1", "tap 2", "Tập 03" const match = str.match(/^(tập|tap)\s+(\d+)$/i); if (match) { const num = parseInt(match[2], 10); return `Tập ${num.toString().padStart(2, '0')}`; } return str; } // Hàm dọn dẹp và giới hạn máy chủ phát phim (Ví dụ: #Hà Nội (Thuyết Minh 1), #Hà Nội (Vietsub) -> Vietsub & Thuyết Minh) // Chỉ giữ tối đa 2 server phát phim chính (ưu tiên Vietsub, Thuyết Minh, Lồng Tiếng) để tránh quá tải danh sách server function cleanAndFilterServers(episodes) { if (!Array.isArray(episodes)) return []; const tempMap = new Map(); for (const srv of episodes) { if (!srv || !srv.server_name) continue; let rawName = srv.server_name.trim(); let cleanName = ''; if (/vietsub/i.test(rawName)) { cleanName = 'Vietsub'; } else if (/thuyết minh|thuyet minh/i.test(rawName)) { cleanName = 'Thuyết Minh'; } else if (/lồng tiếng|long tieng/i.test(rawName)) { cleanName = 'Lồng Tiếng'; } else { cleanName = rawName.replace(/#\s*/g, '').replace(/\([^)]*\)/g, '').trim(); if (!cleanName) cleanName = 'Vietsub'; } // Chỉ giữ các máy chủ chuẩn if (cleanName !== 'Vietsub' && cleanName !== 'Thuyết Minh' && cleanName !== 'Lồng Tiếng') { continue; } // Lưu tập phim của máy chủ đầu tiên xuất hiện cho mỗi loại if (!tempMap.has(cleanName)) { srv.server_name = cleanName; tempMap.set(cleanName, srv); } } // Dựng lại danh sách máy chủ theo độ ưu tiên: 1. Vietsub, 2. Thuyết Minh const result = []; if (tempMap.has('Vietsub')) result.push(tempMap.get('Vietsub')); if (tempMap.has('Thuyết Minh')) result.push(tempMap.get('Thuyết Minh')); // Nếu vẫn chưa đủ 2 server và có Lồng Tiếng, thêm Lồng Tiếng vào if (result.length < 2 && tempMap.has('Lồng Tiếng')) { result.push(tempMap.get('Lồng Tiếng')); } // Trường hợp không phát hiện máy chủ chuẩn nào, giữ lại máy chủ gốc đầu tiên nhưng dọn tên if (result.length === 0 && episodes.length > 0) { const fallbackSrv = episodes[0]; fallbackSrv.server_name = fallbackSrv.server_name.replace(/#\s*/g, '').replace(/\([^)]*\)/g, '').trim() || 'Vietsub'; result.push(fallbackSrv); } return result; } // Hàm gộp tập phim tránh đè cấu hình Intro/Outro của Admin function mergeEpisodes(existingServerList, crawledServerList) { let addedCount = 0; // Nhân bản sâu danh sách tập phim hiện tại để tránh tham chiếu và dễ gộp const existingList = JSON.parse(JSON.stringify(existingServerList || [])); for (const crawledServer of crawledServerList) { let existingServer = existingList.find(s => s.server_name === crawledServer.server_name); if (!existingServer) { crawledServer.server_data.forEach(ep => { ep.name = formatEpisodeName(ep.name); }); existingList.push(crawledServer); addedCount += crawledServer.server_data.length; continue; } // Đồng bộ lại toàn bộ định dạng tên tập hiện tại để so khớp chính xác existingServer.server_data.forEach(ep => { ep.name = formatEpisodeName(ep.name); }); // Map nhanh theo tên tập và ghi nhớ vị trí index gốc const existingMap = new Map(existingServer.server_data.map((e, idx) => [e.name, { ep: e, idx }])); for (const crawledEp of crawledServer.server_data) { crawledEp.name = formatEpisodeName(crawledEp.name); const matched = existingMap.get(crawledEp.name); if (matched) { // Cập nhật link cào mới nhất, bảo tồn các mốc Intro/Outro admin đã cấu hình thủ công existingServer.server_data[matched.idx] = { ...crawledEp, introStart: matched.ep.introStart, introEnd: matched.ep.introEnd, outroStart: matched.ep.outroStart, outroEnd: matched.ep.outroEnd }; } else { // Thêm tập mới vào server existingServer.server_data.push(crawledEp); addedCount++; } } } return { merged: existingList, addedCount }; } export async function POST({ request }) { // Check quyền admin từ cookie auth_token const cookies = request.headers.get('cookie') || ''; const hasToken = cookies.includes('auth_token='); if (!hasToken) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); } const { type, urls, pageStart, pageEnd, scanOnly, countrySlug, genreSlug, selectedEpisodes } = await request.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (data) => { try { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); } catch (e) { // Ignore if the controller is already closed } }; try { // Bơm mồi (padding) 2KB thông qua payload data hợp lệ để ép Vercel xả (flush) stream ngay lập tức send({ action: 'connected', padding: 'x'.repeat(2048) }); const localMovies = await getLocalMovies(); let slugsToCrawl = []; if (type === 'url') { // Xử lý crawl theo danh sách URL const urlList = Array.isArray(urls) ? urls : [urls]; const results = []; for (const rawUrl of urlList) { const slug = extractSlug(rawUrl); if (slug) { try { // Fetch chi tiết từ API KKPhim để có thông tin chính xác về tên phim và số tập const res = await fetchWithTimeout(`https://phimapi.com/phim/${slug}`); if (!res.ok) { throw new Error(`Phim không tồn tại trên KKPhim (HTTP ${res.status})`); } const data = await res.json(); if (!data.status || !data.movie) { throw new Error('Dữ liệu API KKPhim không đúng định dạng'); } const crawledMovie = data.movie; const crawledEpisodes = cleanAndFilterServers(data.episodes || []); // Tính số tập hiện có trên KKPhim let crawledEpCount = 0; if (crawledEpisodes.length > 0) { crawledEpCount = Math.max(...crawledEpisodes.map(s => s.server_data?.length || 0)); } const existing = localMovies[slug]; let expectedNew = crawledEpCount; let exists = false; if (existing) { exists = true; const { addedCount } = mergeEpisodes(existing.episodes, crawledEpisodes); expectedNew = addedCount; } slugsToCrawl.push({ raw: crawledMovie.name, slug, totalEpisodes: crawledEpCount, expectedNew, exists }); send({ action: 'scan_found', name: crawledMovie.name, slug: slug, totalEpisodes: crawledEpCount, expectedNew, exists }); } catch (e) { // Fallback nếu fetch lỗi slugsToCrawl.push({ raw: slug, slug, totalEpisodes: 0, expectedNew: 0, exists: false }); send({ action: 'scan_found', name: slug, slug: slug, totalEpisodes: 0, expectedNew: 0, exists: false }); } } else { results.push({ raw: rawUrl, status: 'invalid', message: 'URL không hợp lệ' }); } } send({ action: 'detect', count: slugsToCrawl.length, invalid: results }); } else if (type === 'country') { // Xử lý crawl theo quốc gia const start = parseInt(pageStart || '1'); const end = parseInt(pageEnd || '1'); send({ status: 'info', message: `Đang quét danh sách phim quốc gia: ${countrySlug} từ trang ${start} đến ${end}...` }); for (let p = start; p <= end; p++) { try { const res = await fetchWithTimeout(`https://phimapi.com/v1/api/quoc-gia/${countrySlug}?page=${p}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const resData = await res.json(); const items = resData.data?.items || []; for (const item of items) { if (item.slug && !slugsToCrawl.some(s => s.slug === item.slug)) { const existing = localMovies[item.slug]; let skip = false; let skipReason = ''; let expectedNew = 1; let exists = false; if (existing) { exists = true; const localStatus = (existing.movie?.episode_current || '').toLowerCase(); const remoteStatus = (item.episode_current || '').toLowerCase(); const isStatusEqual = localStatus === remoteStatus && remoteStatus !== ''; const isBothCompleted = (localStatus.includes('hoàn tất') || localStatus.includes('full') || localStatus.includes('complete') || localStatus.includes('12/12')) && (remoteStatus.includes('hoàn tất') || remoteStatus.includes('full') || remoteStatus.includes('complete') || remoteStatus.includes('12/12')); if (isStatusEqual || isBothCompleted) { skip = true; skipReason = remoteStatus.includes('hoàn tất') ? 'Phim đã hoàn tất' : 'Số tập trùng khớp'; expectedNew = 0; } } slugsToCrawl.push({ raw: item.name, slug: item.slug, totalEpisodes: item.episode_total || (item.episode_current ? item.episode_current : '?'), expectedNew, exists, skip, skipReason }); send({ action: 'scan_found', name: item.name, slug: item.slug, totalEpisodes: item.episode_total || (item.episode_current ? item.episode_current : '?'), expectedNew, exists, skip, skipReason }); } } } catch (e) { send({ status: 'warning', message: `Không thể lấy danh sách trang ${p}: ${e.message}` }); } } send({ action: 'detect', count: slugsToCrawl.length, invalid: [] }); } else if (type === 'genre') { // Xử lý crawl theo thể loại const start = parseInt(pageStart || '1'); const end = parseInt(pageEnd || '1'); send({ status: 'info', message: `Đang quét danh sách phim thể loại: ${genreSlug} từ trang ${start} đến ${end}...` }); for (let p = start; p <= end; p++) { try { let apiUrl = `https://phimapi.com/v1/api/the-loai/${genreSlug}?page=${p}`; const listSlugs = ['hoat-hinh', 'phim-chieu-rap', 'phim-le', 'phim-bo', 'tv-shows']; if (listSlugs.includes(genreSlug)) { apiUrl = `https://phimapi.com/v1/api/danh-sach/${genreSlug}?page=${p}`; } const res = await fetchWithTimeout(apiUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const resData = await res.json(); const items = resData.data?.items || []; for (const item of items) { if (item.slug && !slugsToCrawl.some(s => s.slug === item.slug)) { const existing = localMovies[item.slug]; let skip = false; let skipReason = ''; let expectedNew = 1; let exists = false; if (existing) { exists = true; const localStatus = (existing.movie?.episode_current || '').toLowerCase(); const remoteStatus = (item.episode_current || '').toLowerCase(); const isStatusEqual = localStatus === remoteStatus && remoteStatus !== ''; const isBothCompleted = (localStatus.includes('hoàn tất') || localStatus.includes('full') || localStatus.includes('complete') || localStatus.includes('12/12')) && (remoteStatus.includes('hoàn tất') || remoteStatus.includes('full') || remoteStatus.includes('complete') || remoteStatus.includes('12/12')); if (isStatusEqual || isBothCompleted) { skip = true; skipReason = remoteStatus.includes('hoàn tất') ? 'Phim đã hoàn tất' : 'Số tập trùng khớp'; expectedNew = 0; } } slugsToCrawl.push({ raw: item.name, slug: item.slug, totalEpisodes: item.episode_total || (item.episode_current ? item.episode_current : '?'), expectedNew, exists, skip, skipReason }); send({ action: 'scan_found', name: item.name, slug: item.slug, totalEpisodes: item.episode_total || (item.episode_current ? item.episode_current : '?'), expectedNew, exists, skip, skipReason }); } } } catch (e) { send({ status: 'warning', message: `Không thể lấy danh sách trang ${p}: ${e.message}` }); } } send({ action: 'detect', count: slugsToCrawl.length, invalid: [] }); } else if (type === 'batch') { // Xử lý crawl batch theo trang phim mới cập nhật const start = parseInt(pageStart || '1'); const end = parseInt(pageEnd || '1'); send({ status: 'info', message: `Đang quét danh sách phim mới từ trang ${start} đến ${end}...` }); for (let p = start; p <= end; p++) { try { const res = await fetchWithTimeout(`https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=${p}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const items = data.items || []; for (const item of items) { if (item.slug && !slugsToCrawl.some(s => s.slug === item.slug)) { slugsToCrawl.push({ raw: item.name, slug: item.slug }); send({ action: 'scan_found', name: item.name, slug: item.slug }); } } } catch (e) { send({ status: 'warning', message: `Không thể lấy danh sách trang ${p}: ${e.message}` }); } } send({ action: 'detect', count: slugsToCrawl.length, invalid: [] }); } else if (type === 'all') { // Xử lý crawl toàn bộ phim trên API KKPhim send({ status: 'info', message: 'Đang kết nối API KKPhim để lấy thông tin tổng số trang...' }); let totalPages = 1; try { const firstRes = await fetchWithTimeout(`https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=1`); if (firstRes.ok) { const firstData = await firstRes.json(); if (firstData.pagination && firstData.pagination.totalPages) { totalPages = firstData.pagination.totalPages; } } } catch (e) { send({ status: 'warning', message: `Không thể lấy thông tin trang 1: ${e.message}` }); } send({ status: 'info', message: `Phát hiện tổng số ${totalPages} trang danh sách (khoảng ${totalPages * 30} phim). Đang lấy danh sách slugs toàn bộ phim...` }); // Tải danh sách song song (concurrency = 20) để cực kỳ nhanh const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); const concurrency = 20; for (let i = 0; i < pageNumbers.length; i += concurrency) { const batch = pageNumbers.slice(i, i + concurrency); await Promise.all( batch.map(async (p) => { try { const res = await fetchWithTimeout(`https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=${p}`); if (!res.ok) return; const data = await res.json(); const items = data.items || []; for (const item of items) { if (item.slug && !slugsToCrawl.some(s => s.slug === item.slug)) { slugsToCrawl.push({ raw: item.name, slug: item.slug }); send({ action: 'scan_found', name: item.name, slug: item.slug }); } } } catch (e) { // Lỗi nhỏ bỏ qua để tiếp tục } }) ); send({ status: 'info', message: `Đang tải danh sách: đã lấy được ${slugsToCrawl.length} phim...` }); } send({ action: 'detect', count: slugsToCrawl.length, invalid: [] }); } if (slugsToCrawl.length === 0) { send({ action: 'done', message: 'Không có phim nào để crawl!' }); return; } if (scanOnly) { send({ action: 'scan_complete', slugs: slugsToCrawl }); return; } // Elegant 1.5s delay to visually separate the Analysis/Queuing phase from the Downloading/Supabase-saving phase await new Promise(r => setTimeout(r, 1500)); let successCount = 0; let failCount = 0; let skipCount = 0; // Bắt đầu crawl từng phim một for (let i = 0; i < slugsToCrawl.length; i++) { const item = slugsToCrawl[i]; if (item.skip) { skipCount++; send({ action: 'result', slug: item.slug, name: item.raw, status: 'skipped', message: `Bỏ qua (100% không có tập mới - ${item.skipReason})` }); continue; } send({ action: 'progress', index: i + 1, total: slugsToCrawl.length, slug: item.slug, name: item.raw, status: 'processing' }); try { const res = await fetchWithTimeout(`https://phimapi.com/phim/${item.slug}`); if (!res.ok) { throw new Error(`Phim không tồn tại trên KKPhim (HTTP ${res.status})`); } const data = await res.json(); if (!data.status || !data.movie) { throw new Error('Dữ liệu API KKPhim bị rỗng hoặc không đúng định dạng'); } const crawledMovie = data.movie; const crawledEpisodes = cleanAndFilterServers(data.episodes || []); // Lọc tập phim nếu admin chọn cụ thể if (Array.isArray(selectedEpisodes) && selectedEpisodes.length > 0) { crawledEpisodes.forEach(server => { if (server.server_data) { server.server_data = server.server_data.filter(ep => { const formattedName = formatEpisodeName(ep.name); return selectedEpisodes.includes(formattedName); }); } }); } const existing = localMovies[item.slug]; if (existing) { // So sánh gộp tập phim const { merged, addedCount } = mergeEpisodes(existing.episodes, crawledEpisodes); const hasSelectedEpisodes = Array.isArray(selectedEpisodes) && selectedEpisodes.length > 0; if (addedCount > 0 || hasSelectedEpisodes) { // Có tập mới hoặc cập nhật tập đã chọn! Cập nhật dữ liệu existing.movie = { ...existing.movie, ...crawledMovie, episode_current: crawledMovie.episode_current || existing.movie.episode_current, episode_total: crawledMovie.episode_total || existing.movie.episode_total || null, modified: { time: new Date().toISOString() } }; existing.episodes = merged; await saveLocalMovie(item.slug, existing); successCount++; send({ action: 'result', slug: item.slug, name: crawledMovie.name, status: 'updated', added: addedCount, message: hasSelectedEpisodes && addedCount === 0 ? `Thành công (Cập nhật liên kết ${selectedEpisodes.length} tập)` : `Thành công (Cập nhật +${addedCount} tập mới)` }); } else { // Không có tập mới nào, bỏ qua skipCount++; send({ action: 'result', slug: item.slug, name: crawledMovie.name, status: 'skipped', message: 'Bỏ qua (Đã đầy đủ tập, không có tập mới)' }); } } else { // Phim mới hoàn toàn crawledEpisodes.forEach(srv => { if (srv.server_data) { srv.server_data.forEach(ep => { ep.name = formatEpisodeName(ep.name); }); } }); const newMovie = { movie: crawledMovie, episodes: crawledEpisodes }; await saveLocalMovie(item.slug, newMovie); successCount++; send({ action: 'result', slug: item.slug, name: crawledMovie.name, status: 'success', message: 'Thành công (Phim mới)' }); } // Tự động đồng bộ các danh mục Thể loại, Quốc gia, Diễn viên và Server ngay lập tức! await syncFromMovies('genres'); await syncFromMovies('countries'); await syncFromMovies('actors'); await syncFromMovies('servers'); } catch (e) { failCount++; send({ action: 'result', slug: item.slug, name: item.raw, status: 'failed', message: `Thất bại: ${e.message}` }); } // Tránh bị rate limit bởi server KKPhim await new Promise(r => setTimeout(r, 400)); } send({ action: 'complete', success: successCount, failed: failCount, skipped: skipCount }); } catch (err) { send({ action: 'error', message: err.message }); } finally { try { controller.close(); } catch (e) { // Ignore if already closed } } } }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' } }); }