dptxa-proxy / src /pages /admin /crawl.astro
TXAVLOG
Deploy DPTXA to Hugging Face Spaces
4bea261
Raw
History Blame Contribute Delete
93.9 kB
---
import AdminLayout from '../../layouts/AdminLayout.astro';
---
<AdminLayout title="Thu thập phim KKPhim">
<div class="mb-12">
<h1 class="text-4xl font-black tracking-tighter text-white">Hệ thống Thu thập phim (Crawler)</h1>
<p class="mt-2 text-gray-500">Thu thập phim tự động từ nguồn dữ liệu KKPhim trực tiếp vào cơ sở dữ liệu tệp tin cục bộ.</p>
</div>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-12">
<!-- Cấu hình thu thập -->
<div class="lg:col-span-5 space-y-8">
<!-- Tabs cấu hình -->
<div class="glass rounded-3xl p-8 border border-white/5 space-y-6">
<div class="flex border-b border-white/5 pb-1 gap-2">
<button id="tab-url-btn" class="pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-primary border-b-2 border-primary transition-all whitespace-nowrap cursor-pointer">Theo URL/Slug</button>
<button id="tab-batch-btn" class="pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-white transition-all whitespace-nowrap cursor-pointer">Hàng loạt (Batch)</button>
<button id="tab-all-btn" class="pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-white transition-all whitespace-nowrap cursor-pointer">Toàn bộ (All)</button>
</div>
<!-- Section 1: URL Crawl -->
<div id="section-url" class="space-y-6">
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Danh sách URL hoặc Slug phim</label>
<p class="text-[10px] text-gray-500">Mỗi dòng là một link phim KKPhim hoặc slug phim. Ví dụ: <br/><code>https://kkphim.vip/phim/dau-pha-thuong-khung</code> hoặc <code>tay-du-ky</code></p>
<textarea
id="crawl-urls"
rows="8"
placeholder="Nhập danh sách link phim tại đây..."
class="mt-2 w-full bg-[#12141d] border border-white/5 rounded-2xl p-4 text-sm text-white focus:outline-none focus:border-primary/50 font-mono transition-all"
></textarea>
</div>
<!-- Trạng thái detect URL thời gian thực -->
<div class="flex items-center justify-between bg-white/5 rounded-2xl px-6 py-4 border border-white/5">
<span class="text-xs font-bold text-gray-400">Trạng thái phân tích:</span>
<div class="flex gap-4">
<span class="text-xs font-black text-emerald-400" id="detect-valid">0 Hợp lệ</span>
<span class="text-xs font-black text-red-400" id="detect-invalid">0 Không hợp lệ</span>
</div>
</div>
<button id="start-url-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-primary py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer">
<i class="fas fa-spider"></i> Bắt đầu thu thập
</button>
</div>
<!-- Section 2: Batch Crawl -->
<div id="section-batch" class="space-y-6 hidden">
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang bắt đầu</label>
<input
type="number"
id="batch-page-start"
value="1"
min="1"
class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang kết thúc</label>
<input
type="number"
id="batch-page-end"
value="1"
min="1"
class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all"
/>
</div>
</div>
<p class="text-[10px] text-gray-500">Quét toàn bộ phim mới cập nhật trên các trang được chọn. Mỗi trang có khoảng 30 phim.</p>
<button id="start-batch-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-primary py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer">
<i class="fas fa-layer-group"></i> Bắt đầu quét hàng loạt
</button>
</div>
<!-- Section 3: All Crawl -->
<div id="section-all" class="space-y-6 hidden">
<div class="bg-amber-500/10 border border-amber-500/20 rounded-2xl p-5 text-amber-400 space-y-2">
<h4 class="text-xs font-black uppercase tracking-widest flex items-center gap-1.5"><i class="fas fa-exclamation-triangle"></i> Lưu ý quan trọng</h4>
<p class="text-[10.5px] leading-relaxed opacity-90">Hệ thống sẽ quét toàn bộ dữ liệu phim trên hệ thống API KKPhim (22.826+ phim). Tiến trình này sẽ cào tuần tự, tự động cập nhật tập phim mới hoặc thêm mới phim chưa tồn tại.</p>
</div>
<button id="start-all-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-primary py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer">
<i class="fas fa-database"></i> Bắt đầu cào toàn bộ (All)
</button>
</div>
</div>
<!-- Card 2: Crawl theo Quốc gia -->
<div class="glass rounded-3xl p-8 border border-white/5 space-y-6">
<div class="flex items-center gap-3 border-b border-white/5 pb-4">
<div class="h-8 w-8 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
<i class="fas fa-globe"></i>
</div>
<div>
<h3 class="text-xs font-black uppercase tracking-widest text-white">Cào theo quốc gia</h3>
<p class="text-[10px] text-gray-500 mt-0.5">Crawl phim từ KKPhim theo từng quốc gia cụ thể</p>
</div>
</div>
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Chọn quốc gia</label>
<select id="country-selector" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 font-bold transition-all cursor-pointer">
<option value="trung-quoc">Trung Quốc</option>
<option value="han-quoc">Hàn Quốc</option>
<option value="nhat-ban">Nhật Bản</option>
<option value="thai-lan">Thái Lan</option>
<option value="au-my">Âu Mỹ</option>
<option value="viet-nam">Việt Nam</option>
<option value="an-do">Ấn Độ</option>
<option value="hong-kong">Hồng Kông</option>
<option value="dai-loan">Đài Loan</option>
<option value="anh">Anh</option>
<option value="phap">Pháp</option>
<option value="canada">Canada</option>
<option value="duc">Đức</option>
<option value="tay-ban-nha">Tây Ban Nha</option>
<option value="tho-nhi-ky">Thổ Nhĩ Kỳ</option>
<option value="philippines">Philippines</option>
<option value="y">Ý</option>
<option value="dan-mach">Đan Mạch</option>
<option value="mexico">Mexico</option>
<option value="malaysia">Malaysia</option>
<option value="uc">Úc</option>
<option value="brazil">Brazil</option>
<option value="bi">Bỉ</option>
<option value="na-uy">Na Uy</option>
<option value="nam-phi">Nam Phi</option>
<option value="ba-lan">Ba Lan</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang bắt đầu</label>
<input
type="number"
id="country-page-start"
value="1"
min="1"
class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all font-bold"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang kết thúc</label>
<input
type="number"
id="country-page-end"
value="1"
min="1"
class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all font-bold"
/>
</div>
</div>
<p class="text-[10px] text-gray-500">Hệ thống sẽ tải toàn bộ danh sách phim của quốc gia này trên các trang được chọn trước khi cào chi tiết.</p>
<button id="start-country-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-primary py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer font-bold">
<i class="fas fa-globe-asia"></i> Quét theo quốc gia
</button>
</div>
</div>
<!-- Card 3: Crawl theo Thể loại -->
<div class="glass rounded-3xl p-8 border border-white/5 space-y-6">
<div class="flex items-center gap-3 border-b border-white/5 pb-4">
<div class="h-8 w-8 rounded-xl bg-purple-500/10 flex items-center justify-center text-purple-400 border border-purple-500/20">
<i class="fas fa-tags"></i>
</div>
<div>
<h3 class="text-xs font-black uppercase tracking-widest text-white">Cào theo thể loại</h3>
<p class="text-[10px] text-gray-500 mt-0.5">Crawl phim từ KKPhim theo từng thể loại cụ thể</p>
</div>
</div>
<div class="space-y-4">
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Chọn thể loại</label>
<select id="genre-selector" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-purple-500/50 font-bold transition-all cursor-pointer">
<option value="hanh-dong">Hành Động</option>
<option value="tinh-cam">Tình Cảm</option>
<option value="hai-huoc">Hài Hước</option>
<option value="co-trang">Cổ Trang</option>
<option value="tam-ly">Tâm Lý</option>
<option value="hinh-su">Hình Sự</option>
<option value="chien-tranh">Chiến Tranh</option>
<option value="the-thao">Thể Thao</option>
<option value="vo-thuat">Võ Thuật</option>
<option value="vien-tuong">Viễn Tưởng</option>
<option value="phi-phieu-luu">Phiêu Lưu</option>
<option value="khoa-hoc">Khoa Học</option>
<option value="kinh-di">Kinh Dị</option>
<option value="am-nhac">Âm Nhạc</option>
<option value="phim-chieu-rap">Phim Chiếu Rạp</option>
<option value="hoat-hinh">Hoạt Hình (Anime)</option>
<option value="phim-le">Phim Lẻ</option>
<option value="phim-bo">Phim Bộ</option>
<option value="tv-shows">TV Shows</option>
<option value="than-thoai">Thần Thoại</option>
<option value="tai-lieu">Tài Liệu</option>
<option value="gia-dinh">Gia Đình</option>
<option value="chinh-kich">Chính Kịch</option>
<option value="bi-an">Bí Ẩn</option>
<option value="hoc-duong">Học Đường</option>
<option value="kinh-dien">Kinh Điển</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang bắt đầu</label>
<input
type="number"
id="genre-page-start"
value="1"
min="1"
class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-purple-500/50 transition-all font-bold"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang kết thúc</label>
<input
type="number"
id="genre-page-end"
value="1"
min="1"
class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-purple-500/50 transition-all font-bold"
/>
</div>
</div>
<p class="text-[10px] text-gray-500">Hệ thống sẽ tải toàn bộ danh sách phim của thể loại này trên các trang được chọn trước khi cào chi tiết.</p>
<button id="start-genre-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-purple-500 py-4 text-xs font-black uppercase tracking-widest text-white hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_rgba(168,85,247,0.4)] cursor-pointer font-bold">
<i class="fas fa-tags"></i> Quét theo thể loại
</button>
</div>
</div>
</div>
<!-- Tiến trình & Nhật ký -->
<div class="lg:col-span-7 space-y-6">
<!-- Thẻ trạng thái tiến trình -->
<div class="glass rounded-3xl p-8 border border-white/5 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-black text-white">Trạng thái tiến trình</h3>
<span class="text-[10px] font-black uppercase tracking-widest bg-white/5 border border-white/5 px-3 py-1 rounded-xl text-gray-400" id="crawl-status-badge">Đang chờ lệnh</span>
</div>
<!-- Progress bar -->
<div class="space-y-2">
<div class="flex items-center justify-between text-xs text-gray-400">
<span id="current-movie-name" class="font-bold truncate max-w-xs">Chưa có phim nào đang xử lý...</span>
<span id="progress-percent" class="font-mono font-black">0%</span>
</div>
<div class="h-3 w-full bg-[#12141d] rounded-full overflow-hidden border border-white/5">
<div id="progress-bar-inner" class="h-full w-0 bg-primary shadow-[0_0_15px_var(--primary)] transition-all duration-300"></div>
</div>
</div>
<!-- Confirmation Action Panel -->
<div id="confirm-panel" class="hidden bg-white/[0.02] rounded-2xl p-6 border border-primary/20 space-y-4">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
<i class="fas fa-list-ul"></i>
</div>
<div>
<h4 class="text-xs font-black uppercase tracking-widest text-white">Đã quét xong danh sách phim</h4>
<p class="text-[10px] text-gray-400 mt-0.5" id="confirm-panel-subtitle">Sẵn sàng cào chi tiết 0 phim.</p>
</div>
</div>
<div class="flex gap-3">
<button id="cancel-crawl-btn" class="flex-1 py-3.5 px-4 rounded-xl border border-white/5 text-gray-400 hover:text-white hover:bg-white/5 transition-all text-xs font-black uppercase tracking-widest cursor-pointer text-center">Hủy bỏ</button>
<button id="confirm-crawl-btn" class="flex-[2] py-3.5 px-4 rounded-xl bg-primary text-dark hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest font-bold shadow-[0_0_15px_var(--primary)] cursor-pointer flex items-center justify-center gap-2">
<i class="fas fa-play"></i> Xác nhận & Cào chi tiết
</button>
</div>
</div>
<!-- Active Crawl Controls -->
<div id="active-crawl-controls" class="hidden flex gap-3">
<button id="pause-resume-btn" class="flex-1 py-3.5 px-4 rounded-xl bg-amber-500/20 border border-amber-500/30 text-amber-400 hover:bg-amber-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-2 font-bold">
<i class="fas fa-pause"></i> Tạm dừng cào
</button>
<button id="stop-crawl-btn" class="flex-1 py-3.5 px-4 rounded-xl bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-2 font-bold">
<i class="fas fa-stop"></i> Hủy tiến trình
</button>
</div>
<!-- Thống kê nhanh số lượng -->
<div class="grid grid-cols-3 gap-4">
<div class="bg-[#12141d]/50 rounded-2xl p-4 border border-emerald-500/10 text-center">
<span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Thành công</span>
<span class="text-2xl font-black text-emerald-400 block mt-1" id="stats-success">0</span>
</div>
<div class="bg-[#12141d]/50 rounded-2xl p-4 border border-amber-500/10 text-center">
<span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Bỏ qua</span>
<span class="text-2xl font-black text-amber-400 block mt-1" id="stats-skipped">0</span>
</div>
<div class="bg-[#12141d]/50 rounded-2xl p-4 border border-red-500/10 text-center">
<span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Thất bại</span>
<span class="text-2xl font-black text-red-400 block mt-1" id="stats-failed">0</span>
</div>
</div>
</div>
<!-- Bảng Tiến Trình Chi Tiết -->
<div class="glass rounded-3xl p-6 border border-white/5 space-y-4">
<div class="flex items-center justify-between border-b border-white/5 pb-4">
<span class="text-xs font-black uppercase tracking-widest text-gray-400"><i class="fas fa-table mr-2"></i> Bảng tiến trình chi tiết</span>
<span class="text-[10px] font-mono text-gray-500" id="table-count">Chờ chạy...</span>
</div>
<!-- Bulk Action Bar -->
<div id="bulk-action-bar" class="hidden flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 bg-white/[0.02] p-4 rounded-2xl border border-white/5 transition-all">
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary border border-primary/20">
<i class="fas fa-tasks"></i>
</div>
<div>
<span class="text-xs font-black uppercase tracking-widest text-white block">Thao tác hàng loạt</span>
<span class="text-[10px] text-gray-400 block mt-0.5" id="selected-count">Đã chọn 0 phim</span>
</div>
</div>
<div class="flex items-center gap-2 w-full sm:w-auto">
<button id="bulk-require-login-btn" class="flex-1 sm:flex-initial py-2.5 px-4 rounded-xl bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-1.5 font-bold">
<i class="fas fa-lock"></i> Khóa Login
</button>
<button id="bulk-free-login-btn" class="flex-1 sm:flex-initial py-2.5 px-4 rounded-xl bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-1.5 font-bold">
<i class="fas fa-unlock"></i> Mở Khóa Free
</button>
</div>
</div>
<div class="overflow-y-auto max-h-[300px] border border-white/5 rounded-2xl scrollbar-thin bg-[#0a0b0f]">
<table class="w-full text-left border-collapse text-xs select-text">
<thead>
<tr class="border-b border-white/5 bg-white/[0.02]">
<th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px] w-12 text-center select-none">
<input type="checkbox" id="select-all-crawl" class="rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" disabled />
</th>
<th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px] w-12 text-center">STT</th>
<th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px]">Tên phim</th>
<th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px] w-24">Kết quả</th>
<th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px]">Chi tiết</th>
</tr>
</thead>
<tbody id="progress-table-body" class="divide-y divide-white/[0.03]">
<tr>
<td colspan="5" class="py-8 text-center text-gray-600 font-medium">Chưa bắt đầu tiến trình cào dữ liệu</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Logs Console -->
<div class="glass rounded-3xl p-6 border border-white/5 space-y-4">
<div class="flex items-center justify-between border-b border-white/5 pb-4">
<span class="text-xs font-black uppercase tracking-widest text-gray-400"><i class="fas fa-terminal mr-2"></i> Nhật ký hoạt động chi tiết</span>
<button id="clear-logs-btn" class="text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors">Xóa nhật ký</button>
</div>
<!-- Hộp logs giả lập terminal -->
<div
id="logs-console"
class="h-72 w-full bg-[#0a0b0f] rounded-2xl p-5 font-mono text-xs overflow-y-auto space-y-2 border border-white/5 select-text scrollbar-thin"
>
<div class="text-gray-500">// Hệ thống đã sẵn sàng. Hãy cấu hình và nhấn Bắt đầu thu thập...</div>
</div>
</div>
</div>
</div>
</AdminLayout>
<script>
function init() {
const { txatoast, txamodal } = (window as any);
if (!txatoast) return;
// Elements
const tabUrlBtn = document.getElementById('tab-url-btn');
const tabBatchBtn = document.getElementById('tab-batch-btn');
const tabAllBtn = document.getElementById('tab-all-btn');
const sectionUrl = document.getElementById('section-url');
const sectionBatch = document.getElementById('section-batch');
const sectionAll = document.getElementById('section-all');
const textareaUrls = document.getElementById('crawl-urls') as HTMLTextAreaElement;
const detectValid = document.getElementById('detect-valid');
const detectInvalid = document.getElementById('detect-invalid');
const startUrlCrawlBtn = document.getElementById('start-url-crawl-btn') as HTMLButtonElement;
const startBatchCrawlBtn = document.getElementById('start-batch-crawl-btn') as HTMLButtonElement;
const startAllCrawlBtn = document.getElementById('start-all-crawl-btn') as HTMLButtonElement;
const startCountryCrawlBtn = document.getElementById('start-country-crawl-btn') as HTMLButtonElement;
const startGenreCrawlBtn = document.getElementById('start-genre-crawl-btn') as HTMLButtonElement;
const pageStartInput = document.getElementById('batch-page-start') as HTMLInputElement;
const pageEndInput = document.getElementById('batch-page-end') as HTMLInputElement;
const countrySelector = document.getElementById('country-selector') as HTMLSelectElement;
const countryPageStartInput = document.getElementById('country-page-start') as HTMLInputElement;
const countryPageEndInput = document.getElementById('country-page-end') as HTMLInputElement;
const genreSelector = document.getElementById('genre-selector') as HTMLSelectElement;
const genrePageStartInput = document.getElementById('genre-page-start') as HTMLInputElement;
const genrePageEndInput = document.getElementById('genre-page-end') as HTMLInputElement;
const crawlStatusBadge = document.getElementById('crawl-status-badge');
const currentMovieName = document.getElementById('current-movie-name');
const progressPercent = document.getElementById('progress-percent');
const progressBarInner = document.getElementById('progress-bar-inner');
const confirmPanel = document.getElementById('confirm-panel');
const confirmPanelSubtitle = document.getElementById('confirm-panel-subtitle');
const cancelCrawlBtn = document.getElementById('cancel-crawl-btn');
const confirmCrawlBtn = document.getElementById('confirm-crawl-btn');
const statsSuccess = document.getElementById('stats-success');
const statsSkipped = document.getElementById('stats-skipped');
const statsFailed = document.getElementById('stats-failed');
const progressTableBody = document.getElementById('progress-table-body');
const tableCount = document.getElementById('table-count');
const logsConsole = document.getElementById('logs-console');
const clearLogsBtn = document.getElementById('clear-logs-btn');
// Bulk action elements
const bulkActionBar = document.getElementById('bulk-action-bar');
const selectedCount = document.getElementById('selected-count');
const bulkRequireLoginBtn = document.getElementById('bulk-require-login-btn');
const bulkFreeLoginBtn = document.getElementById('bulk-free-login-btn');
const selectAllChk = document.getElementById('select-all-crawl') as HTMLInputElement;
let currentScrawledSlugs: any[] = [];
const activeCrawlControls = document.getElementById('active-crawl-controls');
// Refresh bulk actions bar and select all status
const refreshBulkActionBar = () => {
const chks = document.querySelectorAll('.movie-select-chk:not([disabled])');
if (chks.length > 0) {
if (bulkActionBar) bulkActionBar.classList.remove('hidden');
if (selectAllChk) selectAllChk.removeAttribute('disabled');
} else {
if (bulkActionBar) bulkActionBar.classList.add('hidden');
if (selectAllChk) {
selectAllChk.setAttribute('disabled', 'true');
selectAllChk.checked = false;
}
}
updateSelectedCount();
};
// Update selected count display
const updateSelectedCount = () => {
const checkedChks = document.querySelectorAll('.movie-select-chk:checked');
if (selectedCount) {
selectedCount.innerText = `Đã chọn ${checkedChks.length} phim`;
}
};
if (progressTableBody) {
progressTableBody.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (target && target.classList.contains('movie-select-chk')) {
updateSelectedCount();
if (selectAllChk) {
const enabledChks = document.querySelectorAll('.movie-select-chk:not([disabled])') as NodeListOf<HTMLInputElement>;
const checkedChks = document.querySelectorAll('.movie-select-chk:checked') as NodeListOf<HTMLInputElement>;
selectAllChk.checked = enabledChks.length > 0 && enabledChks.length === checkedChks.length;
}
}
});
}
if (selectAllChk) {
selectAllChk.onchange = () => {
const enabledChks = document.querySelectorAll('.movie-select-chk:not([disabled])') as NodeListOf<HTMLInputElement>;
enabledChks.forEach(chk => {
chk.checked = selectAllChk.checked;
});
updateSelectedCount();
};
}
const executeBulkLoginAction = async (action: 'require-login' | 'free-login') => {
const checkedChks = document.querySelectorAll('.movie-select-chk:checked') as NodeListOf<HTMLInputElement>;
const slugs = Array.from(checkedChks).map(chk => chk.getAttribute('data-slug')).filter(Boolean);
if (slugs.length === 0) {
txatoast.error('Vui lòng chọn ít nhất một phim để thực hiện thao tác!');
return;
}
const actionText = action === 'require-login' ? 'Yêu cầu đăng nhập' : 'Không yêu cầu đăng nhập';
const actionType = action === 'require-login' ? 'warning' : 'success';
txamodal.show({
title: 'Xác nhận thay đổi quyền xem phim',
message: `Bạn có chắc chắn muốn thiết lập <strong>${actionText}</strong> cho <strong>${slugs.length}</strong> phim đã chọn?`,
type: actionType,
confirmText: 'Xác nhận',
cancelText: 'Hủy',
onConfirm: async () => {
try {
log(`Đang thiết lập ${actionText} hàng loạt cho ${slugs.length} phim...`, 'info');
const res = await fetch('/api/admin/movie/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slugs, action })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Lỗi không xác định');
txatoast.success(data.message || 'Cập nhật thành công!');
log(data.message || 'Thiết lập hàng loạt hoàn tất.', 'success');
slugs.forEach(slug => {
const row = progressTableBody?.querySelector(`[data-slug="${slug}"]`);
if (row) {
const detailEl = row.querySelector('.status-detail');
if (detailEl) {
const oldBadge = detailEl.querySelector('.login-badge');
if (oldBadge) oldBadge.remove();
const loginBadge = document.createElement('span');
loginBadge.className = 'login-badge inline-flex items-center px-1.5 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ml-1.5 ' +
(action === 'require-login'
? 'bg-red-500/10 border border-red-500/20 text-red-400'
: 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400');
loginBadge.innerHTML = action === 'require-login'
? '<i class="fas fa-lock mr-1"></i> Khóa Login'
: '<i class="fas fa-unlock mr-1"></i> Free';
detailEl.appendChild(loginBadge);
}
const chk = row.querySelector('.movie-select-chk');
if (chk) chk.checked = false;
}
});
if (selectAllChk) selectAllChk.checked = false;
updateSelectedCount();
} catch (e) {
txatoast.error(e.message || 'Lỗi khi cập nhật quyền xem phim');
log(`Lỗi khi cập nhật: ${e.message}`, 'error');
}
return true;
}
});
};
if (bulkRequireLoginBtn) {
bulkRequireLoginBtn.onclick = () => executeBulkLoginAction('require-login');
}
if (bulkFreeLoginBtn) {
bulkFreeLoginBtn.onclick = () => executeBulkLoginAction('free-login');
}
const pauseResumeBtn = document.getElementById('pause-resume-btn');
const stopCrawlBtn = document.getElementById('stop-crawl-btn');
let crawlQueue: any[] = [];
let crawlIndex = 0;
let isCrawling = false;
let isPaused = false;
let successCount = 0;
let activeWorkers = 0;
const CONCURRENCY = 2;
let skipCount = 0;
let failCount = 0;
// Helper to control buttons and inputs state visually
const setButtonsState = (isLoading, activeBtn = null) => {
const btns = [startUrlCrawlBtn, startBatchCrawlBtn, startAllCrawlBtn, startCountryCrawlBtn, startGenreCrawlBtn];
const inputs = [textareaUrls, pageStartInput, pageEndInput, countrySelector, countryPageStartInput, countryPageEndInput, genreSelector, genrePageStartInput, genrePageEndInput];
btns.forEach(btn => {
if (!btn) return;
if (isLoading) {
btn.disabled = true;
btn.classList.add('opacity-50', 'cursor-not-allowed');
btn.classList.remove('hover:scale-102', 'active:scale-98');
if (btn === activeBtn) {
btn.setAttribute('data-orig-html', btn.innerHTML);
btn.innerHTML = `<i class="fas fa-spinner fa-spin animate-spin"></i> Đang quét...`;
}
} else {
btn.disabled = false;
btn.classList.remove('opacity-50', 'cursor-not-allowed');
btn.classList.add('hover:scale-102', 'active:scale-98');
const origHtml = btn.getAttribute('data-orig-html');
if (origHtml) {
btn.innerHTML = origHtml;
btn.removeAttribute('data-orig-html');
}
}
});
inputs.forEach(input => {
if (!input) return;
if (isLoading) {
input.setAttribute('disabled', 'true');
input.classList.add('opacity-50', 'cursor-not-allowed');
} else {
input.removeAttribute('disabled');
input.classList.remove('opacity-50', 'cursor-not-allowed');
}
});
};
// 1. Tab switching for 3 tabs
if (tabUrlBtn && tabBatchBtn && tabAllBtn && sectionUrl && sectionBatch && sectionAll) {
const activeClass = 'pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-primary border-b-2 border-primary transition-all whitespace-nowrap cursor-pointer';
const inactiveClass = 'pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-white transition-all whitespace-nowrap cursor-pointer';
tabUrlBtn.onclick = () => {
tabUrlBtn.className = activeClass;
tabBatchBtn.className = inactiveClass;
tabAllBtn.className = inactiveClass;
sectionUrl.classList.remove('hidden');
sectionBatch.classList.add('hidden');
sectionAll.classList.add('hidden');
};
tabBatchBtn.onclick = () => {
tabUrlBtn.className = inactiveClass;
tabBatchBtn.className = activeClass;
tabAllBtn.className = inactiveClass;
sectionUrl.classList.add('hidden');
sectionBatch.classList.remove('hidden');
sectionAll.classList.add('hidden');
};
tabAllBtn.onclick = () => {
tabUrlBtn.className = inactiveClass;
tabBatchBtn.className = inactiveClass;
tabAllBtn.className = activeClass;
sectionUrl.classList.add('hidden');
sectionBatch.classList.add('hidden');
sectionAll.classList.remove('hidden');
};
}
// 2. Real-time URL Detection Logic
const 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;
};
const detectUrls = () => {
const text = textareaUrls.value;
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
let valid = 0;
let invalid = 0;
lines.forEach(line => {
const slug = extractSlug(line);
if (slug && slug.length > 2) {
valid++;
} else {
invalid++;
}
});
if (detectValid) detectValid.innerText = `${valid} Hợp lệ`;
if (detectInvalid) detectInvalid.innerText = `${invalid} Không hợp lệ`;
};
if (textareaUrls) {
textareaUrls.addEventListener('input', detectUrls);
}
// 3. Clear logs
if (clearLogsBtn && logsConsole) {
clearLogsBtn.onclick = () => {
logsConsole.innerHTML = '<div class="text-gray-500">// Nhật ký đã được dọn sạch.</div>';
};
}
// 4. Log helper
const log = (message, type = 'info') => {
if (!logsConsole) return;
const timeStr = new Date().toLocaleTimeString();
let colorClass = 'text-gray-400';
let icon = '⚙️';
if (type === 'success') {
colorClass = 'text-emerald-400 font-bold';
icon = '✓';
} else if (type === 'skipped') {
colorClass = 'text-amber-400 font-bold';
icon = '➜';
} else if (type === 'warning') {
colorClass = 'text-yellow-400';
icon = '⚠';
} else if (type === 'error') {
colorClass = 'text-red-400 font-bold';
icon = '✗';
}
const logRow = document.createElement('div');
logRow.className = `flex gap-2 leading-relaxed ${colorClass}`;
logRow.innerHTML = `<span class="text-gray-600 font-mono">[${timeStr}]</span> <span class="font-mono font-bold">${icon}</span> <span>${message}</span>`;
logsConsole.appendChild(logRow);
logsConsole.scrollTop = logsConsole.scrollHeight;
};
// 5. Crawl core runner via SSE Response Streaming
let activeStreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
const runCrawl = async (body, triggerBtn = null) => {
// Disable buttons
setButtonsState(true, triggerBtn);
// Reset UI states
if (statsSuccess) statsSuccess.innerText = '0';
if (statsSkipped) statsSkipped.innerText = '0';
if (statsFailed) statsFailed.innerText = '0';
if (progressPercent) progressPercent.innerText = '0%';
if (progressBarInner) progressBarInner.style.width = '0%';
if (confirmPanel) confirmPanel.classList.add('hidden');
if (bulkActionBar) bulkActionBar.classList.add('hidden');
if (selectAllChk) {
selectAllChk.setAttribute('disabled', 'true');
selectAllChk.checked = false;
}
if (progressTableBody && body.scanOnly) {
progressTableBody.innerHTML = `
<tr>
<td colspan="5" class="py-8 text-center text-gray-500 font-medium">Đang quét tìm danh sách phim từ API...</td>
</tr>
`;
}
if (tableCount) tableCount.innerText = 'Đang quét...';
if (crawlStatusBadge) {
const isScan = body.scanOnly;
crawlStatusBadge.innerText = isScan ? 'Đang quét' : 'Đang chạy';
crawlStatusBadge.className = isScan
? 'text-[10px] font-black uppercase tracking-widest bg-yellow-500/20 border border-yellow-500/40 px-3 py-1 rounded-xl text-yellow-400 animate-pulse'
: 'text-[10px] font-black uppercase tracking-widest bg-primary/20 border border-primary/40 px-3 py-1 rounded-xl text-primary animate-pulse';
}
log(body.scanOnly ? 'Bắt đầu kết nối & quét danh sách phim...' : 'Khởi chạy tiến trình tải dữ liệu chi tiết...', 'info');
try {
const response = await fetch('/api/admin/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Bạn không có quyền quản trị! Hãy đăng nhập lại.');
}
throw new Error(`Gặp lỗi kết nối (HTTP ${response.status})`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('Không thể khởi tạo luồng dữ liệu thời gian thực');
activeStreamReader = reader;
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
const cleanPart = part.replace(/^data:\s*/, '').trim();
if (!cleanPart) continue;
try {
const data = JSON.parse(cleanPart);
handleProgressMessage(data);
} catch (e) {
console.error('Error parsing SSE row:', cleanPart, e);
}
}
}
if (!body.scanOnly) {
log('--- ĐÃ HOÀN TẤT TIẾN TRÌNH CRAWL ---', 'success');
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Hoàn thành';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-emerald-500/20 border border-emerald-500/40 px-3 py-1 rounded-xl text-emerald-400';
}
if (currentMovieName) currentMovieName.innerText = 'Tiến trình hoàn tất!';
}
} catch (err) {
log(`Lỗi nghiêm trọng: ${err.message}`, 'error');
txatoast.error(err.message);
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Thất bại';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-red-500/20 border border-red-500/40 px-3 py-1 rounded-xl text-red-400';
}
} finally {
activeStreamReader = null;
setButtonsState(false);
}
};
const handleProgressMessage = (data) => {
if (data.action === 'detect') {
log(`Phân tích xong: Đã phát hiện ${data.count} phim hợp lệ cần thu thập.`, 'info');
if (data.invalid && data.invalid.length > 0) {
data.invalid.forEach((inv) => {
log(`Bỏ qua URL lỗi: "${inv.raw}" (${inv.message})`, 'warning');
});
}
if (tableCount) {
tableCount.textContent = `Tổng cộng: ${data.count} phim`;
}
} else if (data.action === 'scan_found') {
if (progressTableBody) {
if (progressTableBody.querySelector('td[colspan]')) {
progressTableBody.innerHTML = '';
}
const existingRow = progressTableBody.querySelector(`[data-slug="${data.slug}"]`);
if (!existingRow) {
// Keep DOM small during active scan to prevent UI freeze
if (progressTableBody.children.length >= 200) {
const currentTotal = parseInt(tableCount?.textContent?.match(/\d+/)?.[0] || '200');
if (tableCount) {
tableCount.textContent = `Tổng cộng: ${currentTotal + 1} phim`;
}
return;
}
const tr = document.createElement('tr');
tr.setAttribute('data-slug', data.slug);
tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]';
let detailHtml = 'Đã tìm thấy trên danh sách';
let badgeClass = 'bg-gray-500/10 border border-gray-500/20 text-gray-400';
let badgeText = 'Chờ cào';
if (data.totalEpisodes !== undefined) {
if (data.exists) {
badgeClass = 'bg-blue-500/10 border border-blue-500/20 text-blue-400';
badgeText = 'Đã có';
detailHtml = `Có <strong>${data.totalEpisodes}</strong> tập trên KKPhim. Dự kiến cập nhật thêm: <strong class="text-emerald-400">+${data.expectedNew}</strong> tập mới.`;
} else {
badgeClass = 'bg-purple-500/10 border border-purple-500/20 text-purple-400';
badgeText = 'Phim mới';
detailHtml = `Phim mới hoàn toàn. Có <strong>${data.totalEpisodes}</strong> tập. Dự kiến thêm mới toàn bộ: <strong class="text-emerald-400">+${data.totalEpisodes}</strong> tập.`;
}
}
const index = progressTableBody.children.length + 1;
const disabledAttr = data.exists ? '' : 'disabled';
tr.innerHTML = `
<td class="py-3 px-4 text-center select-none w-12">
<input type="checkbox" data-slug="${data.slug}" class="movie-select-chk rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" ${disabledAttr} />
</td>
<td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${index}</td>
<td class="py-3 px-4 font-semibold text-white">
<div class="font-bold text-[12px] whitespace-normal line-clamp-1">${data.name || 'Không rõ'}</div>
<div class="text-[10px] text-gray-500 font-mono mt-0.5">${data.slug}</div>
</td>
<td class="py-3 px-4">
<span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${badgeClass}">
${badgeText}
</span>
</td>
<td class="status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal">${detailHtml}</td>
`;
progressTableBody.appendChild(tr);
if (data.exists) refreshBulkActionBar();
tr.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
if (tableCount) {
tableCount.textContent = `Tổng cộng: ${progressTableBody ? progressTableBody.children.length : 0} phim`;
}
} else if (data.action === 'scan_complete') {
currentScrawledSlugs = data.slugs || [];
log(`--- QUÉT DANH SÁCH HOÀN TẤT ---`, 'success');
log(`Đã phát hiện tổng cộng ${currentScrawledSlugs.length} phim hợp lệ.`, 'info');
if (currentScrawledSlugs.length === 0) {
log('Không có phim nào để cào.', 'warning');
if (currentMovieName) currentMovieName.innerText = 'Không có phim mới cần cào.';
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Trống';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-gray-500/20 border border-gray-500/40 px-3 py-1 rounded-xl text-gray-400';
}
setButtonsState(false);
return;
}
log('Đã tải danh sách phim thành công. Vui lòng nhấn Bắt đầu cào chi tiết trong bảng xác nhận để tiếp tục...', 'info');
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Chờ xác nhận';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-amber-500/20 border border-amber-500/40 px-3 py-1 rounded-xl text-amber-400';
}
if (currentMovieName) currentMovieName.innerText = `Đã quét xong. Sẵn sàng cào ${currentScrawledSlugs.length} phim...`;
if (progressPercent) progressPercent.innerText = '0%';
if (progressBarInner) progressBarInner.style.width = '0%';
if (tableCount) tableCount.textContent = `Tổng cộng: ${currentScrawledSlugs.length} phim`;
// Populate preview lists inside table (capped at 200)
if (progressTableBody) {
progressTableBody.innerHTML = '';
const previewList = currentScrawledSlugs.slice(0, 200);
previewList.forEach((item, index) => {
const tr = document.createElement('tr');
tr.setAttribute('data-slug', item.slug);
tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]';
let detailHtml = 'Đã xếp hàng cào chi tiết';
let badgeClass = 'bg-gray-500/10 border border-gray-500/20 text-gray-400';
let badgeText = 'Chờ cào';
if (item.skip) {
badgeClass = 'bg-amber-500/10 border border-amber-500/20 text-amber-400';
badgeText = 'Bỏ qua';
detailHtml = `<span class="text-amber-400 font-bold">Tự động bỏ qua (${item.skipReason})</span>`;
} else if (item.totalEpisodes !== undefined) {
if (item.exists) {
badgeClass = 'bg-blue-500/10 border border-blue-500/20 text-blue-400';
badgeText = 'Đã có';
detailHtml = `Có <strong>${item.totalEpisodes}</strong> tập trên KKPhim. Dự kiến cập nhật thêm: <strong class="text-emerald-400">+${item.expectedNew}</strong> tập mới.`;
} else {
badgeClass = 'bg-purple-500/10 border border-purple-500/20 text-purple-400';
badgeText = 'Phim mới';
detailHtml = `Phim mới hoàn toàn. Có <strong>${item.totalEpisodes}</strong> tập. Dự kiến thêm mới toàn bộ: <strong class="text-emerald-400">+${item.totalEpisodes}</strong> tập.`;
}
}
const disabledAttr = (item.exists || item.skip) ? '' : 'disabled';
tr.innerHTML = `
<td class="py-3 px-4 text-center select-none w-12">
<input type="checkbox" data-slug="${item.slug}" class="movie-select-chk rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" ${disabledAttr} />
</td>
<td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${index + 1}</td>
<td class="py-3 px-4 font-semibold text-white">
<div class="font-bold text-[12px] whitespace-normal line-clamp-1">${item.raw || 'Chưa rõ tên'}</div>
<div class="text-[10px] text-gray-500 font-mono mt-0.5">${item.slug}</div>
</td>
<td class="py-3 px-4">
<span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${badgeClass}">
${badgeText}
</span>
</td>
<td class="status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal">${detailHtml}</td>
`;
progressTableBody.appendChild(tr);
});
if (currentScrawledSlugs.length > 200) {
const tr = document.createElement('tr');
tr.id = 'more-movies-row';
tr.innerHTML = `
<td colspan="5" class="py-4 text-center text-gray-500 font-bold bg-white/[0.01]">
... và ${currentScrawledSlugs.length - 200} phim khác trong danh sách chờ cào chi tiết
</td>
`;
progressTableBody.appendChild(tr);
}
refreshBulkActionBar();
}
// Hiển thị bảng điều khiển xác nhận cào chi tiết để người dùng tự quyết định bấm bắt đầu
if (confirmPanel) {
confirmPanel.classList.remove('hidden');
}
if (confirmPanelSubtitle) {
confirmPanelSubtitle.innerHTML = `Sẵn sàng cào chi tiết <strong>${currentScrawledSlugs.length}</strong> phim đã phát hiện.`;
}
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Chờ xác nhận';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-amber-500/20 border border-amber-500/40 px-3 py-1 rounded-xl text-amber-400';
}
if (currentMovieName) {
currentMovieName.innerText = `Đã tìm thấy ${currentScrawledSlugs.length} phim. Vui lòng xác nhận cào chi tiết.`;
}
} else if (data.action === 'progress') {
const percent = Math.round((data.index / data.total) * 100);
if (progressPercent) progressPercent.innerText = `${percent}%`;
if (progressBarInner) progressBarInner.style.width = `${percent}%`;
if (currentMovieName) currentMovieName.innerText = `Đang crawl: ${data.name} (${data.slug})`;
log(`[${data.index}/${data.total}] Bắt đầu tải dữ liệu: ${data.name}...`, 'info');
} else if (data.action === 'result') {
let type: 'success' | 'skipped' | 'error' = 'success';
if (data.status === 'skipped') type = 'skipped';
else if (data.status === 'failed') type = 'error';
log(`${data.name}: ${data.message}`, type);
// Update stats card
if (data.status === 'success' || data.status === 'updated') {
if (statsSuccess) statsSuccess.innerText = String(parseInt(statsSuccess.innerText) + 1);
} else if (data.status === 'skipped') {
if (statsSkipped) statsSkipped.innerText = String(parseInt(statsSkipped.innerText) + 1);
} else if (data.status === 'failed') {
if (statsFailed) statsFailed.innerText = String(parseInt(statsFailed.innerText) + 1);
}
// Add or update row to progress table
if (progressTableBody) {
let badgeClass = '';
let textStatus = '';
if (data.status === 'success') {
badgeClass = 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400';
textStatus = 'Mới';
} else if (data.status === 'updated') {
badgeClass = 'bg-blue-500/10 border border-blue-500/20 text-blue-400';
textStatus = 'Tập Mới';
} else if (data.status === 'skipped') {
badgeClass = 'bg-amber-500/10 border border-amber-500/20 text-amber-400';
textStatus = 'Bỏ qua';
} else if (data.status === 'failed') {
badgeClass = 'bg-red-500/10 border border-red-500/20 text-red-400';
textStatus = 'Lỗi';
}
const existingRow = progressTableBody.querySelector(`[data-slug="${data.slug}"]`);
if (existingRow) {
const badgeEl = existingRow.querySelector('.status-badge');
const detailEl = existingRow.querySelector('.status-detail');
const nameEl = existingRow.querySelector('.font-bold');
const chk = existingRow.querySelector('.movie-select-chk');
if (badgeEl) {
badgeEl.className = `status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${badgeClass}`;
badgeEl.textContent = textStatus;
}
if (detailEl) {
detailEl.className = 'status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal';
detailEl.textContent = data.message || '';
}
if (nameEl && data.name) {
nameEl.textContent = data.name;
}
if (chk) {
if (data.status === 'success' || data.status === 'updated' || data.status === 'skipped') {
chk.removeAttribute('disabled');
} else {
chk.setAttribute('disabled', 'true');
chk.checked = false;
}
}
existingRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
// Append and prune to prevent massive DOM nodes
const tr = document.createElement('tr');
tr.setAttribute('data-slug', data.slug);
tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]';
const index = progressTableBody.children.length + 1;
const disabledAttr = (data.status === 'success' || data.status === 'updated' || data.status === 'skipped') ? '' : 'disabled';
tr.innerHTML = `
<td class="py-3 px-4 text-center select-none w-12">
<input type="checkbox" data-slug="${data.slug || ''}" class="movie-select-chk rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" ${disabledAttr} />
</td>
<td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${index}</td>
<td class="py-3 px-4 font-semibold text-white">
<div class="font-bold text-[12px] whitespace-normal line-clamp-1">${data.name || 'Không rõ'}</div>
<div class="text-[10px] text-gray-500 font-mono mt-0.5">${data.slug || ''}</div>
</td>
<td class="py-3 px-4">
<span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${badgeClass}">
${textStatus}
</span>
</td>
<td class="status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal">${data.message || ''}</td>
`;
const moreRow = document.getElementById('more-movies-row');
if (moreRow) {
progressTableBody.insertBefore(tr, moreRow);
} else {
progressTableBody.appendChild(tr);
}
tr.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (progressTableBody.children.length > 300) {
const firstChild = progressTableBody.firstChild;
if (firstChild && firstChild !== moreRow) {
progressTableBody.removeChild(firstChild);
}
}
}
refreshBulkActionBar();
}
} else if (data.action === 'complete') {
txamodal.show({
title: 'Thu thập hoàn tất!',
message: `Quá trình crawl đã kết thúc tốt đẹp!<br/><br/>
🟢 Thành công/Cập nhật: <strong>${data.success}</strong> phim<br/>
🟡 Bỏ qua (không có tập mới): <strong>${data.skipped}</strong> phim<br/>
🔴 Thất bại: <strong>${data.failed}</strong> phim`,
type: 'success',
confirmText: 'Xem danh sách phim',
cancelText: 'Đóng',
onConfirm: () => {
window.location.href = '/admin/phim';
return true;
}
});
} else if (data.status === 'info') {
log(data.message, 'info');
} else if (data.status === 'warning') {
log(data.message, 'warning');
} else if (data.action === 'error') {
log(`Lỗi từ máy chủ: ${data.message}`, 'error');
}
};
// === CRAWL QUEUE RUNNER & LOCAL STORAGE CONTROL ===
const saveCrawlState = () => {
localStorage.setItem('tphimx_crawl_queue', JSON.stringify(crawlQueue));
localStorage.setItem('tphimx_crawl_index', String(crawlIndex));
localStorage.setItem('tphimx_crawl_active', String(isCrawling));
localStorage.setItem('tphimx_crawl_paused', String(isPaused));
localStorage.setItem('tphimx_crawl_stats', JSON.stringify({
success: successCount,
skipped: skipCount,
failed: failCount
}));
};
const clearCrawlState = () => {
localStorage.removeItem('tphimx_crawl_queue');
localStorage.removeItem('tphimx_crawl_index');
localStorage.removeItem('tphimx_crawl_active');
localStorage.removeItem('tphimx_crawl_paused');
localStorage.removeItem('tphimx_crawl_stats');
isCrawling = false;
isPaused = false;
crawlQueue = [];
crawlIndex = 0;
if (activeCrawlControls) activeCrawlControls.classList.add('hidden');
};
const updateTableRowStatus = (slug, status, message, name) => {
if (!progressTableBody) return;
let row = progressTableBody.querySelector(`[data-slug="${slug}"]`);
if (!row) {
row = document.createElement('tr');
row.setAttribute('data-slug', slug);
row.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]';
row.innerHTML = `
<td class="py-3 px-4 text-center select-none w-12">
<input type="checkbox" data-slug="${slug}" class="movie-select-chk rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" disabled />
</td>
<td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">*</td>
<td class="py-3 px-4 font-semibold text-white">
<div class="font-bold text-[12px] whitespace-normal line-clamp-1 truncate-slug">${name || slug}</div>
<div class="text-[10px] text-gray-500 font-mono mt-0.5">${slug}</div>
</td>
<td class="py-3 px-4">
<span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-gray-500/10 border border-gray-500/20 text-gray-400">
Chờ cào
</span>
</td>
<td class="status-detail py-3 px-4 text-gray-500 font-medium whitespace-normal text-[11px] leading-normal">Chờ xử lý</td>
`;
const moreRow = document.getElementById('more-movies-row');
if (moreRow) {
progressTableBody.insertBefore(row, moreRow);
} else {
progressTableBody.appendChild(row);
}
}
const badgeEl = row.querySelector('.status-badge');
const detailEl = row.querySelector('.status-detail');
const nameEl = row.querySelector('.font-bold');
const chk = row.querySelector('.movie-select-chk');
if (nameEl && name) {
nameEl.textContent = name;
}
if (chk) {
if (status === 'success' || status === 'skipped') {
chk.removeAttribute('disabled');
} else {
chk.setAttribute('disabled', 'true');
chk.checked = false;
}
}
if (badgeEl) {
if (status === 'processing') {
badgeEl.className = 'status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-primary/10 border border-primary/20 text-primary animate-pulse';
badgeEl.textContent = 'Đang cào';
} else if (status === 'success') {
badgeEl.className = 'status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 text-emerald-400';
badgeEl.textContent = 'Mới/Cập nhật';
} else if (status === 'skipped') {
badgeEl.className = 'status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-amber-500/10 border border-amber-500/20 text-amber-400';
badgeEl.textContent = 'Bỏ qua';
} else if (status === 'failed') {
badgeEl.className = 'status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-red-500/10 border border-red-500/20 text-red-400';
badgeEl.textContent = 'Lỗi';
}
}
if (detailEl) {
detailEl.className = status === 'processing'
? 'status-detail py-3 px-4 text-gray-500 font-medium whitespace-normal text-[11px] leading-normal'
: 'status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal';
detailEl.textContent = message;
}
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
refreshBulkActionBar();
};
const runNextInQueue = async () => {
if (!isCrawling) return;
if (isPaused) {
if (activeWorkers === 0) {
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Tạm dừng';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-amber-500/20 border border-amber-500/40 px-3 py-1 rounded-xl text-amber-400';
}
if (currentMovieName) currentMovieName.innerText = 'Đã tạm dừng tiến trình cào.';
log('Tiến trình cào phim đã được tạm dừng.', 'warning');
saveCrawlState();
}
return;
}
if (crawlIndex >= crawlQueue.length) {
if (activeWorkers === 0) {
isCrawling = false;
setButtonsState(false);
if (activeCrawlControls) activeCrawlControls.classList.add('hidden');
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Hoàn thành';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-emerald-500/20 border border-emerald-500/40 px-3 py-1 rounded-xl text-emerald-400';
}
if (currentMovieName) currentMovieName.innerText = 'Tiến trình hoàn tất!';
log('--- ĐÃ HOÀN TẤT TOÀN BỘ TIẾN TRÌNH CRAWL CHI TIẾT ---', 'success');
txamodal.show({
title: 'Thu thập hoàn tất!',
message: `Quá trình crawl đã kết thúc tốt đẹp!<br/><br/>
🟢 Thành công/Cập nhật: <strong>${successCount}</strong> phim<br/>
🟡 Bỏ qua (không có tập mới): <strong>${skipCount}</strong> phim<br/>
🔴 Thất bại: <strong>${failCount}</strong> phim`,
type: 'success',
confirmText: 'Xem danh sách phim',
cancelText: 'Đóng',
onConfirm: () => {
window.location.href = '/admin/phim';
return true;
}
});
clearCrawlState();
}
return;
}
// Claim the next item safely and atomically
const currentIndex = crawlIndex;
crawlIndex++;
saveCrawlState();
if (currentIndex >= crawlQueue.length) {
return;
}
activeWorkers++;
const item = crawlQueue[currentIndex];
if (item.skip) {
skipCount++;
if (statsSkipped) statsSkipped.innerText = String(skipCount);
log(`${item.raw || item.slug}: Tự động bỏ qua (100% không có tập mới - ${item.skipReason})`, 'skipped');
updateTableRowStatus(item.slug, 'skipped', `Bỏ qua (100% không có tập mới - ${item.skipReason})`, item.raw);
activeWorkers--;
setTimeout(runNextInQueue, 50);
return;
}
const percent = Math.round(((currentIndex + 1) / crawlQueue.length) * 100);
if (progressPercent) progressPercent.innerText = `${percent}%`;
if (progressBarInner) progressBarInner.style.width = `${percent}%`;
const displayName = item.raw || item.slug;
if (currentMovieName) currentMovieName.innerText = `[${currentIndex + 1}/${crawlQueue.length}] Đang crawl: ${displayName}`;
log(`[${currentIndex + 1}/${crawlQueue.length}] Đang cào dữ liệu chi tiết cho: ${displayName}...`, 'info');
updateTableRowStatus(item.slug, 'processing', 'Đang kết nối & tải dữ liệu...', displayName);
try {
const response = await fetch('/api/admin/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'url', urls: [item.slug] })
});
if (!response.ok) {
throw new Error(`Lỗi kết nối máy chủ (HTTP ${response.status})`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('Không thể khởi tạo luồng dữ liệu thời gian thực');
const decoder = new TextDecoder();
let buffer = '';
let isItemSuccess = false;
let isItemSkipped = false;
let itemMessage = '';
let realMovieTitle = displayName;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
const cleanPart = part.replace(/^data:\s*/, '').trim();
if (!cleanPart) continue;
try {
const data = JSON.parse(cleanPart);
if (data.action === 'result') {
itemMessage = data.message;
if (data.name) {
realMovieTitle = data.name;
}
if (data.status === 'success' || data.status === 'updated') {
isItemSuccess = true;
} else if (data.status === 'skipped') {
isItemSkipped = true;
} else if (data.status === 'failed') {
throw new Error(data.message || 'Lỗi không xác định từ API');
}
} else if (data.action === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.error('Lỗi phân tích cú pháp SSE:', e);
}
}
}
if (isItemSuccess) {
successCount++;
if (statsSuccess) statsSuccess.innerText = String(successCount);
log(`${realMovieTitle}: ${itemMessage || 'Thành công'}`, 'success');
updateTableRowStatus(item.slug, 'success', itemMessage || 'Cập nhật thành công', realMovieTitle);
} else if (isItemSkipped) {
skipCount++;
if (statsSkipped) statsSkipped.innerText = String(skipCount);
log(`${realMovieTitle}: ${itemMessage || 'Bỏ qua (đã đủ tập)'}`, 'skipped');
updateTableRowStatus(item.slug, 'skipped', itemMessage || 'Đã đầy đủ tập, không có tập mới', realMovieTitle);
} else {
throw new Error(itemMessage || 'Không nhận được trạng thái thành công hoặc bỏ qua');
}
} catch (e) {
failCount++;
if (statsFailed) statsFailed.innerText = String(failCount);
log(`${displayName}: Thất bại (${e.message})`, 'error');
updateTableRowStatus(item.slug, 'failed', `Lỗi: ${e.message}`, displayName);
}
activeWorkers--;
// Proceed after a short safety buffer to avoid rate limit bans
setTimeout(runNextInQueue, 400);
};
const startQueueCrawl = (slugsList, startIndex = 0, resumeStats = null) => {
crawlQueue = slugsList;
crawlIndex = startIndex;
isCrawling = true;
isPaused = false;
// Lock buttons visually during queue execution
setButtonsState(true);
// Hiển thị panel điều khiển active
if (activeCrawlControls) activeCrawlControls.classList.remove('hidden');
if (confirmPanel) confirmPanel.classList.add('hidden');
if (resumeStats) {
successCount = resumeStats.success || 0;
skipCount = resumeStats.skipped || 0;
failCount = resumeStats.failed || 0;
} else if (startIndex === 0) {
successCount = 0;
skipCount = 0;
failCount = 0;
}
if (statsSuccess) statsSuccess.innerText = String(successCount);
if (statsSkipped) statsSkipped.innerText = String(skipCount);
if (statsFailed) statsFailed.innerText = String(failCount);
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Đang cào';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-primary/20 border border-primary/40 px-3 py-1 rounded-xl text-primary animate-pulse';
}
log(`Bắt đầu tiến trình cào chi tiết cho ${crawlQueue.length} phim (Từ vị trí #${crawlIndex + 1}) với ${CONCURRENCY} luồng song song...`, 'info');
saveCrawlState();
activeWorkers = 0;
for (let i = 0; i < CONCURRENCY; i++) {
setTimeout(runNextInQueue, i * 200);
}
};
const checkSavedCrawlState = () => {
const savedQueueStr = localStorage.getItem('tphimx_crawl_queue');
const savedIndexStr = localStorage.getItem('tphimx_crawl_index');
const savedActiveStr = localStorage.getItem('tphimx_crawl_active');
const savedStatsStr = localStorage.getItem('tphimx_crawl_stats');
if (savedQueueStr && savedIndexStr && savedActiveStr === 'true') {
try {
const queue = JSON.parse(savedQueueStr);
const index = parseInt(savedIndexStr);
const stats = savedStatsStr ? JSON.parse(savedStatsStr) : { success: 0, skipped: 0, failed: 0 };
if (queue.length > 0 && index < queue.length) {
setTimeout(() => {
txamodal.show({
title: 'Phát hiện tiến trình bị gián đoạn',
message: `Hệ thống phát hiện tiến trình cào phim trước đó của bạn đã bị gián đoạn do tải lại trang hoặc đóng tab.<br/><br/>
📊 Trạng thái hiện tại:<br/>
- Đã xử lý: <strong>${index}/${queue.length}</strong> phim<br/>
- Thành công: <strong class="text-emerald-400">${stats.success}</strong><br/>
- Bỏ qua: <strong class="text-amber-400">${stats.skipped}</strong><br/>
- Thất bại: <strong class="text-red-400">${stats.failed}</strong><br/><br/>
Bạn có muốn tiếp tục tiến trình cào phim này không?`,
type: 'warning',
confirmText: 'Tiếp tục cào',
cancelText: 'Hủy bỏ',
onConfirm: () => {
// Populate table preview
if (progressTableBody) {
progressTableBody.innerHTML = '';
const previewList = queue.slice(0, 200);
previewList.forEach((item, idx) => {
const tr = document.createElement('tr');
tr.setAttribute('data-slug', item.slug);
tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]';
let statusText = 'Chờ cào';
let statusBadge = 'bg-gray-500/10 border border-gray-500/20 text-gray-400';
let detailText = 'Đang trong hàng đợi cào';
if (idx < index) {
statusText = 'Đã xong';
statusBadge = 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400';
detailText = 'Đã hoàn thành trước khi reload trang';
}
tr.innerHTML = `
<td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${idx + 1}</td>
<td class="py-3 px-4 font-semibold text-white">
<div class="font-bold text-[12px] whitespace-normal line-clamp-1">${item.raw || item.slug}</div>
<div class="text-[10px] text-gray-500 font-mono mt-0.5">${item.slug}</div>
</td>
<td class="py-3 px-4">
<span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${statusBadge}">
${statusText}
</span>
</td>
<td class="status-detail py-3 px-4 text-gray-500 font-medium whitespace-normal text-[11px] leading-normal">${detailText}</td>
`;
progressTableBody.appendChild(tr);
});
if (queue.length > 200) {
const tr = document.createElement('tr');
tr.id = 'more-movies-row';
tr.innerHTML = `
<td colspan="4" class="py-4 text-center text-gray-500 font-bold bg-white/[0.01]">
... và ${queue.length - 200} phim khác đang trong danh sách chờ cào
</td>
`;
progressTableBody.appendChild(tr);
}
}
if (tableCount) tableCount.textContent = `Tổng cộng: ${queue.length} phim`;
// Start crawl queue
startQueueCrawl(queue, index, stats);
return true;
},
onCancel: () => {
clearCrawlState();
return true;
}
});
}, 600);
} else {
clearCrawlState();
}
} catch (e) {
console.error(e);
clearCrawlState();
}
}
};
// Alert on navigation if crawling
window.addEventListener('beforeunload', (e) => {
if (isCrawling && !isPaused) {
e.preventDefault();
e.returnValue = 'Tiến trình cào phim đang hoạt động. Rời khỏi trang lúc này có thể làm gián đoạn tiến trình!';
return e.returnValue;
}
});
// Pause/Resume and Cancel clicks
if (pauseResumeBtn) {
pauseResumeBtn.onclick = () => {
if (!isCrawling) return;
if (isPaused) {
isPaused = false;
pauseResumeBtn.innerHTML = '<i class="fas fa-pause"></i> Tạm dừng cào';
pauseResumeBtn.className = 'flex-1 py-3.5 px-4 rounded-xl bg-amber-500/20 border border-amber-500/30 text-amber-400 hover:bg-amber-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-2 font-bold';
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Đang cào';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-primary/20 border border-primary/40 px-3 py-1 rounded-xl text-primary animate-pulse';
}
log(`Tiếp tục tiến trình cào phim với ${CONCURRENCY} luồng song song...`, 'info');
saveCrawlState();
activeWorkers = 0;
for (let i = 0; i < CONCURRENCY; i++) {
setTimeout(runNextInQueue, i * 200);
}
} else {
isPaused = true;
pauseResumeBtn.innerHTML = '<i class="fas fa-play"></i> Tiếp tục cào';
pauseResumeBtn.className = 'flex-1 py-3.5 px-4 rounded-xl bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-2 font-bold';
if (currentMovieName) currentMovieName.innerText = 'Đang tạm dừng cào...';
log('Đang thực hiện tạm dừng (tiến trình sẽ dừng sau khi phim hiện tại cào xong)...', 'warning');
saveCrawlState();
}
};
}
if (stopCrawlBtn) {
stopCrawlBtn.onclick = () => {
txamodal.show({
title: 'Xác nhận dừng tiến trình',
message: 'Bạn có chắc chắn muốn hủy bỏ hoàn toàn tiến trình cào phim hiện tại không? Tất cả các phim chưa cào trong danh sách sẽ bị bỏ qua.',
type: 'danger',
confirmText: 'Dừng tiến trình',
onConfirm: () => {
isCrawling = false;
isPaused = false;
setButtonsState(false);
if (activeCrawlControls) activeCrawlControls.classList.add('hidden');
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Đã hủy';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-gray-500/10 border border-gray-500/20 px-3 py-1 rounded-xl text-gray-400';
}
if (currentMovieName) currentMovieName.innerText = 'Tiến trình đã bị dừng bỏ.';
log('Tiến trình cào đã bị dừng bỏ bởi quản trị viên.', 'warning');
clearCrawlState();
return true;
}
});
};
}
// Cancel & Confirm Click Handlers
if (cancelCrawlBtn) {
cancelCrawlBtn.onclick = () => {
if (confirmPanel) confirmPanel.classList.add('hidden');
setButtonsState(false);
if (progressTableBody) {
progressTableBody.innerHTML = `
<tr>
<td colspan="4" class="py-8 text-center text-gray-600 font-medium">Tiến trình đã bị hủy</td>
</tr>
`;
}
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Đã hủy';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-gray-500/10 border border-gray-500/20 px-3 py-1 rounded-xl text-gray-400';
}
if (currentMovieName) {
currentMovieName.innerText = 'Tiến trình quét đã bị hủy bỏ.';
}
log('Tiến trình quét đã bị hủy bỏ bởi quản trị viên.', 'warning');
currentScrawledSlugs = [];
};
}
if (confirmCrawlBtn) {
confirmCrawlBtn.onclick = () => {
if (currentScrawledSlugs.length === 0) {
txatoast.error('Không có phim nào trong danh sách quét để crawl!');
return;
}
if (confirmPanel) confirmPanel.classList.add('hidden');
// Bắt đầu cào hàng đợi phim từ đầu
startQueueCrawl(currentScrawledSlugs, 0);
};
}
// Trigger URL Crawl
if (startUrlCrawlBtn) {
startUrlCrawlBtn.onclick = () => {
if (activeStreamReader || isCrawling) {
txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!');
return;
}
const text = textareaUrls.value;
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
if (lines.length === 0) {
txatoast.error('Vui lòng nhập ít nhất 1 URL hoặc slug phim!');
return;
}
// Reset UI states
if (statsSuccess) statsSuccess.innerText = '0';
if (statsSkipped) statsSkipped.innerText = '0';
if (statsFailed) statsFailed.innerText = '0';
if (progressPercent) progressPercent.innerText = '0%';
if (progressBarInner) progressBarInner.style.width = '0%';
if (confirmPanel) confirmPanel.classList.add('hidden');
if (bulkActionBar) bulkActionBar.classList.add('hidden');
if (selectAllChk) {
selectAllChk.setAttribute('disabled', 'true');
selectAllChk.checked = false;
}
log('Đang phân tích danh sách URL/Slug...', 'info');
currentScrawledSlugs = [];
const invalidUrls = [];
lines.forEach(line => {
const slug = extractSlug(line);
if (slug && slug.length > 2) {
currentScrawledSlugs.push({
raw: slug,
slug: slug,
exists: false
});
} else {
invalidUrls.push(line);
}
});
if (invalidUrls.length > 0) {
invalidUrls.forEach(url => {
log(`Bỏ qua URL/Slug không hợp lệ: "${url}"`, 'warning');
});
}
if (currentScrawledSlugs.length === 0) {
txatoast.error('Không tìm thấy URL hoặc slug phim hợp lệ nào!');
return;
}
log(`--- QUÉT DANH SÁCH HOÀN TẤT ---`, 'success');
log(`Đã phát hiện tổng cộng ${currentScrawledSlugs.length} phim hợp lệ.`, 'info');
if (crawlStatusBadge) {
crawlStatusBadge.innerText = 'Chờ xác nhận';
crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-amber-500/20 border border-amber-500/40 px-3 py-1 rounded-xl text-amber-400';
}
if (currentMovieName) currentMovieName.innerText = `Sẵn sàng cào ${currentScrawledSlugs.length} phim...`;
if (tableCount) tableCount.textContent = `Tổng cộng: ${currentScrawledSlugs.length} phim`;
// Populate preview lists inside table (capped at 200)
if (progressTableBody) {
progressTableBody.innerHTML = '';
const previewList = currentScrawledSlugs.slice(0, 200);
previewList.forEach((item, index) => {
const tr = document.createElement('tr');
tr.setAttribute('data-slug', item.slug);
tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]';
tr.innerHTML = `
<td class="py-3 px-4 text-center select-none w-12">
<input type="checkbox" data-slug="${item.slug}" class="movie-select-chk rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" disabled />
</td>
<td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${index + 1}</td>
<td class="py-3 px-4 font-semibold text-white">
<div class="font-bold text-[12px] whitespace-normal line-clamp-1 truncate-slug">${item.slug}</div>
<div class="text-[10px] text-gray-500 font-mono mt-0.5">${item.slug}</div>
</td>
<td class="py-3 px-4">
<span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-gray-500/10 border border-gray-500/20 text-gray-400">
Chờ cào
</span>
</td>
<td class="status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal">Chờ cào chi tiết</td>
`;
progressTableBody.appendChild(tr);
});
if (currentScrawledSlugs.length > 200) {
const tr = document.createElement('tr');
tr.id = 'more-movies-row';
tr.innerHTML = `
<td colspan="5" class="py-4 text-center text-gray-500 font-bold bg-white/[0.01]">
... và ${currentScrawledSlugs.length - 200} phim khác trong danh sách chờ cào chi tiết
</td>
`;
progressTableBody.appendChild(tr);
}
refreshBulkActionBar();
}
// Hiển thị bảng điều khiển xác nhận cào chi tiết
if (confirmPanel) {
confirmPanel.classList.remove('hidden');
}
if (confirmPanelSubtitle) {
confirmPanelSubtitle.innerHTML = `Sẵn sàng cào chi tiết <strong>${currentScrawledSlugs.length}</strong> phim đã nhập.`;
}
};
}
// Trigger Batch Crawl
if (startBatchCrawlBtn) {
startBatchCrawlBtn.onclick = () => {
if (activeStreamReader || isCrawling) {
txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!');
return;
}
const start = parseInt(pageStartInput.value);
const end = parseInt(pageEndInput.value);
if (isNaN(start) || isNaN(end) || start < 1 || end < 1) {
txatoast.error('Vui lòng nhập số trang hợp lệ!');
return;
}
if (start > end) {
txatoast.error('Trang bắt đầu không thể lớn hơn trang kết thúc!');
return;
}
txamodal.show({
title: 'Xác nhận quét danh sách hàng loạt',
message: `Bạn muốn quét tìm danh sách phim mới từ trang <strong>${start}</strong> đến trang <strong>${end}</strong> trước? Bạn có thể kiểm tra danh sách này trước khi quyết định tải chi tiết phim.`,
type: 'warning',
confirmText: 'Bắt đầu quét danh sách',
onConfirm: () => {
runCrawl({ type: 'batch', pageStart: start, pageEnd: end, scanOnly: true }, startBatchCrawlBtn);
return true;
}
});
};
}
// Trigger All Crawl
if (startAllCrawlBtn) {
startAllCrawlBtn.onclick = () => {
if (activeStreamReader || isCrawling) {
txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!');
return;
}
txamodal.show({
title: 'Xác nhận quét danh sách TOÀN BỘ',
message: 'Bạn muốn quét lấy toàn bộ danh sách phim (hơn 22.826 phim) trên API KKPhim trước? Hệ thống sẽ chỉ tải danh sách tên phim & URL để bạn phê duyệt trước khi tải chi tiết phim.',
type: 'warning',
confirmText: 'Bắt đầu quét danh sách',
onConfirm: () => {
runCrawl({ type: 'all', scanOnly: true }, startAllCrawlBtn);
return true;
}
});
};
}
// Trigger Country Crawl
if (startCountryCrawlBtn && countrySelector) {
startCountryCrawlBtn.onclick = () => {
if (activeStreamReader || isCrawling) {
txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!');
return;
}
const countrySlug = countrySelector.value;
const start = parseInt(countryPageStartInput.value);
const end = parseInt(countryPageEndInput.value);
if (isNaN(start) || isNaN(end) || start < 1 || end < 1) {
txatoast.error('Vui lòng nhập số trang hợp lệ!');
return;
}
if (start > end) {
txatoast.error('Trang bắt đầu không thể lớn hơn trang kết thúc!');
return;
}
const countryName = countrySelector.options[countrySelector.selectedIndex].text;
txamodal.show({
title: 'Xác nhận quét theo quốc gia',
message: `Bạn muốn quét tìm danh sách phim của quốc gia <strong>${countryName}</strong> từ trang <strong>${start}</strong> đến trang <strong>${end}</strong> trước? Bạn có thể kiểm tra danh sách này trước khi quyết định tải chi tiết phim.`,
type: 'warning',
confirmText: 'Bắt đầu quét danh sách',
onConfirm: () => {
runCrawl({ type: 'country', countrySlug, pageStart: start, pageEnd: end, scanOnly: true }, startCountryCrawlBtn);
return true;
}
});
};
}
// Trigger Genre Crawl
if (startGenreCrawlBtn && genreSelector) {
startGenreCrawlBtn.onclick = () => {
if (activeStreamReader || isCrawling) {
txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!');
return;
}
const genreSlug = genreSelector.value;
const start = parseInt(genrePageStartInput.value);
const end = parseInt(genrePageEndInput.value);
if (isNaN(start) || isNaN(end) || start < 1 || end < 1) {
txatoast.error('Vui lòng nhập số trang hợp lệ!');
return;
}
if (start > end) {
txatoast.error('Trang bắt đầu không thể lớn hơn trang kết thúc!');
return;
}
const genreName = genreSelector.options[genreSelector.selectedIndex].text;
txamodal.show({
title: 'Xác nhận quét theo thể loại',
message: `Bạn muốn quét tìm danh sách phim của thể loại <strong>${genreName}</strong> từ trang <strong>${start}</strong> đến trang <strong>${end}</strong> trước? Bạn có thể kiểm tra danh sách này trước khi quyết định tải chi tiết phim.`,
type: 'warning',
confirmText: 'Bắt đầu quét danh sách',
onConfirm: () => {
runCrawl({ type: 'genre', genreSlug, pageStart: start, pageEnd: end, scanOnly: true }, startGenreCrawlBtn);
return true;
}
});
};
}
// Khôi phục nếu có tiến trình cào dở dang
checkSavedCrawlState();
}
document.addEventListener('astro:page-load', init);
</script>
<style>
.glass {
background: rgba(18, 20, 29, 0.4);
backdrop-filter: blur(40px) saturate(200%);
}
#logs-console::-webkit-scrollbar {
width: 6px;
}
#logs-console::-webkit-scrollbar-track {
background: transparent;
}
#logs-console::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
#logs-console::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>