Karan Adhikari
UI: redesign templates
ef88983 unverified
Raw History Blame Contribute Delete
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% !important;
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 &amp; 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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');
}
function escapeHtmlAttr(str) {
return String(str || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
}
/* ─────────────── 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 %}