Spaces:
Sleeping
Sleeping
| 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' | |
| } | |
| }); | |
| } | |