Download Backend/fastapi/templates/tools.html from dhyeye/streamio: direct link, hf CLI and curl.
- Browser
- Download file 25.3 kB
-
https://huggingface.co/spaces/dhyeye/streamio/resolve/main/Backend/fastapi/templates/tools.html
- Command line
-
hf download hf://spaces/dhyeye/streamio/Backend/fastapi/templates/tools.html
-
curl -L -o tools.html https://huggingface.co/spaces/dhyeye/streamio/resolve/main/Backend/fastapi/templates/tools.html
25.3 kB
| {% extends "base.html" %} | |
| {% block title %}Tools β TG Stremio{% endblock %} | |
| {% block content %} | |
| <style> | |
| .tool-card { | |
| background: color-mix(in srgb, var(--card) 94%, var(--bg)); | |
| border: 1px solid var(--border); | |
| border-radius: 1.5rem; | |
| padding: 1.5rem 1.75rem; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.15); | |
| } | |
| .tool-card h2 { | |
| font-size: 1rem; font-weight: 800; | |
| letter-spacing: 0.05em; text-transform: uppercase; | |
| color: var(--primary); margin-bottom: 1.25rem; | |
| display: flex; align-items: center; gap: 0.5rem; | |
| } | |
| .s-label { | |
| display: block; font-size: 0.78rem; font-weight: 700; | |
| color: var(--text-sec); text-transform: uppercase; | |
| letter-spacing: 0.04em; margin-bottom: 0.5rem; | |
| } | |
| .btn { | |
| display: inline-flex; align-items: center; gap: 0.5rem; | |
| padding: 0.6rem 1.25rem; border-radius: 9999px; | |
| font-size: 0.85rem; font-weight: 700; cursor: pointer; | |
| border: 1px solid transparent; transition: all 0.15s; white-space: nowrap; | |
| } | |
| .btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .btn-primary { background: var(--primary); color: #fff; box-shadow: 0 0 18px color-mix(in srgb,var(--primary) 40%,transparent); } | |
| .btn-danger { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.3); color: #ef4444; } | |
| .btn-danger:hover:not(:disabled) { background: rgba(239,68,68,0.25); } | |
| .btn-ghost { background: color-mix(in srgb,var(--text-sec) 12%,transparent); color: var(--text); } | |
| .btn-warning { background: rgba(245,158,11,0.14); border-color: rgba(245,158,11,0.35); color: #f59e0b; } | |
| .btn-warning:hover:not(:disabled) { background: rgba(245,158,11,0.28); } | |
| /* channel chips */ | |
| .chip-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 240px; overflow-y: auto; } | |
| .chip { | |
| display: flex; align-items: center; gap: 0.75rem; | |
| padding: 0.7rem 1rem; border-radius: 0.9rem; | |
| border: 1px solid var(--border); | |
| background: color-mix(in srgb, var(--bg) 60%, transparent); | |
| cursor: pointer; transition: border-color 0.15s, background 0.15s; | |
| } | |
| .chip:hover { border-color: var(--primary); } | |
| .chip.selected { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 12%, transparent); } | |
| .chip input { width: 1.05rem; height: 1.05rem; accent-color: var(--primary); } | |
| .chip .c-name { font-weight: 700; font-size: 0.9rem; } | |
| .chip .c-id { font-size: 0.72rem; color: var(--text-sec); } | |
| /* progress */ | |
| .progress-track { | |
| width: 100%; height: 0.65rem; border-radius: 9999px; | |
| background: color-mix(in srgb, var(--text-sec) 18%, transparent); | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; border-radius: 9999px; background: var(--primary); | |
| width: 0%; transition: width 0.4s ease; | |
| } | |
| .progress-fill.indeterminate { | |
| width: 35% ; | |
| animation: indet 1.2s ease-in-out infinite; | |
| } | |
| @keyframes indet { 0% { margin-left: -35%; } 100% { margin-left: 100%; } } | |
| .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px,1fr)); gap: 0.75rem; } | |
| .stat-box { | |
| background: color-mix(in srgb, var(--bg) 55%, transparent); | |
| border: 1px solid var(--border); border-radius: 0.9rem; | |
| padding: 0.75rem 0.9rem; | |
| } | |
| .stat-box .v { font-size: 1.3rem; font-weight: 800; } | |
| .stat-box .l { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-sec); } | |
| .status-pill { | |
| display: inline-flex; align-items: center; gap: 0.4rem; | |
| padding: 0.25rem 0.8rem; border-radius: 9999px; | |
| font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; | |
| } | |
| .status-idle { background: color-mix(in srgb,var(--text-sec) 15%,transparent); color: var(--text-sec); } | |
| .status-running { background: rgba(34,197,94,0.15); color: #22c55e; } | |
| .status-paused { background: rgba(245,158,11,0.15); color: #f59e0b; } | |
| .status-cancelled { background: rgba(245,158,11,0.15); color: #f59e0b; } | |
| .status-completed { background: rgba(34,197,94,0.15); color: #22c55e; } | |
| .status-error { background: rgba(239,68,68,0.15); color: #ef4444; } | |
| .dead-row { | |
| display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; | |
| padding: 0.6rem 0.9rem; border-radius: 0.75rem; | |
| border: 1px solid var(--border); | |
| background: color-mix(in srgb, var(--bg) 55%, transparent); | |
| } | |
| .dead-list { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; } | |
| .divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; } | |
| .hint { font-size: 0.78rem; color: var(--text-sec); } | |
| </style> | |
| <!-- Page header --> | |
| <div class="flex items-center justify-between mb-8 flex-wrap gap-3"> | |
| <div> | |
| <h1 class="text-3xl font-extrabold">Tools</h1> | |
| <p class="mt-1 text-sm" style="color:var(--text-sec)"> | |
| Scan your channels, verify the database, and clean up dead links β all from here. | |
| </p> | |
| </div> | |
| </div> | |
| <div class="space-y-6"> | |
| <!-- ββ Channel Scanner ββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="tool-card"> | |
| <div class="flex items-center justify-between flex-wrap gap-2"> | |
| <h2 style="margin-bottom:0"><i class="fa-solid fa-satellite-dish"></i> Channel Scanner</h2> | |
| <span id="scan-status-pill" class="status-pill status-idle">Idle</span> | |
| </div> | |
| <p class="hint mt-3 mb-4"> | |
| Index existing files from your AUTH channels into the database. A scan | |
| <strong>resumes from where it left off</strong> if the server restarts or you | |
| stop it β it only restarts from scratch when you choose <strong>Rescan</strong>. | |
| The bot must be an admin in each channel. | |
| </p> | |
| <label class="s-label">Select channels</label> | |
| <div class="chip-list" id="channel-list"> | |
| <div class="hint" id="channels-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading channelsβ¦</div> | |
| </div> | |
| <div class="flex items-center gap-2 mt-3"> | |
| <button class="btn btn-ghost" type="button" onclick="selectAllChannels(true)"><i class="fa-solid fa-check-double"></i> Select all</button> | |
| <button class="btn btn-ghost" type="button" onclick="selectAllChannels(false)"><i class="fa-solid fa-xmark"></i> Clear</button> | |
| </div> | |
| <hr class="divider"> | |
| <!-- live progress --> | |
| <div class="flex items-center justify-between mb-2 flex-wrap gap-2"> | |
| <div class="text-sm font-bold" id="scan-current">No scan running</div> | |
| <div class="text-sm" style="color:var(--text-sec)"> | |
| <i class="fa-regular fa-clock"></i> <span id="scan-elapsed">0s</span> | |
| </div> | |
| </div> | |
| <div class="progress-track mb-4"> | |
| <div class="progress-fill" id="scan-bar"></div> | |
| </div> | |
| <div class="stat-grid mb-4"> | |
| <div class="stat-box"><div class="v" id="sc-processed">0</div><div class="l">Processed</div></div> | |
| <div class="stat-box"><div class="v" style="color:#22c55e" id="sc-indexed">0</div><div class="l">Indexed</div></div> | |
| <div class="stat-box"><div class="v" id="sc-dup">0</div><div class="l">Skipped (dup)</div></div> | |
| <div class="stat-box"><div class="v" id="sc-meta">0</div><div class="l">Skipped (meta)</div></div> | |
| <div class="stat-box"><div class="v" id="sc-nonvid">0</div><div class="l">Non-video</div></div> | |
| <div class="stat-box"><div class="v" style="color:#ef4444" id="sc-errors">0</div><div class="l">Errors</div></div> | |
| </div> | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <button class="btn btn-primary" id="scan-start-btn" onclick="startScan('scan')"> | |
| <i class="fa-solid fa-play"></i> <span id="scan-start-label">Start Scan</span> | |
| </button> | |
| <button class="btn btn-warning" id="rescan-btn" onclick="confirmRescan()"> | |
| <i class="fa-solid fa-arrows-rotate"></i> Rescan (wipe & re-index) | |
| </button> | |
| <button class="btn btn-danger" id="scan-cancel-btn" onclick="cancelScan()" disabled> | |
| <i class="fa-solid fa-stop"></i> Stop | |
| </button> | |
| </div> | |
| <p class="hint mt-3" id="scan-error" style="color:#ef4444;display:none"></p> | |
| </div> | |
| <!-- ββ Database Check ββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="tool-card"> | |
| <div class="flex items-center justify-between flex-wrap gap-2"> | |
| <h2 style="margin-bottom:0"><i class="fa-solid fa-stethoscope"></i> Database Check & Dead Links</h2> | |
| <span id="dbc-status-pill" class="status-pill status-idle">Idle</span> | |
| </div> | |
| <p class="hint mt-3 mb-4"> | |
| Verify that every indexed file still exists on Telegram. Dead entries | |
| found here can be purged below. Runs are throttled to respect Telegram limits. | |
| </p> | |
| <div class="flex items-center justify-between mb-2 flex-wrap gap-2"> | |
| <div class="text-sm font-bold" id="dbc-current">No check running</div> | |
| <div class="text-sm" style="color:var(--text-sec)"> | |
| <i class="fa-solid fa-gauge-high"></i> <span id="dbc-speed">0</span>/s Β· | |
| <i class="fa-regular fa-clock"></i> <span id="dbc-elapsed">0s</span> | |
| </div> | |
| </div> | |
| <div class="progress-track mb-4"> | |
| <div class="progress-fill" id="dbc-bar"></div> | |
| </div> | |
| <div class="stat-grid mb-4"> | |
| <div class="stat-box"><div class="v" id="dbc-checked">0</div><div class="l">Checked</div></div> | |
| <div class="stat-box"><div class="v" style="color:#22c55e" id="dbc-alive">0</div><div class="l">Alive</div></div> | |
| <div class="stat-box"><div class="v" style="color:#ef4444" id="dbc-dead">0</div><div class="l">Dead</div></div> | |
| <div class="stat-box"><div class="v" id="dbc-errors">0</div><div class="l">Errors</div></div> | |
| <div class="stat-box"><div class="v" id="dbc-purged">0</div><div class="l">Purged</div></div> | |
| </div> | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <button class="btn btn-primary" id="dbc-start-btn" onclick="startDbCheck()"> | |
| <i class="fa-solid fa-play"></i> Run DB Check | |
| </button> | |
| <button class="btn btn-danger" id="dbc-cancel-btn" onclick="cancelDbCheck()" disabled> | |
| <i class="fa-solid fa-stop"></i> Stop | |
| </button> | |
| </div> | |
| <hr class="divider"> | |
| <!-- Dead links --> | |
| <div class="flex items-center justify-between mb-3 flex-wrap gap-2"> | |
| <label class="s-label" style="margin-bottom:0">Dead links found</label> | |
| <div class="flex items-center gap-2"> | |
| <button class="btn btn-ghost" type="button" onclick="loadFlaggedDeadLinks()"> | |
| <i class="fa-solid fa-rotate"></i> Load flagged | |
| </button> | |
| <button class="btn btn-danger" id="purge-btn" onclick="purgeDeadLinks()" disabled> | |
| <i class="fa-solid fa-trash"></i> <span id="purge-label">Purge dead links</span> | |
| </button> | |
| </div> | |
| </div> | |
| <p class="hint mb-3"> | |
| "Load flagged" shows dead links detected automatically by the background checker. | |
| Running a DB Check above also fills this list. Purging removes those entries from the database. | |
| </p> | |
| <div class="dead-list" id="dead-list"> | |
| <div class="hint">No dead links to show yet. Run a DB Check or load flagged links.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| /* βββββββββββββββ State βββββββββββββββ */ | |
| let scanTimer = null; | |
| let dbcTimer = null; | |
| let deadSource = "dbcheck"; // where the current dead list came from | |
| let purgeIds = []; // explicit ids to purge when source = flagged | |
| /* βββββββββββββββ Channels βββββββββββββββ */ | |
| async function loadChannels() { | |
| const list = document.getElementById('channel-list'); | |
| try { | |
| const res = await fetch('/api/admin/tools/channels'); | |
| const data = await res.json(); | |
| const channels = (data && data.data) || []; | |
| if (!channels.length) { | |
| list.innerHTML = `<div class="hint">No AUTH channels configured. Add them in <a href="/admin/settings" style="color:var(--primary)">Settings</a>.</div>`; | |
| return; | |
| } | |
| list.innerHTML = channels.map(c => ` | |
| <label class="chip" data-id="${escapeHtmlAttr(c.id)}"> | |
| <input type="checkbox" value="${escapeHtmlAttr(c.id)}" onchange="onChipChange(this)"> | |
| <span class="min-w-0"> | |
| <span class="c-name block">${escapeHtml(c.name)}</span> | |
| <span class="c-id">${escapeHtml(c.id)}</span> | |
| </span> | |
| </label> | |
| `).join(''); | |
| } catch (e) { | |
| list.innerHTML = `<div class="hint" style="color:#ef4444">Failed to load channels.</div>`; | |
| } | |
| } | |
| function onChipChange(input) { | |
| input.closest('.chip').classList.toggle('selected', input.checked); | |
| } | |
| function selectAllChannels(state) { | |
| document.querySelectorAll('#channel-list input[type=checkbox]').forEach(cb => { | |
| cb.checked = state; | |
| onChipChange(cb); | |
| }); | |
| } | |
| function getSelectedChannels() { | |
| return [...document.querySelectorAll('#channel-list input[type=checkbox]:checked')].map(cb => cb.value); | |
| } | |
| /* βββββββββββββββ Scan control βββββββββββββββ */ | |
| async function startScan(mode) { | |
| const channels = getSelectedChannels(); | |
| if (mode === 'scan' && !channels.length) { | |
| // allow plain resume with no selection only if resumable | |
| showToast('Select at least one channel to scan.', 'error', 'No channels'); | |
| return; | |
| } | |
| try { | |
| const res = await fetch('/api/admin/tools/scan/start', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ mode, channels }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| showToast(data.message || 'Scan started.', 'success', 'Scanner'); | |
| startScanPolling(); | |
| } else { | |
| showToast(data.detail || 'Could not start scan.', 'error', 'Error'); | |
| } | |
| } catch (e) { | |
| showToast('Network error.', 'error', 'Error'); | |
| } | |
| } | |
| function confirmRescan() { | |
| const channels = getSelectedChannels(); | |
| if (!channels.length) { | |
| showToast('Select the channels you want to rescan.', 'error', 'No channels'); | |
| return; | |
| } | |
| if (!confirm(`Rescan will DELETE all existing DB entries for ${channels.length} selected channel(s) and re-index from scratch.\n\nProceed?`)) return; | |
| startScan('rescan'); | |
| } | |
| async function cancelScan() { | |
| try { | |
| const res = await fetch('/api/admin/tools/scan/cancel', { method: 'POST' }); | |
| const data = await res.json(); | |
| showToast(data.message || 'Stop requested.', data.ok ? 'info' : 'error', 'Scanner'); | |
| } catch (e) { | |
| showToast('Network error.', 'error', 'Error'); | |
| } | |
| } | |
| function startScanPolling() { | |
| if (scanTimer) clearInterval(scanTimer); | |
| pollScan(); | |
| scanTimer = setInterval(pollScan, 1500); | |
| } | |
| async function pollScan() { | |
| try { | |
| const res = await fetch('/api/admin/tools/scan/status'); | |
| const data = await res.json(); | |
| renderScan(data.data || {}); | |
| } catch (e) { /* ignore transient errors */ } | |
| } | |
| function renderScan(s) { | |
| const pill = document.getElementById('scan-status-pill'); | |
| const status = s.status || 'idle'; | |
| pill.className = 'status-pill status-' + status; | |
| pill.textContent = status.charAt(0).toUpperCase() + status.slice(1); | |
| const c = s.counters || {}; | |
| setText('sc-processed', c.processed || 0); | |
| setText('sc-indexed', c.indexed || 0); | |
| setText('sc-dup', c.skipped_dup || 0); | |
| setText('sc-meta', c.skipped_meta || 0); | |
| setText('sc-nonvid', c.skipped_nonvid || 0); | |
| setText('sc-errors', c.errors || 0); | |
| setText('scan-elapsed', s.elapsed || '0s'); | |
| const bar = document.getElementById('scan-bar'); | |
| const startBtn = document.getElementById('scan-start-btn'); | |
| const cancelBtn = document.getElementById('scan-cancel-btn'); | |
| const rescanBtn = document.getElementById('rescan-btn'); | |
| const startLabel = document.getElementById('scan-start-label'); | |
| const errEl = document.getElementById('scan-error'); | |
| if (s.is_running) { | |
| const name = s.current_channel_name || s.current_channel || ''; | |
| if (s.has_progress && s.current_target_id) { | |
| bar.classList.remove('indeterminate'); | |
| bar.style.width = (s.progress || 0) + '%'; | |
| document.getElementById('scan-current').textContent = | |
| `Scanning ${name} Β· ${s.current_id}/${s.current_target_id} (${s.progress || 0}%)`; | |
| } else { | |
| bar.classList.add('indeterminate'); | |
| document.getElementById('scan-current').textContent = | |
| `Scanning ${name}` + (s.current_id ? ` Β· up to msg #${s.current_id}` : ''); | |
| } | |
| startBtn.disabled = true; rescanBtn.disabled = true; cancelBtn.disabled = false; | |
| errEl.style.display = 'none'; | |
| } else { | |
| bar.classList.remove('indeterminate'); | |
| startBtn.disabled = false; rescanBtn.disabled = false; cancelBtn.disabled = true; | |
| if (s.resumable) { | |
| startLabel.textContent = 'Resume Scan'; | |
| bar.style.width = '60%'; | |
| document.getElementById('scan-current').textContent = | |
| `Paused β ${ (s.pending||[]).length } channel(s) remaining. Resume to continue.`; | |
| } else { | |
| startLabel.textContent = 'Start Scan'; | |
| bar.style.width = (status === 'completed') ? '100%' : '0%'; | |
| document.getElementById('scan-current').textContent = | |
| status === 'completed' ? 'Scan complete.' : | |
| status === 'error' ? 'Scan failed.' : 'No scan running'; | |
| } | |
| if (s.error) { errEl.style.display = ''; errEl.textContent = s.error; } | |
| else errEl.style.display = 'none'; | |
| if (scanTimer && ['completed','cancelled','error','paused','idle'].includes(status)) { | |
| clearInterval(scanTimer); scanTimer = null; | |
| } | |
| } | |
| } | |
| /* βββββββββββββββ DB Check control βββββββββββββββ */ | |
| async function startDbCheck() { | |
| try { | |
| const res = await fetch('/api/admin/tools/dbcheck/start', { method: 'POST' }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| showToast('DB check started.', 'success', 'DB Check'); | |
| startDbcPolling(); | |
| } else { | |
| showToast(data.detail || 'Could not start.', 'error', 'Error'); | |
| } | |
| } catch (e) { showToast('Network error.', 'error', 'Error'); } | |
| } | |
| async function cancelDbCheck() { | |
| try { | |
| const res = await fetch('/api/admin/tools/dbcheck/cancel', { method: 'POST' }); | |
| const data = await res.json(); | |
| showToast(data.message || 'Stop requested.', data.ok ? 'info' : 'error', 'DB Check'); | |
| } catch (e) { showToast('Network error.', 'error', 'Error'); } | |
| } | |
| function startDbcPolling() { | |
| if (dbcTimer) clearInterval(dbcTimer); | |
| pollDbc(); | |
| dbcTimer = setInterval(pollDbc, 1500); | |
| } | |
| async function pollDbc() { | |
| try { | |
| const res = await fetch('/api/admin/tools/dbcheck/status'); | |
| const data = await res.json(); | |
| renderDbc(data.data || {}); | |
| } catch (e) { /* ignore */ } | |
| } | |
| function renderDbc(s) { | |
| const pill = document.getElementById('dbc-status-pill'); | |
| const status = s.status || 'idle'; | |
| pill.className = 'status-pill status-' + status; | |
| pill.textContent = status.charAt(0).toUpperCase() + status.slice(1); | |
| setText('dbc-checked', s.checked || 0); | |
| setText('dbc-alive', s.alive || 0); | |
| setText('dbc-dead', s.dead || 0); | |
| setText('dbc-errors', s.errors || 0); | |
| setText('dbc-purged', s.purged || 0); | |
| setText('dbc-speed', s.speed || 0); | |
| setText('dbc-elapsed', s.elapsed || '0s'); | |
| const bar = document.getElementById('dbc-bar'); | |
| const startBtn = document.getElementById('dbc-start-btn'); | |
| const cancelBtn = document.getElementById('dbc-cancel-btn'); | |
| if (s.is_running) { | |
| document.getElementById('dbc-current').textContent = `Checking⦠${s.checked || 0} verified`; | |
| bar.classList.add('indeterminate'); | |
| startBtn.disabled = true; cancelBtn.disabled = false; | |
| } else { | |
| bar.classList.remove('indeterminate'); | |
| bar.style.width = (status === 'completed' || status === 'cancelled') ? '100%' : '0%'; | |
| startBtn.disabled = false; cancelBtn.disabled = true; | |
| document.getElementById('dbc-current').textContent = | |
| status === 'completed' ? `Done β ${s.dead || 0} dead found.` : | |
| status === 'cancelled' ? 'Stopped.' : | |
| status === 'error' ? 'Check failed.' : 'No check running'; | |
| if (dbcTimer && ['completed','cancelled','error','idle'].includes(status)) { | |
| clearInterval(dbcTimer); dbcTimer = null; | |
| } | |
| } | |
| // refresh dead list from this run's findings | |
| if ((s.dead_entries || []).length || status === 'completed') { | |
| deadSource = 'dbcheck'; | |
| purgeIds = []; | |
| renderDeadList(s.dead_entries || []); | |
| } | |
| } | |
| /* βββββββββββββββ Dead links βββββββββββββββ */ | |
| function renderDeadList(entries) { | |
| const list = document.getElementById('dead-list'); | |
| const purgeBtn = document.getElementById('purge-btn'); | |
| const purgeLabel = document.getElementById('purge-label'); | |
| if (!entries.length) { | |
| list.innerHTML = `<div class="hint">No dead links to show.</div>`; | |
| purgeBtn.disabled = true; | |
| purgeLabel.textContent = 'Purge dead links'; | |
| return; | |
| } | |
| list.innerHTML = entries.map(e => ` | |
| <div class="dead-row"> | |
| <span class="min-w-0 truncate"> | |
| <i class="fa-solid fa-link-slash" style="color:#ef4444"></i> | |
| ${escapeHtml(e.title || 'Unknown')} | |
| ${e.quality ? `<span class="c-id">Β· ${escapeHtml(e.quality)}</span>` : ''} | |
| </span> | |
| </div> | |
| `).join(''); | |
| purgeBtn.disabled = false; | |
| purgeLabel.textContent = `Purge ${entries.length} dead link${entries.length === 1 ? '' : 's'}`; | |
| } | |
| async function loadFlaggedDeadLinks() { | |
| try { | |
| const res = await fetch('/api/admin/dead-links'); | |
| const data = await res.json(); | |
| const links = (data && data.data) || []; | |
| deadSource = 'flagged'; | |
| purgeIds = links.map(l => l.quality_id).filter(Boolean); | |
| renderDeadList(links.map(l => ({ title: l.title, quality: l.quality }))); | |
| showToast(`${links.length} flagged dead link(s) loaded.`, 'info', 'Dead Links'); | |
| } catch (e) { showToast('Failed to load flagged links.', 'error', 'Error'); } | |
| } | |
| async function purgeDeadLinks() { | |
| if (!confirm('Permanently remove these dead links from the database?')) return; | |
| const btn = document.getElementById('purge-btn'); | |
| btn.disabled = true; | |
| const body = (deadSource === 'flagged') | |
| ? { source: 'flagged' } | |
| : { source: 'dbcheck' }; | |
| try { | |
| const res = await fetch('/api/admin/tools/dead-links/purge', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok && data.ok) { | |
| showToast(data.message || 'Purged.', 'success', 'Dead Links'); | |
| renderDeadList([]); | |
| pollDbc(); | |
| } else { | |
| showToast(data.message || data.detail || 'Nothing to purge.', 'error', 'Dead Links'); | |
| btn.disabled = false; | |
| } | |
| } catch (e) { | |
| showToast('Network error.', 'error', 'Error'); | |
| btn.disabled = false; | |
| } | |
| } | |
| /* βββββββββββββββ Utils βββββββββββββββ */ | |
| function setText(id, val) { const el = document.getElementById(id); if (el) el.textContent = val; } | |
| function escapeHtml(str) { | |
| return String(str || '').replace(/&/g,'&').replace(/</g,'<') | |
| .replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); | |
| } | |
| function escapeHtmlAttr(str) { | |
| return String(str || '').replace(/&/g,'&').replace(/"/g,'"'); | |
| } | |
| /* βββββββββββββββ Init βββββββββββββββ */ | |
| loadChannels(); | |
| pollScan().then(() => { | |
| const pill = document.getElementById('scan-status-pill'); | |
| if (pill.classList.contains('status-running')) startScanPolling(); | |
| }); | |
| pollDbc().then(() => { | |
| const pill = document.getElementById('dbc-status-pill'); | |
| if (pill.classList.contains('status-running')) startDbcPolling(); | |
| }); | |
| </script> | |
| {% endblock %} | |