
<style>
@keyframes dqPulse { 0%,100%{opacity:1} 50%{opacity:.5} }
@keyframes dqSpin { to{transform:rotate(360deg)} }
@keyframes dqSlideDown { from{max-height:0;opacity:0} to{max-height:2000px;opacity:1} }
@keyframes dqProgressStripe {from{background-position:1rem 0}to{background-position:0 0}}
.dq-pulse{animation:dqPulse 2s ease-in-out infinite}
.dq-spin{animation:dqSpin 1s linear infinite}
.dq-slide{animation:dqSlideDown .3s ease-out forwards;overflow:hidden}
.dq-stripe{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem;animation:dqProgressStripe .5s linear infinite}
.dq-ring{width:44px;height:44px;border-radius:50%;position:relative}
.dq-ring svg{transform:rotate(-90deg)}
.dq-ring-label{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
.dq-video-thumb{width:64px;height:36px;border-radius:6px;object-fit:cover;background:#f3f4f6;flex-shrink:0}
.dq-toast{position:fixed;top:1rem;right:1rem;z-index:9999;display:flex;align-items:center;gap:.5rem;padding:.75rem 1rem;border-radius:.5rem;color:#fff;font-size:.8125rem;font-weight:500;box-shadow:0 10px 25px rgba(0,0,0,.15);transition:all .3s}
</style>


<div id="dqBanner" class="rounded-lg mb-5 overflow-hidden transition-all">
    <div id="dqBannerInner" class="px-5 py-4 flex items-center justify-between gap-4 bg-gray-50 border border-gray-200 rounded-lg">
        <div class="flex items-center gap-4 min-w-0">
            <div id="dqBannerIcon" class="w-10 h-10 rounded-xl bg-gray-200 flex items-center justify-center shrink-0">
                <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
            </div>
            <div class="min-w-0">
                <div id="dqBannerTitle" class="text-sm font-bold text-gray-600">Checking queue...</div>
                <div id="dqBannerSub" class="text-xs text-gray-400 mt-0.5"></div>
            </div>
        </div>
        <div class="flex items-center gap-2 shrink-0">
            <button type="button" id="dqPauseBtn" onclick="DQ.pauseService()" class="hidden inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold rounded-lg transition-all shadow-sm">
                <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5"/></svg>
                <span>Pause Queue</span>
            </button>
            <button type="button" id="dqResumeBtn" onclick="DQ.resumeService()" class="hidden inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-green-600 hover:bg-green-700 rounded-lg transition-all shadow-sm">
                <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"/></svg>
                <span>Resume Queue</span>
            </button>
        </div>
    </div>
</div>


<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
    <div id="dqCardDownloading" role="button" aria-label="Show active downloads" tabindex="0" onclick="DQ.setFilter('active')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();DQ.setFilter('active')}" class="bg-white rounded-lg border border-gray-200 p-3 hover:border-blue-300 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-blue-300 outline-none" title="Show active downloads">
        <div class="flex items-center gap-2.5">
            <div class="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
                <svg class="w-4.5 h-4.5 text-blue-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
            </div>
            <div>
                <div id="dqStatDownloading" class="text-lg font-bold text-gray-900 leading-tight">0</div>
                <div class="text-[11px] text-gray-500">Downloading</div>
            </div>
        </div>
    </div>
    <div id="dqCardPending" role="button" aria-label="Show active downloads" tabindex="0" onclick="DQ.setFilter('active')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();DQ.setFilter('active')}" class="bg-white rounded-lg border border-gray-200 p-3 hover:border-amber-300 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-amber-300 outline-none" title="Show active downloads">
        <div class="flex items-center gap-2.5">
            <div class="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center shrink-0">
                <svg class="w-4.5 h-4.5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
            </div>
            <div>
                <div id="dqStatPending" class="text-lg font-bold text-gray-900 leading-tight">0</div>
                <div class="text-[11px] text-gray-500">Pending</div>
            </div>
        </div>
    </div>
    <div id="dqCardCompleted" role="button" aria-label="Show completed downloads" tabindex="0" onclick="DQ.setFilter('completed')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();DQ.setFilter('completed')}" class="bg-white rounded-lg border border-gray-200 p-3 hover:border-green-300 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-green-300 outline-none" title="Show completed downloads">
        <div class="flex items-center gap-2.5">
            <div class="w-9 h-9 rounded-lg bg-green-50 flex items-center justify-center shrink-0">
                <svg class="w-4.5 h-4.5 text-green-600" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
            </div>
            <div>
                <div id="dqStatCompleted" class="text-lg font-bold text-gray-900 leading-tight">0</div>
                <div class="text-[11px] text-gray-500">Completed</div>
            </div>
        </div>
    </div>
    <div id="dqCardFailed" role="button" aria-label="Show failed downloads" tabindex="0" onclick="DQ.setFilter('failed')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();DQ.setFilter('failed')}" class="bg-white rounded-lg border border-gray-200 p-3 hover:border-red-300 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-red-300 outline-none" title="Show failed downloads">
        <div class="flex items-center gap-2.5">
            <div class="w-9 h-9 rounded-lg bg-red-50 flex items-center justify-center shrink-0">
                <svg class="w-4.5 h-4.5 text-red-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
            </div>
            <div>
                <div id="dqStatFailed" class="text-lg font-bold text-gray-900 leading-tight">0</div>
                <div class="text-[11px] text-gray-500">Failed</div>
            </div>
        </div>
    </div>
</div>


<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-4">
    <div class="px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
        <div class="flex items-center gap-3 flex-wrap">
            <nav class="flex gap-0 -mb-px">
                <button onclick="DQ.setFilter('all')" id="dqFilterAll" class="dq-tab px-3 py-1.5 text-xs font-medium rounded-l-lg border border-gray-200 bg-primary-50 text-primary-700 border-primary-200">All</button>
                <button onclick="DQ.setFilter('active')" id="dqFilterActive" class="dq-tab px-3 py-1.5 text-xs font-medium border-t border-b border-r border-gray-200 text-gray-500 hover:bg-gray-50">Active</button>
                <button onclick="DQ.setFilter('completed')" id="dqFilterCompleted" class="dq-tab px-3 py-1.5 text-xs font-medium border-t border-b border-r border-gray-200 text-gray-500 hover:bg-gray-50">Completed</button>
                <button onclick="DQ.setFilter('failed')" id="dqFilterFailed" class="dq-tab px-3 py-1.5 text-xs font-medium border-t border-b border-r border-gray-200 text-gray-500 hover:bg-gray-50">Failed</button>
                <button onclick="DQ.setFilter('cancelled')" id="dqFilterCancelled" class="dq-tab px-3 py-1.5 text-xs font-medium rounded-r-lg border-t border-b border-r border-gray-200 text-gray-500 hover:bg-gray-50">Cancelled</button>
            </nav>
            <div class="relative">
                <svg class="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
                <input type="text" id="dqSearch" placeholder="Search..." class="pl-8 pr-3 py-1.5 text-xs border border-gray-300 rounded-lg w-44 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" oninput="DQ.onSearch()">
            </div>
            <span id="dqResultCount" class="text-[11px] text-gray-400 hidden sm:inline"></span>
        </div>
        <div class="flex items-center gap-2 flex-wrap">
            <?php $_dqWorkers = max(1, min(3, (int) Setting::get('catalogue_max_workers', '3'))); ?>
            <div class="flex items-center gap-1.5 mr-1" title="How many videos download in parallel (max 3). Higher = faster but more bandwidth/CPU. Each download runs independently, is crash-safe and resumes on its own.">
                <span class="text-[11px] font-medium text-gray-500 inline-flex items-center gap-1"><svg class="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>Parallel</span>
                <div id="dqWorkerSel" role="group" aria-label="Parallel downloads" class="inline-flex rounded-lg border border-gray-200 overflow-hidden">
                    <?php for ($w = 1; $w <= 3; $w++): ?>
                    <button type="button" data-w="<?= $w ?>" onclick="DQ.setWorkers(<?= $w ?>)" aria-pressed="<?= $w === $_dqWorkers ? 'true' : 'false' ?>" class="px-2.5 py-1 text-xs font-semibold focus-visible:ring-2 focus-visible:ring-primary-400 outline-none <?= $w > 1 ? 'border-l border-gray-200 ' : '' ?><?= $w === $_dqWorkers ? 'bg-primary-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50' ?>"><?= $w ?></button>
                    <?php endfor; ?>
                </div>
            </div>
            <button type="button" id="dqCancelAllBtn" class="hidden inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-amber-400 outline-none" onclick="DQ.cancelAll()" title="Cancel every pending download">
                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
                <span class="dq-btn-label">Cancel all pending</span>
            </button>
            <button type="button" id="dqRetryAllBtn" class="hidden inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-primary-700 bg-primary-50 hover:bg-primary-100 border border-primary-200 rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-primary-400 outline-none" onclick="DQ.retryAllFailed()">
                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"/></svg>
                <span class="dq-btn-label">Retry all failed</span>
            </button>
            <button type="button" id="dqClearFinishedBtn" class="hidden inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-gray-500 bg-white hover:bg-gray-50 hover:text-red-600 border border-gray-200 rounded-lg transition-colors focus-visible:ring-2 focus-visible:ring-gray-300 outline-none" onclick="DQ.clearFinished()" title="Remove completed, failed and cancelled entries from the list (published videos are kept)">
                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
                <span class="dq-btn-label">Clear finished</span>
            </button>
        </div>
    </div>
</div>


<div id="dqImportJobs" class="hidden mb-5">
    <div class="flex items-center gap-2.5 mb-3">
        <div class="w-8 h-8 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0">
            <svg class="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
        </div>
        <div>
            <h3 class="text-sm font-bold text-gray-900">CSV / JSON Imports</h3>
            <p id="dqImportSummary" class="text-xs text-gray-500"></p>
        </div>
    </div>
    <div id="dqImportList" class="space-y-2"></div>
</div>


<div id="dqBatchContainer">
    <div class="bg-white rounded-lg border border-gray-200 text-center py-16 text-gray-400 text-sm">
        <svg class="dq-spin h-6 w-6 mx-auto mb-2 text-gray-300" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
        Loading downloads...
    </div>
</div>


<div id="dqEmpty" class="hidden">
    <div class="bg-white rounded-lg shadow-sm border border-gray-200 text-center py-20 px-6">
        <div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-100 flex items-center justify-center">
            <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
        </div>
        <h3 class="text-base font-semibold text-gray-900 mb-1">No downloads yet</h3>
        <p class="text-sm text-gray-500 mb-5 max-w-xs mx-auto">Purchase videos from the catalogue or import CSV/JSON files to get started.</p>
        <div class="flex items-center justify-center gap-3">
            <a href="/admin/import" class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors no-underline shadow-sm">
                <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
                Import Videos
            </a>
        </div>
    </div>
</div>

<script>
window.DQ = (function() {
    const CSRF = '<?= Router::csrfToken() ?>';
    let pollTimer = null, allDownloads = [], allBatches = [];
    let currentFilter = 'all', searchQuery = '', searchTimeout = null;
    let actionInFlight = false, firstLoad = true, lastStats = {};
    let servicePaused = false, pauseReason = null, netFailCount = 0;
    let lastRenderFP = '', lastBannerState = '';
    const manualExpanded = new Set(), manualCollapsed = new Set();
    const batchSpeedState = new Map(), confirmTimers = new Map();

    // ── Import Jobs state ──
    let importJobs = [], importPollTimer = null;

    // ── Init ──
    function init() {
        pollDownloads();
        pollImportJobs();
        startPolling();
        startImportPolling();
        document.addEventListener('visibilitychange', () => {
            if (document.hidden) { stopPolling(); stopImportPolling(); }
            else { pollDownloads(); pollImportJobs(); startPolling(); startImportPolling(); }
        });
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') { const m = el('dqDetailModal'); if (m && m.style.display !== 'none') closeDetail(); }
        });
    }

    // ── Polling ──
    function startPolling() { if (pollTimer) return; pollTimer = setInterval(pollDownloads, getInterval()); }
    function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
    function getInterval() {
        const d = parseInt(el('dqStatDownloading')?.textContent) || 0;
        const p = parseInt(el('dqStatPending')?.textContent) || 0;
        return (d + p > 0) ? 3000 : 10000;
    }

    async function pollDownloads() {
        try {
            const params = new URLSearchParams({ limit: '200' });
            if (searchQuery) params.set('search', searchQuery);
            const resp = await fetch('/admin/catalogue/downloads?' + params);
            const data = await resp.json();
            allDownloads = data.downloads || [];
            allBatches = data.batches || [];
            servicePaused = !!data.service_paused;
            pauseReason = data.pause_reason || null;
            netFailCount = data.net_fail_count || 0;
            const stats = data.stats || {};
            updateStats(stats);
            updateBanner(stats);
            renderBatches();
            // Adjust interval
            const hasActive = (stats.downloading||0) + (stats.pending||0) > 0;
            const newInt = hasActive ? 3000 : 10000;
            stopPolling();
            pollTimer = setInterval(pollDownloads, newInt);
            if (!hasActive) cleanupSpeed();
            firstLoad = false;
        } catch(e) {}
    }

    function updateStats(s) {
        setText('dqStatDownloading', (s.downloading||0).toLocaleString());
        setText('dqStatPending', (s.pending||0).toLocaleString());
        setText('dqStatCompleted', (s.completed||0).toLocaleString());
        setText('dqStatFailed', (s.failed||0).toLocaleString());
        lastStats = s || {};
        toggleHidden('dqRetryAllBtn', !s.failed);
        toggleHidden('dqCancelAllBtn', !((s.downloading||0)+(s.pending||0)));
        const fin = (s.completed||0)+(s.failed||0)+(s.cancelled||0);
        toggleHidden('dqClearFinishedBtn', !fin);
        const cfb = el('dqClearFinishedBtn'); if (cfb) { const lbl = cfb.querySelector('.dq-btn-label'); if (lbl) lbl.textContent = 'Clear finished' + (fin>0 ? ' ('+fin.toLocaleString()+')' : ''); }
    }

    // ── Banner ──
    function updateBanner(stats) {
        const banner = el('dqBannerInner');
        const icon = el('dqBannerIcon');
        const title = el('dqBannerTitle');
        const sub = el('dqBannerSub');
        const pauseBtn = el('dqPauseBtn');
        const resumeBtn = el('dqResumeBtn');
        if (!banner) return;
        const hasActive = (stats.downloading||0) + (stats.pending||0) > 0;

        // Skip banner DOM update if state hasn't changed
        const bs = (servicePaused?pauseReason:'') + ':' + hasActive + ':' + (stats.downloading||0) + ':' + (stats.pending||0) + ':' + netFailCount;
        if (bs === lastBannerState && !firstLoad) return;
        lastBannerState = bs;

        pauseBtn.disabled = false;
        resumeBtn.disabled = false;
        pauseBtn.querySelector('span').textContent = 'Pause Queue';
        resumeBtn.querySelector('span').textContent = 'Resume Queue';

        if (servicePaused && pauseReason === 'network') {
            banner.className = 'px-5 py-4 flex items-center justify-between gap-4 bg-gradient-to-r from-red-50 to-red-100 border border-red-200 rounded-lg';
            icon.className = 'w-10 h-10 rounded-xl bg-red-100 flex items-center justify-center shrink-0';
            icon.innerHTML = '<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>';
            title.className = 'text-sm font-bold text-red-800';
            title.textContent = 'Auto-paused — server unreachable';
            sub.className = 'text-xs text-red-600 mt-0.5';
            sub.textContent = netFailCount + ' consecutive failure' + (netFailCount !== 1 ? 's' : '') + ' detected';
            pauseBtn.classList.add('hidden');
            resumeBtn.classList.remove('hidden');
        } else if (servicePaused) {
            banner.className = 'px-5 py-4 flex items-center justify-between gap-4 bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-lg';
            icon.className = 'w-10 h-10 rounded-xl bg-amber-100 flex items-center justify-center shrink-0';
            icon.innerHTML = '<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5"/></svg>';
            title.className = 'text-sm font-bold text-amber-800';
            title.textContent = 'Queue paused';
            sub.className = 'text-xs text-amber-600 mt-0.5';
            sub.textContent = hasActive ? (stats.pending||0) + ' video' + ((stats.pending||0)!==1?'s':'') + ' waiting to download' : 'No pending downloads';
            pauseBtn.classList.add('hidden');
            resumeBtn.classList.remove('hidden');
        } else if (hasActive) {
            banner.className = 'px-5 py-4 flex items-center justify-between gap-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg';
            icon.className = 'w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center shrink-0';
            icon.innerHTML = '<svg class="w-5 h-5 text-green-600 dq-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>';
            title.className = 'text-sm font-bold text-green-800';
            title.textContent = 'Downloading ' + (stats.downloading||0) + ' video' + ((stats.downloading||0)!==1?'s':'');
            sub.className = 'text-xs text-green-600 mt-0.5';
            sub.textContent = (stats.pending||0) + ' in queue';
            pauseBtn.classList.remove('hidden');
            pauseBtn.className = 'inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-amber-700 bg-white hover:bg-amber-50 border border-amber-200 rounded-lg transition-all shadow-sm';
            resumeBtn.classList.add('hidden');
        } else {
            banner.className = 'px-5 py-4 flex items-center justify-between gap-4 bg-gray-50 border border-gray-200 rounded-lg';
            icon.className = 'w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center shrink-0';
            icon.innerHTML = '<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>';
            title.className = 'text-sm font-bold text-gray-500';
            title.textContent = 'Queue idle';
            sub.className = 'text-xs text-gray-400 mt-0.5';
            sub.textContent = 'No pending downloads';
            pauseBtn.classList.add('hidden');
            resumeBtn.classList.add('hidden');
        }
    }

    // ── Pause / Resume ──
    async function pauseService() {
        const b = el('dqPauseBtn'); b.disabled=true; b.querySelector('span').textContent='Pausing...';
        try { await postJSON('/admin/catalogue/pause-service',{}); toast('Queue paused','info'); await pollDownloads(); }
        catch(e) { toast('Failed to pause','error'); }
    }
    async function resumeService() {
        const b = el('dqResumeBtn'); b.disabled=true; b.querySelector('span').textContent='Resuming...';
        try { await postJSON('/admin/catalogue/resume-service',{}); toast('Queue resumed — processing will start shortly','success'); await pollDownloads(); }
        catch(e) { toast('Failed to resume','error'); }
    }

    // ── Filter / Search ──
    function setFilter(f) {
        currentFilter = f;
        document.querySelectorAll('.dq-tab').forEach(t => {
            const active = t.id === 'dqFilter' + f.charAt(0).toUpperCase() + f.slice(1);
            t.classList.toggle('bg-primary-50', active);
            t.classList.toggle('text-primary-700', active);
            t.classList.toggle('border-primary-200', active);
            t.classList.toggle('text-gray-500', !active);
            t.classList.toggle('hover:bg-gray-50', !active);
        });
        renderBatches();
    }
    function onSearch() {
        clearTimeout(searchTimeout);
        searchTimeout = setTimeout(() => { searchQuery = el('dqSearch').value.trim(); pollDownloads(); }, 300);
    }
    function filterDl(downloads) {
        if (currentFilter === 'all') return downloads;
        if (currentFilter === 'active') return downloads.filter(d => d.status==='downloading'||d.status==='pending');
        return downloads.filter(d => d.status === currentFilter);
    }

    // ── Batch Grouping ──
    function groupByBatch(downloads) {
        const groups = new Map(), meta = new Map();
        allBatches.forEach(b => meta.set(b.batch_id, b));
        downloads.forEach(d => { const bid = d.batch_id||'__none__'; if (!groups.has(bid)) groups.set(bid,[]); groups.get(bid).push(d); });
        const order = allBatches.map(b => b.batch_id);
        groups.forEach((_,bid) => { if (!order.includes(bid)) order.push(bid); });
        // Sort: active (downloading/pending) batches always first
        order.sort((a,b) => {
            const ma = meta.get(a)||{}, mb = meta.get(b)||{};
            const aActive = (int(ma.downloading)+int(ma.pending)) > 0 ? 1 : 0;
            const bActive = (int(mb.downloading)+int(mb.pending)) > 0 ? 1 : 0;
            if (aActive !== bActive) return bActive - aActive;
            return 0; // preserve original order within same activity status
        });
        return order.map(bid => {
            const items = groups.get(bid);
            if (!items||!items.length) return null;
            const m = meta.get(bid)||{};
            return {
                batch_id:bid, items,
                total:int(m.total||items.length), completed:int(m.completed), failed:int(m.failed),
                downloading:int(m.downloading), pending:int(m.pending), cancelled:int(m.cancelled), active:int(m.active),
                total_size:int(m.total_size), total_bytes_downloaded:int(m.total_bytes_downloaded), total_cost:int(m.total_cost),
                source_site:m.source_site||items[0]?.source_site||'', created_at:m.created_at||items[0]?.created_at||''
            };
        }).filter(Boolean);
    }

    // ── Rendering ──
    function renderBatches() {
        const container = el('dqBatchContainer'), empty = el('dqEmpty'), countEl = el('dqResultCount');
        const filtered = filterDl(allDownloads), batches = groupByBatch(filtered);
        countEl.textContent = filtered.length + ' download' + (filtered.length!==1?'s':'');

        if (!allDownloads.length && !importJobs.length && currentFilter==='all' && !searchQuery) {
            container.classList.add('hidden'); empty.classList.remove('hidden'); lastRenderFP=''; return;
        }
        empty.classList.add('hidden'); container.classList.remove('hidden');
        if (!batches.length) {
            const msg = searchQuery ? 'No results for "'+esc(searchQuery)+'"' : 'No '+currentFilter+' downloads';
            if (lastRenderFP !== 'empty:'+msg) {
                lastRenderFP = 'empty:'+msg;
                container.innerHTML = '<div class="bg-white rounded-lg shadow-sm border border-gray-200 text-center py-12 text-gray-400 text-sm">'+msg+'</div>';
            }
            return;
        }

        // Fingerprint: skip DOM rebuild if nothing meaningful changed
        const fp = currentFilter + '|' + filtered.map(d => d.id+d.status+(d.bytes_downloaded||0)+(d.ai_status||'')+(d.ai_pipeline_step||0)+(d.error_message||'')).join(',')
            + '|' + allBatches.map(b => b.batch_id+b.completed+b.failed+b.active+b.downloading+b.total_bytes_downloaded).join(',')
            + '|' + [...manualExpanded].join(',') + '|' + [...manualCollapsed].join(',');
        if (fp === lastRenderFP && !firstLoad) return;
        lastRenderFP = fp;

        // Preserve scroll position across re-renders
        const scrollY = window.scrollY;
        container.innerHTML = batches.map(renderBatch).join('');
        requestAnimationFrame(() => window.scrollTo(0, scrollY));
    }

    function renderBatch(b) {
        const bid = b.batch_id, expanded = shouldExpand(b), state = batchState(b);
        const borderMap = {active:'border-blue-200',['has-failures']:'border-red-200',cancelled:'border-gray-100',completed:'border-gray-200'};
        const border = borderMap[state]||'border-gray-200';
        const pct = b.total > 0 ? Math.round(b.completed / b.total * 100) : 0;

        // Progress ring SVG
        const ringR = 18, ringC = 2*Math.PI*ringR;
        const ringOffset = ringC - (pct/100)*ringC;
        const ringColor = state==='active'?'#3b82f6':state==='has-failures'?'#ef4444':pct===100?'#22c55e':'#d1d5db';
        const ringBg = state==='active'?'#dbeafe':state==='has-failures'?'#fee2e2':pct===100?'#dcfce7':'#f3f4f6';

        // Speed / ETA
        let speedText='', etaText='';
        if (b.active > 0) {
            const speed = computeSpeed(bid, b.total_bytes_downloaded);
            if (speed > 0) { speedText = fmtBytes(speed)+'/s'; }
            if (speed > 0 && b.total_size > b.total_bytes_downloaded) { etaText = fmtEta(Math.round((b.total_size-b.total_bytes_downloaded)/speed)); }
        }

        // Striped progress bar for active
        let progressBar = '';
        if (b.active > 0) {
            progressBar = `<div class="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden"><div class="h-full bg-blue-500 dq-stripe rounded-full transition-all duration-700" style="width:${pct}%"></div></div>`;
        } else if (pct === 100) {
            progressBar = `<div class="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden"><div class="h-full bg-green-500 rounded-full" style="width:100%"></div></div>`;
        }

        // Status chips
        const chips = [];
        if (b.downloading>0) chips.push(`<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-blue-100 text-blue-700"><span class="w-1.5 h-1.5 rounded-full bg-blue-500 dq-pulse"></span>${b.downloading.toLocaleString()} downloading</span>`);
        if (b.pending>0) chips.push(`<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-100 text-amber-700">${b.pending.toLocaleString()} pending</span>`);
        if (b.completed>0) chips.push(`<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-green-100 text-green-700">${b.completed.toLocaleString()} done</span>`);
        if (b.failed>0) chips.push(`<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-red-100 text-red-700">${b.failed.toLocaleString()} failed</span>`);
        if (b.cancelled>0) chips.push(`<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-gray-100 text-gray-700">${b.cancelled.toLocaleString()} cancelled</span>`);

        // Actions
        let actions = '';
        const realBatch = bid && bid !== '__none__';
        if ((b.downloading>0 || b.pending>0) && realBatch) actions += `<button aria-label="Cancel pending downloads in this batch" onclick="event.stopPropagation();DQ.cancelBatch('${eAttr(bid)}')" class="px-2 py-1 text-[11px] font-medium rounded border bg-white text-amber-600 border-amber-200 hover:bg-amber-50 transition-colors focus-visible:ring-2 focus-visible:ring-amber-300 outline-none"><span class="dq-btn-label">Cancel pending</span></button>`;
        if (b.failed>0 && realBatch) actions += `<button aria-label="Retry failed downloads in this batch" onclick="event.stopPropagation();DQ.retryBatch('${eAttr(bid)}')" class="px-2 py-1 text-[11px] font-medium rounded border bg-white text-primary-600 border-primary-200 hover:bg-primary-50 transition-colors focus-visible:ring-2 focus-visible:ring-primary-300 outline-none"><span class="dq-btn-label">Retry failed</span></button>`;
        if (realBatch && b.downloading===0 && b.pending===0) actions += `<button aria-label="Remove this batch from the list" title="Remove these finished entries from the list (published videos are kept)" onclick="event.stopPropagation();DQ.deleteBatch('${eAttr(bid)}')" class="px-2 py-1 text-[11px] font-medium rounded border bg-white text-gray-400 border-gray-200 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors focus-visible:ring-2 focus-visible:ring-gray-300 outline-none"><span class="dq-btn-label">Clear</span></button>`;

        // Expanded body
        let body = '';
        if (expanded) {
            const MAX_ROWS = 50;
            const visible = b.items.slice(0, MAX_ROWS);
            const hidden = b.items.length - visible.length;
            let moreRow = '';
            if (hidden > 0) {
                moreRow = `<tr><td colspan="4" class="px-4 py-3 text-center text-[11px] text-gray-400 bg-gray-50">
                    Showing ${MAX_ROWS} of ${b.items.length} items in this view &middot; ${hidden} more not displayed
                    ${b.total > b.items.length ? ' &middot; <span class="font-medium text-gray-500">' + (b.total - b.items.length).toLocaleString() + ' in other pages</span>' : ''}
                </td></tr>`;
            } else if (b.total > b.items.length) {
                moreRow = `<tr><td colspan="4" class="px-4 py-2.5 text-center text-[11px] text-gray-400 bg-gray-50">
                    ${b.items.length} shown &middot; <span class="font-medium text-gray-500">${(b.total - b.items.length).toLocaleString()} more in batch</span>
                </td></tr>`;
            }
            body = `<div class="border-t border-gray-100">
                <div class="overflow-x-auto"><table class="w-full">
                <thead><tr class="bg-gray-50"><th class="px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-gray-500 w-[45%]">Video</th><th class="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-gray-500 hide-mobile">Progress</th><th class="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-gray-500 hide-mobile">Size</th><th class="px-3 py-2 text-right text-[11px] font-semibold uppercase tracking-wider text-gray-500 w-20">Action</th></tr></thead>
                <tbody class="divide-y divide-gray-100">${visible.map(renderRow).join('')}${moreRow}</tbody>
                </table></div></div>`;
        }

        const chevron = expanded
            ? '<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>'
            : '<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>';

        return `<div class="bg-white rounded-lg shadow-sm border ${border} mb-3 overflow-hidden ${state==='cancelled'?'opacity-50':''}" data-batch="${eAttr(bid)}">
            <div class="px-4 py-3.5 cursor-pointer select-none hover:bg-gray-50/50 transition-colors" onclick="DQ.toggleBatch('${eAttr(bid)}')">
                <div class="flex items-center gap-4">
                    <div class="dq-ring shrink-0">
                        <svg width="44" height="44"><circle cx="22" cy="22" r="${ringR}" fill="none" stroke="${ringBg}" stroke-width="4"/><circle cx="22" cy="22" r="${ringR}" fill="none" stroke="${ringColor}" stroke-width="4" stroke-dasharray="${ringC}" stroke-dashoffset="${ringOffset}" stroke-linecap="round"/></svg>
                        <span class="dq-ring-label" style="color:${ringColor}">${pct}%</span>
                    </div>
                    <div class="flex-1 min-w-0">
                        <div class="flex items-center gap-2 mb-1">
                            ${chevron}
                            <span class="text-sm font-semibold text-gray-900">${b.total.toLocaleString()} video${b.total!==1?'s':''}</span>
                            <span class="text-[11px] text-gray-400">${b.source_site?esc(b.source_site)+' &middot; ':''}${timeAgo(b.created_at)}${b.total_cost>0?' &middot; <span class="font-medium text-gray-500">'+b.total_cost.toLocaleString()+' credit'+(b.total_cost!==1?'s':'')+'</span>':''}</span>
                        </div>
                        <div class="flex items-center gap-1.5 flex-wrap">${chips.join('')}
                            ${speedText?'<span class="text-[10px] text-gray-400 hide-mobile">'+speedText+'</span>':''}
                            ${etaText?'<span class="text-[10px] font-medium text-gray-500 hide-mobile">ETA '+etaText+'</span>':''}
                            ${b.total_size>0&&b.active===0?'<span class="text-[10px] text-gray-400 hide-mobile">'+fmtBytes(b.total_size)+'</span>':''}
                        </div>
                        ${progressBar}
                    </div>
                    <div class="flex items-center gap-1.5 shrink-0">${actions}</div>
                </div>
            </div>
            ${body}
        </div>`;
    }

    function renderRow(d) {
        const statusIcons = {
            downloading:'<div class="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center shrink-0"><svg class="w-3 h-3 text-blue-600 dq-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg></div>',
            pending:'<div class="w-5 h-5 rounded-full bg-amber-100 flex items-center justify-center shrink-0"><svg class="w-3 h-3 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5"/></svg></div>',
            completed:'<div class="w-5 h-5 rounded-full bg-green-100 flex items-center justify-center shrink-0"><svg class="w-3 h-3 text-green-600" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg></div>',
            failed:'<div class="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center shrink-0"><svg class="w-3 h-3 text-red-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></div>',
            cancelled:'<div class="w-5 h-5 rounded-full bg-gray-100 flex items-center justify-center shrink-0"><svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg></div>'
        };
        const icon = statusIcons[d.status]||statusIcons.pending;
        const rowBg = d.status==='downloading'?'bg-blue-50/30':d.status==='failed'?'bg-red-50/20':'';

        // AI badge — show step name instead of "AI 1/3"
        let ai = '';
        if (d.ai_status && d.ai_status!=='none') {
            if (d.ai_status==='processing') {
                const lbl = aiStepLabel(d);
                ai = `<span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[9px] font-bold rounded bg-violet-100 text-violet-700"><svg class="w-2.5 h-2.5 dq-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>${esc(lbl)}</span>`;
            }
            else if (d.ai_status==='completed') ai = '<span class="px-1.5 py-0.5 text-[9px] font-bold rounded bg-violet-50 text-violet-500">AI done</span>';
            else if (d.ai_status==='pending') ai = '<span class="px-1.5 py-0.5 text-[9px] font-bold rounded bg-gray-50 text-gray-400">AI queued</span>';
        }

        // Thumb
        const thumb = d.thumbnail_url ? `<img loading="lazy" src="${eAttr(d.thumbnail_url)}" class="dq-video-thumb" onerror="this.style.display='none'" alt="">` : '<div class="dq-video-thumb bg-gray-100"></div>';

        // Progress
        let prog = '';
        if (d.status==='downloading') {
            if (d.file_size>0 && d.bytes_downloaded>0) {
                const p = Math.min(100,Math.round(d.bytes_downloaded/d.file_size*100));
                prog = `<div class="flex items-center gap-2"><div class="w-20 bg-gray-200 rounded-full h-1.5"><div class="bg-blue-500 h-1.5 rounded-full dq-stripe transition-all" style="width:${p}%"></div></div><span class="text-[10px] font-semibold text-blue-700">${p}%</span></div>`;
            } else { prog = '<div class="flex items-center gap-2"><div class="w-20 bg-gray-200 rounded-full h-1.5"><div class="bg-blue-500 h-1.5 rounded-full dq-pulse" style="width:30%"></div></div></div>'; }
        } else if (d.status==='completed') { prog = '<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-100 text-green-800">Completed</span>'; }
        else if (d.status==='failed') { prog = '<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-red-100 text-red-700">Failed</span>'; }
        else if (d.status==='pending') { prog = '<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-600">Queued</span>'; }
        else { prog = '<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-700">Cancelled</span>'; }

        // Size
        let sz = '';
        if (d.file_size>0) { sz = d.status==='downloading'&&d.bytes_downloaded>0 ? fmtBytes(d.bytes_downloaded)+'/'+fmtBytes(d.file_size) : fmtBytes(d.file_size); }

        // Action(s) — every state gets a sensible affordance
        let act = '';
        if (d.status==='pending' || d.status==='downloading') { act += `<button onclick="DQ.cancelDownload(${d.id},${d.status==='downloading'?'true':'false'})" aria-label="Cancel this download" title="Cancel" class="px-2 py-1 text-[11px] font-medium rounded border bg-white text-amber-600 border-amber-200 hover:bg-amber-50 transition-colors focus-visible:ring-2 focus-visible:ring-amber-300 outline-none">Cancel</button>`; }
        if (d.status==='completed' && d.video_id) { act += `<a href="/admin/videos/${d.video_id}/edit" class="px-2 py-1 text-[11px] font-medium rounded border bg-white text-gray-600 border-gray-300 hover:bg-gray-50 no-underline focus-visible:ring-2 focus-visible:ring-gray-300 outline-none">Edit</a>`; }
        if (d.status==='failed') { act += `<button onclick="DQ.retryDownload(${d.id})" class="px-2 py-1 text-[11px] font-medium rounded border bg-white text-primary-600 border-primary-200 hover:bg-primary-50 focus-visible:ring-2 focus-visible:ring-primary-300 outline-none">Retry</button>`; }
        if (d.status==='completed' || d.status==='failed' || d.status==='cancelled') { act += `<button onclick="DQ.deleteDownload(${d.id})" aria-label="Remove from list" title="Remove from list (a published video is kept)" class="px-2 py-1 text-[11px] font-medium rounded border bg-white text-gray-400 border-gray-200 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors focus-visible:ring-2 focus-visible:ring-gray-300 outline-none"><span class="dq-btn-label">Remove</span></button>`; }

        let errRow = '';
        if (d.status==='failed' && d.error_message) { errRow = `<tr class="${rowBg}"><td colspan="4" class="px-4 pl-14 py-1.5 text-[11px] text-red-500 leading-relaxed">${esc(d.error_message)}</td></tr>`; }

        return `<tr class="${rowBg} hover:bg-gray-50 transition-colors">
            <td class="px-4 py-2">
                <div class="flex items-center gap-3 min-w-0">
                    ${icon}
                    ${thumb}
                    <div class="min-w-0 flex-1">
                        <button onclick="DQ.showDetail(${d.id})" class="text-xs font-medium text-gray-900 truncate block text-left hover:text-primary-600 transition-colors w-full" title="${eAttr(d.title)}">${esc(d.title)}</button>
                        <div class="flex items-center gap-1.5 mt-0.5">${fmtQuality(d.quality,d.src_height)?'<span class="text-[10px] text-gray-400">'+fmtQuality(d.quality,d.src_height)+'</span>':''}${ai}</div>
                    </div>
                </div>
            </td>
            <td class="px-3 py-2 hide-mobile">${prog}</td>
            <td class="px-3 py-2 hide-mobile"><span class="text-[11px] text-gray-500">${sz}</span></td>
            <td class="px-3 py-2 text-right"><div class="inline-flex items-center gap-1 justify-end">${act}</div></td>
        </tr>${errRow}`;
    }

    // ── Batch helpers ──
    function batchState(b) { if (b.downloading>0||b.pending>0) return 'active'; if (b.failed>0) return 'has-failures'; if (b.cancelled===b.total) return 'cancelled'; return 'completed'; }
    function shouldExpand(b) { const bid=b.batch_id; if (manualExpanded.has(bid)) return true; if (manualCollapsed.has(bid)) return false; const s=batchState(b); return s==='active'||s==='has-failures'; }
    function toggleBatch(bid) {
        if (manualExpanded.has(bid)) { manualExpanded.delete(bid); manualCollapsed.add(bid); }
        else if (manualCollapsed.has(bid)) { manualCollapsed.delete(bid); manualExpanded.add(bid); }
        else { const e=document.querySelector('[data-batch="'+bid+'"]'); if(e&&e.querySelector('table'))manualCollapsed.add(bid); else manualExpanded.add(bid); }
        renderBatches();
    }

    // ── Speed ──
    function computeSpeed(bid,bytes) { const n=Date.now();let s=batchSpeedState.get(bid);if(!s){s={p:bytes,t:n,s:0};batchSpeedState.set(bid,s);return 0;}if(bytes>s.p&&n>s.t){const e=(n-s.t)/1000;if(e>.5){const r=(bytes-s.p)/e;s.s=s.s>0?s.s*.7+r*.3:r;s.p=bytes;s.t=n;}}return s.s; }
    function cleanupSpeed() { const ids=new Set(allBatches.filter(b=>int(b.active)>0).map(b=>b.batch_id));batchSpeedState.forEach((_,bid)=>{if(!ids.has(bid))batchSpeedState.delete(bid);}); }

    // ── Confirm ──
    function confirmAction(btn,action,arg) {
        const key=action+(arg||''), lbl=btn.querySelector('.dq-btn-label')||btn, orig=lbl.dataset.orig||lbl.textContent;
        if (confirmTimers.has(key)) { clearTimeout(confirmTimers.get(key)); confirmTimers.delete(key); lbl.textContent=orig; lbl.dataset.orig=''; btn.classList.remove('ring-2','ring-offset-1'); execAction(action,arg); return; }
        lbl.dataset.orig=orig; lbl.textContent='Confirm?'; btn.classList.add('ring-2','ring-offset-1');
        confirmTimers.set(key, setTimeout(()=>{lbl.textContent=orig;lbl.dataset.orig='';btn.classList.remove('ring-2','ring-offset-1');confirmTimers.delete(key);},3000));
    }
    function execAction(a,arg) { switch(a){case 'retryAll':retryAll();break;case 'retryBatch':retryBatch(arg);break;case 'cancelAll':cancelAll();break;case 'cancelBatch':cancelBatch(arg);break;case 'deleteDownload':deleteDownload(arg);break;case 'deleteBatch':deleteBatch(arg);break;case 'clearFinished':clearDownloads('finished','Finished entries cleared');break;} }

    // ── Actions ──
    async function doAction(url,body,msg) { if(actionInFlight)return;actionInFlight=true;try{const resp=await postJSON(url,body);let data={};try{data=await resp.json();}catch(e){}const m=(typeof msg==='function')?msg(data):msg;if(m)toast(m,'success');await pollDownloads();}catch(e){toast('Action failed','error');}finally{actionInFlight=false;} }
    function retryDownload(id) { doAction('/admin/catalogue/retry-download',{id}); }
    function retryBatch(bid) { doAction('/admin/catalogue/retry-batch',{batch_id:bid},'Failed downloads queued for retry'); }
    function retryAll() { const n=int(el('dqStatFailed')?.textContent);if(!n)return;doAction('/admin/catalogue/retry-all',{},n+' downloads queued for retry'); }
    function cancelDownload(id,downloading) { if(downloading && !confirm('Cancel this download? Any progress will be lost.')) return; doAction('/admin/catalogue/cancel-download',{id},'Download cancelled'); }
    function cancelBatch(bid) { if(!confirm('Cancel all pending downloads in this batch?')) return; doAction('/admin/catalogue/cancel-batch',{batch_id:bid},'Pending downloads cancelled'); }
    function cancelAll() { if(!confirm('Cancel ALL pending downloads?')) return; doAction('/admin/catalogue/cancel-all',{},'All pending downloads cancelled'); }
    function deleteDownload(id) { if(!confirm('Remove this entry from the list? The published video is kept.')) return; doAction('/admin/catalogue/delete-download',{id},'Removed from list'); }
    function deleteBatch(bid) { if(!confirm('Remove the finished entries of this batch from the list? Published videos are kept.')) return; doAction('/admin/catalogue/delete-batch',{batch_id:bid},function(d){var k=(d&&d.deleted)||0;return k.toLocaleString()+' entr'+(k===1?'y':'ies')+' removed';}); }
    function clearDownloads(status,msg) { doAction('/admin/catalogue/clear-downloads',{status},msg); }
    async function setWorkers(n) { try { await postJSON('/admin/catalogue/set-workers',{workers:n}); document.querySelectorAll('#dqWorkerSel [data-w]').forEach(function(b){ var on=(+b.dataset.w===n); b.setAttribute('aria-pressed',on?'true':'false'); b.classList.toggle('bg-primary-600',on); b.classList.toggle('text-white',on); b.classList.toggle('bg-white',!on); b.classList.toggle('text-gray-500',!on); }); toast('Parallel downloads set to '+n,'success'); pollDownloads(); } catch(e){ toast('Failed to update','error'); } }
    function clearFinished() { var n=(lastStats.completed||0)+(lastStats.failed||0)+(lastStats.cancelled||0); if(!confirm('Remove '+n.toLocaleString()+' finished entr'+(n===1?'y':'ies')+' (completed, failed, cancelled) from the list?\nPublished videos are kept.')) return; clearDownloads('finished',function(d){var k=(d&&d.cleared)||0;return k.toLocaleString()+' entr'+(k===1?'y':'ies')+' cleared';}); }

    // ── Detail Modal ──
    function closeDetail() { const m = el('dqDetailModal'); if (!m) return; m.style.display = 'none'; if (m._dqTrigger && typeof m._dqTrigger.focus === 'function') { try { m._dqTrigger.focus(); } catch(e){} } }
    function showDetail(id) {
        const d = allDownloads.find(x=>x.id===id); if(!d) return;
        let modal = el('dqDetailModal');
        if (!modal) { modal=document.createElement('div');modal.id='dqDetailModal';modal.setAttribute('role','dialog');modal.setAttribute('aria-modal','true');modal.style.cssText='position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.4);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;padding:1rem;';modal.addEventListener('click',e=>{if(e.target===modal)closeDetail();});document.body.appendChild(modal); }
        const cats=parseList(d.categories),tags=parseList(d.tags),perfs=parseList(d.performers);
        const dur=d.duration>0?Math.floor(d.duration/60)+':'+String(d.duration%60).padStart(2,'0'):'--';
        const size=d.file_size>0?fmtBytes(d.file_size):'--';
        const chip=(items,bg,tc)=>items.length?items.map(i=>'<span class="inline-block px-1.5 py-0.5 text-[10px] rounded '+bg+' '+tc+' mr-0.5 mb-0.5">'+esc(i)+'</span>').join(''):'<span class="text-[11px] text-gray-300">--</span>';
        const row=(l,v)=>'<div class="flex py-2 border-b border-gray-50"><span class="w-28 shrink-0 text-[10px] font-semibold text-gray-400 uppercase tracking-wider pt-0.5">'+l+'</span><div class="text-[11px] text-gray-700 min-w-0">'+v+'</div></div>';
        const sc={downloading:'bg-blue-100 text-blue-700',pending:'bg-amber-100 text-amber-700',completed:'bg-green-100 text-green-700',failed:'bg-red-100 text-red-700',cancelled:'bg-gray-100 text-gray-500'};
        const badge='<span class="inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full '+(sc[d.status]||'')+'">'+d.status+'</span>';
        let aiRow='';
        if(d.ai_status&&d.ai_status!=='none'){let detail='';if(d.ai_status==='completed')detail='All steps completed';else if(d.ai_status==='pending')detail='Waiting to start';else if(d.ai_status==='processing')detail=aiStepLabel(d)+'...';if(d.ai_error)detail+=' <span class="text-red-500">('+esc(d.ai_error)+')</span>';aiRow=row('AI Pipeline',detail);}

        modal.innerHTML = `<div style="position:relative;width:100%;max-width:520px;max-height:90vh;overflow-y:auto;background:#fff;border-radius:.75rem;box-shadow:0 25px 50px rgba(0,0,0,.2);">
            <div class="sticky top-0 bg-white z-10 flex items-center justify-between px-5 py-3 border-b border-gray-200 rounded-t-xl">
                <h3 class="text-sm font-bold text-gray-900 truncate pr-3">${esc(d.title||'Untitled')}</h3>
                <button data-dq-close onclick="DQ.closeDetail()" aria-label="Close" class="shrink-0 p-1 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"><svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button>
            </div>
            <div class="p-5">
                <div class="flex gap-4 mb-4">
                    <img src="${eAttr(d.thumbnail_url||'')}" alt="" class="w-40 aspect-video rounded-lg object-cover shrink-0 bg-gray-100" onerror="this.style.display='none'">
                    <div class="flex-1 min-w-0">${row('Status',badge)}${row('Quality',fmtQuality(d.quality,d.src_height)||'--')}${row('Duration',dur)}${row('Size',size)}${row('Source',esc(d.source_site||'--'))}</div>
                </div>
                ${aiRow}${d.description?row('Description','<p class="leading-relaxed">'+esc(d.description)+'</p>'):''}
                ${row('Performers',chip(perfs,'bg-pink-50','text-pink-700'))}${row('Categories',chip(cats,'bg-purple-50','text-purple-700'))}${row('Tags',chip(tags,'bg-gray-100','text-gray-600'))}
                ${d.error_message?row('Error','<span class="text-red-600">'+esc(d.error_message)+'</span>'):''}
                ${row('Created',esc(d.created_at||'--'))}${d.completed_at?row('Completed',esc(d.completed_at)):''}
            </div></div>`;
        modal._dqTrigger = document.activeElement;
        modal.style.display='flex';
        const _cb = modal.querySelector('[data-dq-close]'); if (_cb) _cb.focus();
    }

    // ── AI Step Labels ──
    function aiStepLabel(d) {
        const step = int(d.ai_pipeline_step);
        let settings = {};
        try { settings = JSON.parse(d.ai_settings || '{}'); } catch(e) {}
        // Pipeline order: 1=rewrite_title, 2=translate, 3=rewrite_description
        // ai_pipeline_step = last COMPLETED step. Current = next enabled step.
        const steps = [];
        if (settings.rewrite_title) steps.push({num: 1, label: 'Rewriting title'});
        if (settings.translate) steps.push({num: 2, label: 'Translating'});
        if (settings.rewrite_description) steps.push({num: 3, label: 'Rewriting desc.'});
        for (const s of steps) { if (step < s.num) return s.label; }
        return 'Finishing AI';
    }

    // ── Import Jobs polling ──
    function startImportPolling() { if (importPollTimer) return; importPollTimer = setInterval(pollImportJobs, 5000); }
    function stopImportPolling() { if (importPollTimer) { clearInterval(importPollTimer); importPollTimer = null; } }

    async function pollImportJobs() {
        try {
            const res = await fetch('/admin/import/jobs', { credentials: 'same-origin' });
            if (!res.ok) return;
            const data = await res.json();
            importJobs = data.jobs || [];
            renderImportJobs();
            // Stop polling if no active jobs
            const hasActive = importJobs.some(j => j.status === 'processing' || j.status === 'pending');
            if (!hasActive) stopImportPolling();
        } catch(e) {}
    }

    function renderImportJobs() {
        const container = el('dqImportJobs'), list = el('dqImportList'), summary = el('dqImportSummary');
        if (!importJobs.length) { container.classList.add('hidden'); return; }
        container.classList.remove('hidden');

        const processing = importJobs.filter(j => j.status === 'processing' || j.status === 'pending').length;
        const paused = importJobs.filter(j => j.status === 'paused').length;
        const completed = importJobs.filter(j => j.status === 'completed').length;
        const failed = importJobs.filter(j => j.status === 'failed').length;
        const parts = [];
        if (processing > 0) parts.push(processing + ' active');
        if (paused > 0) parts.push(paused + ' paused');
        if (completed > 0) parts.push(completed + ' completed');
        if (failed > 0) parts.push(failed + ' failed');
        summary.textContent = parts.join(' · ');

        list.innerHTML = importJobs.map(renderImportJobCard).join('');
    }

    function renderImportJobCard(job) {
        const pct = int(job.total_rows) > 0 ? Math.round(int(job.processed_rows) / int(job.total_rows) * 100) : 0;
        const isActive = job.status === 'processing' || job.status === 'pending';
        const isPaused = job.status === 'paused';
        const isDone = ['completed', 'cancelled', 'failed'].includes(job.status);
        const statusColors = {
            pending: 'bg-amber-50 text-amber-600', processing: 'bg-blue-50 text-blue-600',
            paused: 'bg-orange-50 text-orange-600', completed: 'bg-green-50 text-green-700',
            cancelled: 'bg-gray-100 text-gray-500', failed: 'bg-red-50 text-red-600'
        };
        const statusLabels = {
            pending: 'Queued', processing: 'Importing...', paused: 'Paused',
            completed: 'Completed', cancelled: 'Cancelled', failed: 'Failed'
        };
        const barColor = {
            processing: 'bg-indigo-500 dq-stripe', pending: 'bg-indigo-500 dq-stripe',
            paused: 'bg-orange-400', completed: 'bg-green-500', failed: 'bg-red-400', cancelled: 'bg-gray-300'
        };
        const timeInfo = job.completed_at ? 'Finished ' + timeAgo(job.completed_at)
            : (job.started_at ? 'Started ' + timeAgo(job.started_at) : 'Created ' + timeAgo(job.created_at || ''));

        let html = '<div class="bg-white rounded-lg border border-gray-200 p-4">';
        html += '<div class="flex items-center gap-3 mb-2.5">' +
            '<div class="w-9 h-9 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0">' +
                '<svg class="w-4.5 h-4.5 text-indigo-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>' +
            '</div>' +
            '<div class="flex-1 min-w-0">' +
                '<span class="text-sm font-semibold text-gray-900 truncate block">' + esc(job.original_name || job.filename) + '</span>' +
                '<span class="text-[11px] text-gray-400">Job #' + int(job.id) + ' · ' + timeInfo + '</span>' +
            '</div>' +
            '<span class="px-2.5 py-0.5 text-[11px] font-medium rounded-full ' + (statusColors[job.status] || '') + '">' + (statusLabels[job.status] || job.status) + '</span>' +
        '</div>';

        // Progress bar
        html += '<div class="w-full bg-gray-100 rounded-full h-1.5 mb-2 overflow-hidden">' +
            '<div class="h-1.5 rounded-full transition-all ' + (barColor[job.status] || 'bg-gray-300') + '" style="width:' + pct + '%"></div>' +
        '</div>';

        // Stats
        html += '<div class="flex flex-wrap items-center gap-3 text-xs">' +
            '<span class="text-gray-500">' + int(job.processed_rows).toLocaleString() + ' / ' + int(job.total_rows).toLocaleString() + ' rows (' + pct + '%)</span>' +
            '<span class="text-green-600">' + int(job.imported_count).toLocaleString() + ' imported</span>' +
            (int(job.skipped_count) > 0 ? '<span class="text-amber-600">' + int(job.skipped_count).toLocaleString() + ' skipped</span>' : '') +
            (int(job.error_count) > 0 ? '<span class="text-red-600">' + int(job.error_count).toLocaleString() + ' errors</span>' : '') +
        '</div>';

        // Actions
        html += '<div class="flex items-center gap-1.5 mt-2.5 pt-2 border-t border-gray-100">';
        if (isActive) {
            html += '<button type="button" onclick="DQ.importAction(\'pause-job\',' + int(job.id) + ')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-orange-50 text-orange-700 border border-orange-200 hover:bg-orange-100 transition-colors">' +
                '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5"/></svg>Pause</button>';
            html += '<button type="button" onclick="DQ.importAction(\'cancel-job\',' + int(job.id) + ',\'Cancel this import?\')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50 transition-colors">' +
                '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="M6 18L18 6M6 6l12 12"/></svg>Cancel</button>';
        }
        if (isPaused) {
            html += '<button type="button" onclick="DQ.importAction(\'resume-job\',' + int(job.id) + ')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-primary-50 text-primary-700 border border-primary-200 hover:bg-primary-100 transition-colors">' +
                '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"/></svg>Resume</button>';
            html += '<button type="button" onclick="DQ.importAction(\'cancel-job\',' + int(job.id) + ',\'Cancel this import?\')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50 transition-colors">' +
                '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="M6 18L18 6M6 6l12 12"/></svg>Cancel</button>';
        }
        if (isDone || isPaused) {
            html += '<button type="button" onclick="DQ.importAction(\'delete-job\',' + int(job.id) + ',\'Remove this job?\')" class="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors ml-auto">' +
                '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>Delete</button>';
        }
        html += '</div>';

        // Error
        if (job.error_message) {
            const isFatal = job.status === 'failed';
            html += '<div class="mt-2 border rounded-lg px-3 py-2 ' + (isFatal ? 'bg-red-50 border-red-200' : 'bg-amber-50 border-amber-200') + '">' +
                '<div class="text-xs ' + (isFatal ? 'text-red-700' : 'text-amber-700') + ' font-mono truncate">' + esc(job.error_message.split('\n')[0]) + '</div>' +
            '</div>';
        }

        html += '</div>';
        return html;
    }

    async function importAction(endpoint, jobId, confirmMsg) {
        if (confirmMsg && !confirm(confirmMsg)) return;
        try {
            await postJSON('/admin/import/' + endpoint, { id: jobId });
            await pollImportJobs();
            if (endpoint === 'resume-job') startImportPolling();
        } catch(e) { toast('Action failed', 'error'); }
    }

    // ── Utils ──
    function el(id){return document.getElementById(id)}
    function setText(id,t){const e=el(id);if(e)e.textContent=t;}
    function toggleHidden(id,h){const e=el(id);if(e)e.classList.toggle('hidden',h);}
    function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML;}
    function eAttr(s){return(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;');}
    function int(v){return parseInt(v)||0;}
    function fmtBytes(b){b=int(b);if(!b)return '0 B';if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';if(b<1073741824)return(b/1048576).toFixed(1)+' MB';return(b/1073741824).toFixed(2)+' GB';}
    // Render the selected transcode formats, capped at the video's source height
    // (a 1080p source never lists 2160p/1440p — those can't be produced).
    function fmtQuality(q, srcH){
        if(!q) return '';
        var arr; try{ arr = JSON.parse(q); }catch(e){ return esc(String(q)); }
        if(!Array.isArray(arr)) return esc(String(q));
        srcH = int(srcH);
        if(srcH>0) arr = arr.filter(function(f){ return int(f) <= srcH; });
        return esc(arr.join(' · '));
    }
    function fmtEta(s){if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m '+s%60+'s';return Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m';}
    function timeAgo(d){if(!d)return'';const s=Math.round((new Date()-new Date(d))/1000);if(s<60)return'just now';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';return Math.floor(s/86400)+'d ago';}
    function parseList(v){if(!v)return[];try{const a=JSON.parse(v);if(Array.isArray(a))return a.map(x=>typeof x==='object'&&x!==null?(x.name||''):String(x)).filter(Boolean);}catch(e){}return v.split(/[,;|]+/).map(s=>s.trim()).filter(Boolean);}
    async function postJSON(url,body){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-TOKEN':CSRF},body:JSON.stringify(body)});}
    function toast(msg,type){const c={success:'background:#16a34a',error:'background:#dc2626',info:'background:#2563eb'};const d=document.createElement('div');d.className='dq-toast';d.setAttribute('role','status');d.setAttribute('aria-live','polite');d.style.cssText=c[type]||c.info;d.innerHTML='<span>'+esc(msg)+'</span>';document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transform='translateY(-10px)';setTimeout(()=>d.remove(),300);},3500);}

    document.addEventListener('DOMContentLoaded',init);
    return{setFilter,onSearch,toggleBatch,retryDownload,retryBatch,retryAllFailed:retryAll,cancelDownload,cancelBatch,cancelAll,deleteDownload,deleteBatch,clearFinished,setWorkers,pauseService,resumeService,confirmAction,showDetail,closeDetail,importAction};
})();
</script>
