// NAUT — main application controller. // Wires the live stream into the sidebar, torrent grid, detail panel, // status bar, RSS/Automation/Search/Engine views, selection, context menu, // hotkeys. import { api } from './api.js'; import * as f from './format.js'; import { renderDetailShell, closeDetail } from './detail.js'; import { getPluginView, loadPlugins, mountPluginSidebarSections, pluginViews, renderPluginSidebarSections, renderPluginView, } from './plugins.js'; /* ===================== state ===================== */ const state = { view: 'torrents', snapshot: { torrents: [], server: {} }, meta: { categories: [], tags: [], trackers: [], preferences: {}, searchPlugins: [] }, selected: new Set(), lastClicked: null, detailHash: null, filter: { type: 'status', value: 'all' }, // type: status|category|tag|tracker quick: '', sort: { key: 'addedOn', dir: -1 }, columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'], searchResults: null, searchQuery: '', }; let viewRenderSeq = 0; const COLUMNS = { name: { label: 'Name' }, // flexible: absorbs remaining width size: { label: 'Size', num: true, w: '90px' }, progress: { label: 'Done', w: '120px' }, state: { label: 'Status', w: '120px' }, seeds: { label: 'Seeds', num: true, w: '92px' }, peers: { label: 'Peers', num: true, w: '92px' }, dlspeed: { label: 'Down', num: true, w: '100px' }, upspeed: { label: 'Up', num: true, w: '100px' }, eta: { label: 'ETA', num: true, w: '90px' }, ratio: { label: 'Ratio', num: true, w: '70px' }, availability: { label: 'Avail.', num: true, w: '72px' }, category: { label: 'Category', w: '130px' }, tags: { label: 'Tags', w: '170px' }, addedOn: { label: 'Added', num: true, w: '128px' }, completionOn: { label: 'Completed', num: true, w: '128px' }, savePath: { label: 'Save path', w: '220px' }, priority: { label: '#', num: true, w: '56px' }, }; const STATUS_FILTERS = [ ['all', 'All', '◎'], ['downloading', 'Downloading', '▼'], ['seeding', 'Seeding', '▲'], ['completed', 'Completed', '✓'], ['active', 'Active', '⚡'], ['inactive', 'Inactive', '○'], ['stalled', 'Stalled', '◍'], ['paused', 'Paused', '⏸'], ['errored', 'Errored', '⚠'], ]; /* ===================== bootstrap ===================== */ async function boot() { await ensureLogin(); await loadMeta(); // retries until the server answers await loadPlugins(api, appContext()); renderPluginViewTabs(); bindGlobal(); syncAltToggle(); renderSidebar(); renderView(); api.stream(onSnapshot, onStatus); } function appContext() { return { api, f, state, toast, refreshMeta, setView: switchViewTab, renderView, selectedHashes: () => [...state.selected], selectHashes: (hashes) => { state.selected = new Set(hashes || []); renderGrid(); }, }; } async function loadMeta() { for (;;) { try { state.meta = await api.meta(); return; } catch { onStatus('reconnecting'); await new Promise((r) => setTimeout(r, 2000)); } } } async function ensureLogin() { const auth = await api.authStatus(); if (auth.authenticated) return; document.getElementById('app').hidden = true; await new Promise((resolve) => showLogin(auth, resolve)); document.getElementById('app').hidden = false; } function showLogin(auth, done) { const host = document.createElement('div'); host.className = 'login-screen'; host.innerHTML = `
NAUT torrent console
${auth.generatedPassword ? '

A password was generated for this server process. Check the server console output.

' : ''}
`; document.body.appendChild(host); const form = host.querySelector('#loginForm'); const err = host.querySelector('#loginError'); form.addEventListener('submit', async (e) => { e.preventDefault(); err.hidden = true; form.querySelector('.login-submit').disabled = true; try { await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value); host.remove(); done(); } catch { err.textContent = 'Invalid username or password'; err.hidden = false; form.querySelector('.login-submit').disabled = false; host.querySelector('#loginPass').select(); } }); setTimeout(() => host.querySelector('#loginPass')?.focus(), 0); } function onSnapshot(snap) { state.snapshot = snap; // drop selection / detail for torrents that no longer exist const live = new Set(snap.torrents.map((t) => t.hash)); for (const h of [...state.selected]) if (!live.has(h)) state.selected.delete(h); if (state.detailHash && !live.has(state.detailHash)) closeDetailPanel(); updateRates(); renderStatusbar(); renderSidebar(); if (state.view === 'torrents') renderGrid(); } function onStatus(s) { const led = document.querySelector('.statusbar .led'); if (led) led.style.background = s === 'connected' ? 'var(--ok)' : 'var(--warn)'; setBanner(s === 'connected' ? '' : 'Reconnecting to server…'); } /* ===================== derived ===================== */ function visibleTorrents() { let list = state.snapshot.torrents; const { type, value } = state.filter; list = list.filter((t) => matchFilter(t, type, value)); if (state.quick) { const q = state.quick.toLowerCase(); list = list.filter((t) => t.name.toLowerCase().includes(q) || (t.category || '').toLowerCase().includes(q) || t.tags.some((x) => x.includes(q))); } const { key, dir } = state.sort; list = [...list].sort((a, b) => cmp(a, b, key) * dir); return list; } function matchFilter(t, type, value) { if (type === 'category') return (t.category || '') === value; if (type === 'tag') return t.tags.includes(value); if (type === 'tracker') return (t.trackerHosts || []).includes(value); // status const s = t.state; switch (value) { case 'all': return true; case 'downloading': return ['downloading', 'forcedDL', 'metaDL', 'stalledDL', 'queuedDL'].includes(s); case 'seeding': return ['uploading', 'forcedUP', 'stalledUP', 'queuedUP'].includes(s); case 'completed': return t.progress >= 1; case 'active': return t.dlspeed > 0 || t.upspeed > 0; case 'inactive': return t.dlspeed === 0 && t.upspeed === 0; case 'stalled': return s === 'stalledDL' || s === 'stalledUP'; case 'paused': return s.startsWith('paused'); case 'errored': return s === 'error' || s === 'missingFiles'; default: return true; } } function cmp(a, b, key) { if (key === 'name' || key === 'state' || key === 'category' || key === 'savePath') { return String(a[key] || '').localeCompare(String(b[key] || '')); } if (key === 'tags') return a.tags.join().localeCompare(b.tags.join()); return (a[key] ?? 0) - (b[key] ?? 0); } /* ===================== sidebar ===================== */ function renderSidebar() { const all = state.snapshot.torrents; const count = (type, value) => all.filter((t) => matchFilter(t, type, value)).length; const el = document.getElementById('sidebar'); const statusItems = STATUS_FILTERS.map(([v, label, ic]) => sideItem('status', v, ic, label, count('status', v))).join(''); // Categories/tags: source names from server meta (so empty ones still show) merged with live counts. const catCounts = new Map(); for (const t of all) catCounts.set(t.category || '', (catCounts.get(t.category || '') || 0) + 1); const catNames = new Set(state.meta.categories.map((c) => c.name)); for (const k of catCounts.keys()) catNames.add(k); const cats = [...catNames].sort((a, b) => a.localeCompare(b)) .map((c) => sideItem('category', c, '🗂', c || 'Uncategorized', catCounts.get(c) || 0, c !== '')).join(''); const tagCounts = new Map(); for (const t of all) for (const tag of t.tags) tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); const tagNames = new Set(state.meta.tags); for (const k of tagCounts.keys()) tagNames.add(k); const tags = [...tagNames].sort((a, b) => (tagCounts.get(b) || 0) - (tagCounts.get(a) || 0) || a.localeCompare(b)) .map((tg) => sideItem('tag', tg, '#', tg, tagCounts.get(tg) || 0, true)).join(''); const trackers = state.meta.trackers.slice(0, 10) .map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join(''); const pluginSections = renderPluginSidebarSections(appContext()); el.innerHTML = `
Status
${statusItems}
Categories
${cats}
Tags
${tags || '
none
'}
Trackers
${trackers}
${pluginSections}`; el.querySelectorAll('.side-item[data-type]').forEach((it) => { it.addEventListener('click', () => { state.filter = { type: it.dataset.type, value: it.dataset.value }; if (state.view !== 'torrents') { state.view = 'torrents'; switchViewTab('torrents'); } renderSidebar(); renderView(); }); if (it.dataset.type === 'category' || it.dataset.type === 'tag') { it.addEventListener('contextmenu', (e) => onSidebarContext(e, it.dataset.type, it.dataset.value, it.dataset.removable === '1')); } }); el.querySelectorAll('.side-add[data-add]').forEach((b) => b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); })); mountPluginSidebarSections(el, appContext()); } function onSidebarContext(e, type, value, removable) { e.preventDefault(); const items = [ { label: type === 'category' ? 'New category…' : 'New tag…', act: () => (type === 'category' ? openCreateCategory() : openCreateTag()) }, ]; if (type === 'category') { items.push({ label: 'Edit category…', act: () => openEditCategory(value) }); } if (removable) { items.push({ sep: true }); items.push({ label: `Delete ${type}`, danger: true, act: () => confirmDeleteMeta(type, value) }); } showContextMenu(e.clientX, e.clientY, items); } function sideItem(type, value, ic, label, cnt, removable) { const active = state.filter.type === type && state.filter.value === value; return `
${ic}${f.esc(label)}${cnt}
`; } async function refreshMeta() { state.meta = await api.meta(); renderSidebar(); } /* ===================== views ===================== */ function renderView() { const seq = ++viewRenderSeq; const host = document.getElementById('viewHost'); if (state.view === 'torrents') return renderTorrentsView(host); if (state.view === 'rss') return renderRssView(host, seq); if (state.view === 'automation') return renderAutomationView(host, seq); if (state.view === 'search') return renderSearchView(host); if (state.view === 'settings') return renderSettingsView(host); if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext()); host.innerHTML = '
Unknown view
'; } /* ---------- torrents view ---------- */ function renderTorrentsView(host) { host.innerHTML = `
`; renderGrid(); if (state.detailHash) renderDetailShell(state.detailHash, document.getElementById('detailHost')); } let gridSig = ''; function renderGrid() { const wrap = document.getElementById('gridWrap'); if (!wrap) return; const list = visibleTorrents(); const cols = state.columns; // signature of structure (columns + ordered hashes). When unchanged, update // cell contents in place instead of rebuilding — preserves text selection, // scroll, focus, and avoids re-binding listeners every second. const sig = cols.join(',') + '|' + list.map((t) => t.hash).join(','); if (sig === gridSig) { const tbody = wrap.querySelector('tbody'); if (tbody && tbody.children.length === list.length) { for (let r = 0; r < list.length; r++) { const t = list[r]; const tr = tbody.children[r]; tr.classList.toggle('sel', state.selected.has(t.hash)); const inner = cols.map((k) => cell(t, k)).join(''); if (tr.__inner !== inner) { tr.innerHTML = inner; tr.__inner = inner; } } return; } } gridSig = sig; const head = cols.map((k) => { const c = COLUMNS[k]; const arrow = state.sort.key === k ? `${state.sort.dir < 0 ? '▾' : '▴'}` : ''; return `${c.label}${arrow}`; }).join(''); const rows = list.map((t) => `${cols.map((k) => cell(t, k)).join('')}`).join(''); wrap.innerHTML = `${head}${rows || emptyRow(cols.length)}
`; wrap.querySelectorAll('th[data-col]').forEach((th) => th.addEventListener('click', () => { const k = th.dataset.col; if (state.sort.key === k) state.sort.dir *= -1; else state.sort = { key: k, dir: k === 'name' ? 1 : -1 }; renderGrid(); })); wrap.querySelectorAll('tr[data-hash]').forEach((tr, idx) => { tr.__inner = cols.map((k) => cell(list[idx], k)).join(''); // cache computed (not DOM-normalized) html tr.addEventListener('click', (e) => onRowClick(e, tr.dataset.hash, visibleTorrents())); tr.addEventListener('dblclick', () => openDetail(tr.dataset.hash)); tr.addEventListener('contextmenu', (e) => onRowContext(e, tr.dataset.hash)); }); } function emptyRow(span) { return `
No torrents match this filter
`; } function cell(t, k) { switch (k) { case 'name': return `
${t.private ? 'PRIV' : ''} ${f.esc(t.name)}
`; case 'size': return `${f.bytes(t.size)}`; case 'progress': return `${progressBar(t)}`; case 'state': return `${f.stateLabel(t.state)}`; case 'seeds': return `${t.seeds} (${t.seedsTotal})`; case 'peers': return `${t.peers} (${t.peersTotal})`; case 'dlspeed': return `${t.dlspeed ? f.rate(t.dlspeed) : '–'}`; case 'upspeed': return `${t.upspeed ? f.rate(t.upspeed) : '–'}`; case 'eta': return `${f.eta(t.eta)}`; case 'ratio': return `${f.ratio(t.ratio)}`; case 'availability': return `${t.availability.toFixed(2)}`; case 'category': return `${t.category ? `${f.esc(t.category)}` : ''}`; case 'tags': return `
${t.tags.map((x) => `${f.esc(x)}`).join('') || ''}
`; case 'addedOn': return `${f.date(t.addedOn)}`; case 'completionOn': return `${f.date(t.completionOn)}`; case 'savePath': return `${f.esc(t.savePath)}`; case 'priority': return `${t.priority || '—'}`; default: return ''; } } function progressBar(t) { const cls = t.progress >= 1 ? 'done' : t.state.startsWith('paused') ? 'paused' : t.state.startsWith('checking') ? 'check' : (t.state === 'error' || t.state === 'missingFiles') ? 'error' : ''; return `
${f.pct(t.progress)}
`; } /* ---------- selection ---------- */ function onRowClick(e, hash, list) { if (e.shiftKey && state.lastClicked) { const order = list.map((t) => t.hash); const a = order.indexOf(state.lastClicked), b = order.indexOf(hash); if (a > -1 && b > -1) { const [lo, hi] = a < b ? [a, b] : [b, a]; if (!(e.ctrlKey || e.metaKey)) state.selected.clear(); for (let i = lo; i <= hi; i++) state.selected.add(order[i]); } } else if (e.ctrlKey || e.metaKey) { state.selected.has(hash) ? state.selected.delete(hash) : state.selected.add(hash); state.lastClicked = hash; } else { state.selected.clear(); state.selected.add(hash); state.lastClicked = hash; } renderGrid(); // a single-selection click opens/updates the detail panel; multi-select leaves it alone if (state.selected.size === 1) showDetail([...state.selected][0]); } function showDetail(hash) { state.detailHash = hash; const host = document.getElementById('detailHost'); if (host) renderDetailShell(hash, host); } function openDetail(hash) { state.selected.clear(); state.selected.add(hash); state.lastClicked = hash; renderGrid(); showDetail(hash); } function closeDetailPanel() { state.detailHash = null; closeDetail(); const host = document.getElementById('detailHost'); if (host) { host.closest('.torrents-view')?.style.setProperty('--detail-h', '0px'); host.innerHTML = ''; } } /* ===================== feedback (toasts + connection banner) ===================== */ function toast(msg, kind = '') { let host = document.getElementById('toastHost'); if (!host) { host = document.createElement('div'); host.id = 'toastHost'; host.className = 'toast-host'; document.body.appendChild(host); } const el = document.createElement('div'); el.className = `toast ${kind}`; el.textContent = msg; host.appendChild(el); setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 250); }, 3200); } function setBanner(msg) { let b = document.getElementById('connBanner'); if (!msg) { b?.remove(); return; } if (!b) { b = document.createElement('div'); b.id = 'connBanner'; b.className = 'conn-banner'; document.body.appendChild(b); } b.textContent = msg; } // Run an async handler, surfacing failures as a toast instead of an unhandled rejection. function guard(fn, msg) { return Promise.resolve().then(fn).catch((e) => { console.error(e); toast(msg || 'Something went wrong', 'err'); }); } /* ===================== actions ===================== */ async function doAction(action, hashes) { hashes = hashes || [...state.selected]; if (!hashes.length) return; if (action === 'delete') return confirmDelete(hashes); try { if (action === 'altspeed') { await api.toggleAltSpeed(); syncAltToggle(); return; } await api.action(action, hashes); } catch (e) { console.error(e); toast(`Action failed: ${action}`, 'err'); } } function syncAltToggle() { const btn = document.querySelector('[data-act="altspeed"]'); if (btn) btn.classList.toggle('on', !!state.snapshot.server?.alt_speed_enabled); } async function confirmDelete(hashes) { openModal(`Remove ${hashes.length} torrent(s)?`, `

Choose whether to also delete the downloaded data from disk.

`, [{ label: 'Cancel', act: closeModal }, { label: 'Remove', cls: 'danger', primary: true, act: () => guard(async () => { const delFiles = document.getElementById('delFiles').checked; await api.remove(hashes, delFiles); hashes.forEach((h) => state.selected.delete(h)); if (hashes.includes(state.detailHash)) closeDetailPanel(); closeModal(); }, 'Failed to remove torrent(s)'), }]); } /* ---------- add torrent modal ---------- */ // Default download location for a category: its own save path if set, // otherwise the global default. function categoryDefaultPath(catName) { const c = state.meta.categories.find((x) => x.name === catName); if (c && c.savePath) return c.savePath; return state.meta.preferences.save_path || '/data/downloads'; } function openAddModal() { const cats = '' + state.meta.categories.filter((c) => c.name).map((c) => ``).join(''); const tagOpts = state.meta.tags.map((t) => ``).join(''); openModal('Add torrent', `
Category default — click to change.
`, [{ label: 'Cancel', act: closeModal }, { label: 'Add', primary: true, act: () => guard(async () => { const magnet = document.getElementById('addMagnet').value.trim(); const common = { category: document.getElementById('addCat').value, tags: [...document.querySelectorAll('#addTagsList input:checked')].map((c) => c.value), savePath: document.getElementById('addPath').value.trim(), paused: document.getElementById('addPaused').checked, seqDl: document.getElementById('addSeq').checked, skipCheck: document.getElementById('addSkip').checked, }; const files = [...document.getElementById('addFile').files]; if (files.length) { for (const file of files) await api.add({ ...common, ...(await readTorrentFile(file)) }); toast(`Added ${files.length} torrent(s)`, 'ok'); } else if (magnet) { await api.add({ ...common, magnet }); toast('Torrent added', 'ok'); } closeModal(); }, 'Failed to add torrent'), }]); // tags checkbox dropdown: a fixed-position menu that floats over the dialog (() => { const btn = document.getElementById('addTagsBtn'); const menu = document.getElementById('addTagsMenu'); const list = document.getElementById('addTagsList'); const label = document.getElementById('addTagsLabel'); const input = document.getElementById('addTagNew'); const refresh = () => { const sel = [...list.querySelectorAll('input:checked')].map((c) => c.value); label.textContent = sel.length ? sel.join(', ') : 'No tags selected'; }; const addTag = () => { const name = input.value.trim(); if (!name) return; let cb = [...list.querySelectorAll('input')].find((c) => c.value === name); if (!cb) { const ph = list.querySelector('.dim'); if (ph) ph.remove(); const lbl = document.createElement('label'); lbl.className = 'cbdrop-item'; lbl.innerHTML = ''; cb = lbl.querySelector('input'); cb.value = name; lbl.querySelector('span').textContent = name; list.appendChild(lbl); } cb.checked = true; input.value = ''; refresh(); place(); }; // Anchor the floating menu under the button and keep it on-screen. const place = () => { const r = btn.getBoundingClientRect(); menu.style.left = r.left + 'px'; menu.style.width = r.width + 'px'; menu.style.top = (r.bottom + 4) + 'px'; menu.style.maxHeight = Math.max(120, window.innerHeight - r.bottom - 16) + 'px'; }; let onOutside; let onReflow; const close = () => { menu.hidden = true; document.removeEventListener('mousedown', onOutside, true); document.removeEventListener('scroll', onReflow, true); window.removeEventListener('resize', onReflow); }; const open = () => { menu.hidden = false; place(); onOutside = (e) => { if (!menu.contains(e.target) && !btn.contains(e.target)) close(); }; onReflow = () => place(); document.addEventListener('mousedown', onOutside, true); document.addEventListener('scroll', onReflow, true); // reposition on modal scroll window.addEventListener('resize', onReflow); }; btn.addEventListener('click', () => (menu.hidden ? open() : close())); list.addEventListener('change', refresh); document.getElementById('addTagAdd').addEventListener('click', addTag); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }); })(); // save path: greyed default that tracks the category until the user edits it (() => { const path = document.getElementById('addPath'); const cat = document.getElementById('addCat'); const hint = document.getElementById('addPathHint'); let manual = false; const enable = () => { if (manual) return; manual = true; path.readOnly = false; path.classList.remove('path-default'); hint.textContent = "Custom location — won't change with the category."; path.focus(); path.select(); }; path.addEventListener('mousedown', (e) => { if (!manual) { e.preventDefault(); enable(); } }); cat.addEventListener('change', () => { if (!manual) path.value = categoryDefaultPath(cat.value); }); })(); // live readout of the chosen file(s) document.getElementById('addFile').addEventListener('change', async (e) => { const info = document.getElementById('addFileInfo'); const files = [...e.target.files]; if (!files.length) { info.textContent = ''; return; } const metas = await Promise.all(files.map(readTorrentFile)); info.innerHTML = metas.map((m) => `📄 ${f.esc(m.name)} — ${f.bytes(m.size)} · ${m.pieceCount} pieces · ${(m.files || []).length || 1} file(s)`).join('
'); }); } /* ---------- per-torrent location / rate / share limits ---------- */ function promptLocation() { const hashes = [...state.selected]; if (!hashes.length) return; const cur = state.snapshot.torrents.find((t) => t.hash === hashes[0]); openModal('Set location', `
${f.esc(cur?.savePath || '—')}

Moves the torrent's files now. Unchecked, files you moved individually keep their relative path (or stay put if moved elsewhere). Applies to ${hashes.length} torrent(s).

`, [{ label: 'Cancel', act: closeModal }, { label: 'Apply', primary: true, act: () => guard(async () => { const savePath = document.getElementById('locPath').value.trim(); const reset = document.getElementById('locReset').checked; if (savePath) await api.action('setSavePath', hashes, { savePath, reset }); closeModal(); }, 'Failed to set location'), }]); setTimeout(() => document.getElementById('locPath')?.focus(), 0); } function promptRateLimit(dir) { const hashes = [...state.selected]; if (!hashes.length) return; const action = dir === 'down' ? 'setDownLimit' : 'setUpLimit'; openModal(`Limit ${dir === 'down' ? 'download' : 'upload'} rate · ${hashes.length} torrent(s)`, `
`, [{ label: 'Cancel', act: closeModal }, { label: 'Apply', primary: true, act: () => guard(async () => { const kib = Math.max(0, parseInt(document.getElementById('rateVal').value, 10) || 0); await api.action(action, hashes, { limit: kib * 1024 }); closeModal(); }, 'Failed to set rate limit'), }]); setTimeout(() => document.getElementById('rateVal')?.focus(), 0); } function promptShareLimit() { const hashes = [...state.selected]; if (!hashes.length) return; openModal(`Set share limit · ${hashes.length} torrent(s)`, `

−1 = use global · 0 = unlimited

`, [{ label: 'Cancel', act: closeModal }, { label: 'Apply', primary: true, act: () => guard(async () => { const v = document.getElementById('ratioVal').value.trim(); const limit = v === '' ? -1 : parseFloat(v); await api.action('setRatioLimit', hashes, { limit: Number.isNaN(limit) ? -1 : limit }); closeModal(); }, 'Failed to set share limit'), }]); setTimeout(() => document.getElementById('ratioVal')?.focus(), 0); } // Minimal bencode decoder (operates on a Uint8Array) — enough to read a .torrent's // info dict client-side so an uploaded file shows realistic name/size/pieces. function bdecode(buf) { let i = 0; const td = new TextDecoder('utf-8'); const readUntil = (term) => { let s = ''; while (buf[i] !== term) s += String.fromCharCode(buf[i++]); i++; return s; }; const parseStr = () => { const len = parseInt(readUntil(0x3a), 10); const out = buf.subarray(i, i + len); i += len; return out; }; const parse = () => { const c = buf[i]; if (c === 0x69) { i++; return parseInt(readUntil(0x65), 10); } // i…e if (c === 0x6c) { i++; const a = []; while (buf[i] !== 0x65) a.push(parse()); i++; return a; } // l…e if (c === 0x64) { i++; const o = {}; while (buf[i] !== 0x65) { const k = td.decode(parseStr()); o[k] = parse(); } i++; return o; } // d…e return parseStr(); }; return parse(); } function bytesToBase64(bytes) { let bin = ''; const chunk = 0x8000; // avoid arg-count limits on String.fromCharCode for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); return btoa(bin); } async function readTorrentFile(file) { const td = new TextDecoder('utf-8'); const bytes = new Uint8Array(await file.arrayBuffer()); // The raw .torrent bytes are what the server actually adds; the parsed // fields below are only for the modal's preview readout. const data = bytesToBase64(bytes); try { const meta = bdecode(bytes); const info = meta.info; const name = td.decode(info.name); const pieceSize = info['piece length']; const pieceCount = Math.round((info.pieces?.length || 0) / 20) || undefined; let size, files; if (info.length != null) { size = info.length; files = [{ name, size }]; } else { files = (info.files || []).map((fl) => ({ name: `${name}/${fl.path.map((p) => td.decode(p)).join('/')}`, size: fl.length })); size = files.reduce((a, fl) => a + fl.size, 0); } return { name, size, pieceSize, pieceCount, files, data }; } catch { // Not parseable as bencode — still upload the bytes; the server validates. return { name: file.name.replace(/\.torrent$/i, ''), data }; } } /* ===================== context menu ===================== */ function onRowContext(e, hash) { e.preventDefault(); if (!state.selected.has(hash)) { state.selected.clear(); state.selected.add(hash); renderGrid(); } const n = state.selected.size; const items = [ { label: 'Resume', sc: '', act: () => doAction('resume') }, { label: 'Pause', sc: '', act: () => doAction('pause') }, { label: 'Force start', act: () => doAction('forceStart') }, { label: 'Force recheck', act: () => doAction('recheck') }, { label: 'Reannounce', act: () => doAction('reannounce') }, { sep: true }, { sub: 'Queue' }, { label: 'Move to top', act: () => doAction('topPriority') }, { label: 'Move up', act: () => doAction('increasePriority') }, { label: 'Move down', act: () => doAction('decreasePriority') }, { label: 'Move to bottom', act: () => doAction('bottomPriority') }, { sep: true }, { label: 'Toggle sequential', act: () => doAction('toggleSeqDl') }, { label: 'Toggle super seeding', act: () => doAction('toggleSuperSeeding') }, { label: 'Set category…', act: () => promptCategory() }, { label: 'Edit tags…', act: () => promptTags() }, { sep: true }, { sub: 'Limits & location' }, { label: 'Set location…', act: () => promptLocation() }, { label: 'Limit download rate…', act: () => promptRateLimit('down') }, { label: 'Limit upload rate…', act: () => promptRateLimit('up') }, { label: 'Set share limit…', act: () => promptShareLimit() }, { sep: true }, { label: 'Properties', sc: '⏎', act: () => openDetail(hash) }, { sep: true }, { label: `Remove ${n > 1 ? `(${n})` : ''}`, danger: true, sc: 'Del', act: () => doAction('delete') }, ]; showContextMenu(e.clientX, e.clientY, items); } function promptCategory() { const cur = state.snapshot?.torrents?.find((t) => t.hash === [...state.selected][0])?.category || ''; const sel = (v) => (v === cur ? ' selected' : ''); const opts = `` + state.meta.categories.filter((c) => c.name).map((c) => ``).join(''); openModal('Set category', `

Need a new one? Use + next to “Categories” in the sidebar.

`, [{ label: 'Cancel', act: closeModal }, { label: 'Apply', primary: true, act: async () => { await api.action('setCategory', [...state.selected], { category: document.getElementById('catSel').value }); closeModal(); }, }]); } /* ---------- create / delete categories & tags ---------- */ function openCreateCategory() { openModal('New category', `
`, [{ label: 'Cancel', act: closeModal }, { label: 'Create', primary: true, act: async () => { const name = document.getElementById('catName').value.trim(); if (!name) return closeModal(); await api.createCategory(name, document.getElementById('catPath').value.trim()); await refreshMeta(); closeModal(); }, }]); setTimeout(() => document.getElementById('catName')?.focus(), 0); } function openEditCategory(value) { const isUncat = value === ''; const cur = state.meta.categories.find((c) => c.name === value); const curPath = cur ? (cur.savePath || '') : ''; const nameField = isUncat ? `
` : `
`; openModal('Edit category', ` ${nameField}
`, [{ label: 'Cancel', act: closeModal }, { label: 'Save', primary: true, act: async () => { const newName = isUncat ? '' : document.getElementById('catName').value.trim(); if (!isUncat && !newName) return closeModal(); const savePath = document.getElementById('catPath').value.trim(); await api.editCategory(value, newName, savePath); await refreshMeta(); if (!isUncat && newName !== value && state.filter.type === 'category' && state.filter.value === value) state.filter = { type: 'category', value: newName }; renderView(); closeModal(); }, }]); setTimeout(() => document.getElementById('catName')?.focus(), 0); } function openCreateTag() { openModal('New tag', `
`, [{ label: 'Cancel', act: closeModal }, { label: 'Create', primary: true, act: async () => { const name = document.getElementById('tagName').value.trim(); if (!name) return closeModal(); await api.createTag(name); await refreshMeta(); closeModal(); }, }]); setTimeout(() => document.getElementById('tagName')?.focus(), 0); } function confirmDeleteMeta(type, value) { const label = type === 'category' ? `category “${value || 'Uncategorized'}”` : `tag “${value}”`; openModal(`Delete ${type}`, `

Remove the ${label}? It will be unassigned from all torrents. Downloaded files are not affected.

`, [{ label: 'Cancel', act: closeModal }, { label: 'Delete', cls: 'danger', primary: true, act: async () => { if (type === 'category') await api.deleteCategory(value); else await api.deleteTag(value); await refreshMeta(); if (state.filter.type === type && state.filter.value === value) state.filter = { type: 'status', value: 'all' }; renderView(); closeModal(); }, }]); } // Add/remove tags on the current selection (with inline create). function promptTags() { const hashes = [...state.selected]; if (!hashes.length) return; const torrents = state.snapshot.torrents.filter((t) => hashes.includes(t.hash)); const tagRow = (tag) => { const have = torrents.filter((t) => t.tags.includes(tag)).length; const some = have > 0 && have < torrents.length; return ``; }; openModal(`Edit tags · ${hashes.length} torrent(s)`, `
${state.meta.tags.map(tagRow).join('') || 'No tags yet — add one above.'}
`, [{ label: 'Cancel', act: closeModal }, { label: 'Apply', primary: true, act: async () => { const boxes = [...document.querySelectorAll('#tagList [data-tag]')]; const toAdd = boxes.filter((b) => b.checked).map((b) => b.dataset.tag); const toRemove = boxes.filter((b) => !b.checked).map((b) => b.dataset.tag); if (toAdd.length) await api.action('addTags', hashes, { tags: toAdd }); if (toRemove.length) await api.action('removeTags', hashes, { tags: toRemove }); closeModal(); }, }]); const addNew = async () => { const inp = document.getElementById('newTag'); const v = inp.value.trim(); if (!v) return; await api.createTag(v); await refreshMeta(); const list = document.getElementById('tagList'); if (!list.querySelector(`[data-tag="${CSS.escape(v)}"]`)) { if (list.querySelector('span.dim') && !list.querySelector('[data-tag]')) list.innerHTML = ''; const lbl = document.createElement('label'); lbl.className = 'check-row'; lbl.innerHTML = `${f.esc(v)}`; list.appendChild(lbl); } inp.value = ''; inp.focus(); }; document.getElementById('addTagBtn').addEventListener('click', addNew); document.getElementById('newTag').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }); setTimeout(() => document.getElementById('newTag')?.focus(), 0); } function showContextMenu(x, y, items) { closeContextMenu(); const menu = document.createElement('div'); menu.className = 'ctxmenu'; menu.id = 'ctxmenu'; menu.innerHTML = items.map((it) => { if (it.sep) return '
'; if (it.sub) return `
${it.sub}
`; return `
${it.label}${it.sc || ''}
`; }).join(''); document.body.appendChild(menu); const r = menu.getBoundingClientRect(); menu.style.left = `${Math.min(x, window.innerWidth - r.width - 6)}px`; menu.style.top = `${Math.min(y, window.innerHeight - r.height - 6)}px`; menu.querySelectorAll('.mi').forEach((mi) => mi.addEventListener('click', () => { closeContextMenu(); items[+mi.dataset.idx].act(); })); } function closeContextMenu() { document.getElementById('ctxmenu')?.remove(); } /* ===================== RSS view ===================== */ async function renderRssView(host, seq) { host.innerHTML = '
Loading feeds…
'; const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]); if (seq !== viewRenderSeq || state.view !== 'rss') return; host.innerHTML = `

Feeds

${feeds.map((fd) => `
📡 ${f.esc(fd.name)}${f.ago(fd.lastUpdate)}
${f.esc(fd.url)}
`).join('
')}

Unread articles

${feeds.flatMap((fd) => fd.articles.map((a) => ``)).join('')}
FeedTitleSizePublished
${f.esc(fd.name)}${f.esc(a.title)} ${f.bytes(a.size)}${f.ago(a.date)} ${a.isRead ? 'read' : '● new'}

Auto-download rules

${rules.map((r) => renderRule(r)).join('')}
`; } function renderRule(r) { return `
${f.esc(r.name)} ${r.enabled ? 'enabled' : 'disabled'} ${r.useRegex ? 'regex' : ''} ${r.addPaused ? 'add paused' : ''}
must contain ${f.esc(r.mustContain)}
${r.mustNotContain ? `
must not contain ${f.esc(r.mustNotContain)}
` : ''}
→ category ${f.esc(r.assignedCategory)} · save to ${f.esc(r.savePath)}
feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}
`; } /* ===================== Automation view ===================== */ function highlightLua(source) { const tokenRe = /--\[\[[\s\S]*?\]\]|\[\[[\s\S]*?\]\]|--[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b|\b\d+(?:\.\d+)?\b|\b(?:naut|event)\b/g; let out = ''; let last = 0; let m; while ((m = tokenRe.exec(source)) !== null) { const token = m[0]; out += f.esc(source.slice(last, m.index)); let cls = 'tok-keyword'; if (token.startsWith('--')) cls = 'tok-comment'; else if (token[0] === '"' || token[0] === "'" || token.startsWith('[[')) cls = 'tok-string'; else if (/^\d/.test(token)) cls = 'tok-number'; else if (token === 'naut' || token === 'event') cls = 'tok-api'; out += `${f.esc(token)}`; last = tokenRe.lastIndex; } out += f.esc(source.slice(last)); return out; } function syncScriptHighlight(editor, highlight) { highlight.innerHTML = `${highlightLua(editor.value)}\n`; highlight.parentElement.scrollTop = editor.scrollTop; highlight.parentElement.scrollLeft = editor.scrollLeft; } // One form control per declared setting, keyed for collection on save. function settingField(s) { const key = f.esc(s.key); const label = f.esc(s.label || s.key); const val = s.value ?? s.default ?? ''; if (s.type === 'bool') { const on = String(val) === 'true' || String(val) === '1'; return ``; } const inputType = s.type === 'number' ? 'number' : 'text'; return ``; } function settingsPanel(settings) { const list = Array.isArray(settings) ? settings : []; const body = list.length ? list.map(settingField).join('') : `
This script exposes no settings. Call naut.define_settings{…} in the script to add configurable variables here.
`; return ``; } async function renderAutomationView(host, seq) { host.innerHTML = '
Loading automation script…
'; let script; try { script = await api.script(); } catch (e) { if (seq !== viewRenderSeq || state.view !== 'automation') return; host.innerHTML = '
Unable to load script status
'; return; } if (seq !== viewRenderSeq || state.view !== 'automation') return; const stat = (label, value) => `
${label}${value ?? 0}
`; const source = script.source || ''; host.innerHTML = `

Automation Script

${script.loaded ? f.esc(script.path || 'loaded') : 'No script loaded'}
${stat('Queued', script.queued)} ${stat('Handled', script.handled)} ${stat('Dropped', script.dropped)} ${stat('Errors', script.errors)} ${stat('Move requests', script.move_requests)}
${script.last_error ? `
Last error${f.esc(script.last_error)}
` : ''}
${settingsPanel(script.settings)}
`; bindSettingsPanel(script); const editor = document.getElementById('scriptEditor'); const highlight = document.getElementById('scriptHighlight'); const saveBtn = document.getElementById('saveScript'); const refreshBtn = document.getElementById('refreshScript'); const status = document.getElementById('scriptSaveStatus'); let initialSource = script.loaded ? source : editor.value; let saving = false; const setStatus = (message, kind = '') => { status.textContent = message; status.className = `script-save-status ${kind}`; }; const updateDirty = (preserveStatus = false) => { const dirty = editor.value !== initialSource; saveBtn.disabled = !script.loaded || !dirty || saving; if (!saving && !preserveStatus) setStatus(dirty ? 'Unsaved changes' : '', dirty ? 'dirty' : ''); }; syncScriptHighlight(editor, highlight); editor.addEventListener('input', () => { syncScriptHighlight(editor, highlight); updateDirty(); }); editor.addEventListener('scroll', () => syncScriptHighlight(editor, highlight)); editor.addEventListener('keydown', (e) => { if (e.key === 'Tab') { e.preventDefault(); editor.setRangeText(' ', editor.selectionStart, editor.selectionEnd, 'end'); editor.dispatchEvent(new Event('input')); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); if (!saveBtn.disabled) saveBtn.click(); } }); saveBtn.addEventListener('click', async () => { saving = true; saveBtn.disabled = true; setStatus('Saving…'); try { const updated = await api.saveScript(editor.value); initialSource = updated.source || editor.value; editor.value = initialSource; syncScriptHighlight(editor, highlight); script = updated; saving = false; updateDirty(true); setStatus('Saved', 'ok'); toast('Script saved', 'ok'); } catch (e) { console.error(e); saving = false; updateDirty(true); setStatus('Save failed', 'err'); toast('Script save failed', 'err'); } }); refreshBtn.addEventListener('click', () => renderAutomationView(host, viewRenderSeq)); updateDirty(); } // Wire the script-settings form: collect values by key and save them, without // touching the script source. function bindSettingsPanel(script) { const form = document.getElementById('settingsForm'); const saveBtn = document.getElementById('saveSettings'); const status = document.getElementById('settingsSaveStatus'); if (!form || !saveBtn) return; const setStatus = (msg, kind = '') => { status.textContent = msg; status.className = `script-save-status ${kind}`; }; const collect = () => { const out = {}; form.querySelectorAll('[data-skey]').forEach((el) => { out[el.dataset.skey] = el.dataset.stype === 'bool' ? (el.checked ? 'true' : 'false') : el.value; }); return out; }; form.addEventListener('input', () => setStatus('Unsaved changes', 'dirty')); saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; setStatus('Saving…'); try { await api.saveScriptSettings(collect()); setStatus('Saved', 'ok'); toast('Settings saved', 'ok'); } catch (e) { console.error(e); setStatus('Save failed', 'err'); toast('Settings save failed', 'err'); } finally { saveBtn.disabled = false; } }); } /* ===================== Search view ===================== */ function renderSearchView(host) { const plugins = state.meta.searchPlugins.map((p) => `${f.esc(p.name)}`).join(' '); host.innerHTML = `
Indexers: ${plugins}
${state.searchResults ? searchTable(state.searchResults) : '
Enter a query to search configured indexers
'}
`; const input = document.getElementById('searchInput'); const run = async () => { state.searchQuery = input.value.trim(); document.getElementById('searchResults').innerHTML = '
Searching…
'; state.searchResults = await api.search(state.searchQuery); document.getElementById('searchResults').innerHTML = searchTable(state.searchResults); bindSearchRows(); }; document.getElementById('searchBtn').addEventListener('click', run); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); input.focus(); bindSearchRows(); } function searchTable(rows) { if (!rows.length) return '
No results
'; return ` ${rows.map((r, i) => ``).join('')}
NameSizeSeedsLeeches EnginePublished
${f.esc(r.name)}${f.bytes(r.size)} ${r.seeds}${r.leeches} ${f.esc(r.engine)}${f.ago(r.pubDate)}
`; } function bindSearchRows() { document.querySelectorAll('[data-sr]').forEach((b) => b.addEventListener('click', async () => { const r = state.searchResults[+b.dataset.sr]; await api.add({ name: r.name }); b.textContent = '✓ Added'; b.disabled = true; })); } /* ===================== Engine/settings view ===================== */ function renderSettingsView(host) { const p = state.meta.preferences; const card = (title, rows) => `

${title}

${rows.map(([k, v]) => `
${k}${v}
`).join('')}
`; host.innerHTML = `
${card('Bandwidth', [ ['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'], ['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'], ['Alt download limit', f.rate(p.alt_dl_limit)], ['Alt upload limit', f.rate(p.alt_up_limit)], ['Alt limits active', p.alt_speed_enabled ? 'yes' : 'no'], ])} ${card('Connections', [ ['Global max connections', p.max_connec], ['Max per torrent', p.max_connec_per_torrent], ['Max upload slots', p.max_uploads], ['Listen port', p.listen_port], ['UPnP / NAT-PMP', p.upnp ? 'on' : 'off'], ['µTP enabled', p.utp ? 'on' : 'off'], ])} ${card('Queueing', [ ['Queueing enabled', p.queueing_enabled ? 'on' : 'off'], ['Max active downloads', p.max_active_downloads], ['Max active uploads', p.max_active_uploads], ['Max active torrents', p.max_active_torrents], ])} ${card('Privacy / BitTorrent', [ ['DHT', p.dht ? 'on' : 'off'], ['Peer Exchange (PeX)', p.pex ? 'on' : 'off'], ['Local Peer Discovery', p.lsd ? 'on' : 'off'], ['Encryption', ['Prefer encryption', 'Require encryption', 'Disable encryption'][p.encryption]], ])} ${card('Paths', [ ['Default save path', p.save_path], ['RSS scan interval', `${p.scan_interval} min`], ])}

This is a stubbed engine view — values are read from the mock server. Wire these to a real client's API (qBittorrent WebAPI, Transmission RPC, Deluge JSON-RPC) to make them editable.

`; } /* ===================== status bar ===================== */ function renderStatusbar() { const s = state.snapshot.server || {}; const el = document.getElementById('statusbar'); el.innerHTML = ` ${s.connection_status || 'connecting'} DHT: ${s.dht_nodes ?? '–'} nodes Port: ${s.listen_port ?? '–'} Active: ${s.active_torrents ?? 0}/${s.total_torrents ?? 0} Session ▼ ${f.bytes(s.dl_info_data)}${f.bytes(s.up_info_data)} Global ratio: ${f.ratio(s.global_ratio)} Cache hit: ${s.read_cache_hits ?? '–'}% Queued I/O: ${s.queued_io_jobs ?? 0} Free space: ${f.bytes(s.free_space)}`; } function updateRates() { const s = state.snapshot.server || {}; const dl = document.getElementById('globalDl'); const up = document.getElementById('globalUp'); if (dl) dl.textContent = f.rate(s.dl_info_speed) + (s.dl_rate_limit ? ` /${f.rate(s.dl_rate_limit)}` : ''); if (up) up.textContent = f.rate(s.up_info_speed) + (s.up_rate_limit ? ` /${f.rate(s.up_rate_limit)}` : ''); } /* ===================== modal ===================== */ function openModal(title, bodyHtml, buttons) { const back = document.getElementById('modalBackdrop'); const modal = document.getElementById('modal'); modal.innerHTML = `

${f.esc(title)}

${bodyHtml}
${buttons.map((b, i) => ``).join('')}
`; back.hidden = false; modal.querySelectorAll('[data-mb]').forEach((b) => b.addEventListener('click', () => buttons[+b.dataset.mb].act())); } function closeModal() { document.getElementById('modalBackdrop').hidden = true; } /* ===================== view tabs ===================== */ function renderPluginViewTabs() { const nav = document.getElementById('viewtabs'); for (const view of pluginViews()) { if (nav.querySelector(`[data-view="${CSS.escape(view.id)}"]`)) continue; const button = document.createElement('button'); button.className = 'vtab plugin-vtab'; button.dataset.view = view.id; button.textContent = view.label; nav.appendChild(button); } } function switchViewTab(view) { state.view = view; document.querySelectorAll('.vtab').forEach((b) => b.classList.toggle('active', b.dataset.view === view)); renderView(); } /* ===================== global bindings ===================== */ function bindGlobal() { document.querySelectorAll('.toolbar [data-act]').forEach((b) => b.addEventListener('click', () => { const a = b.dataset.act; if (a === 'logout') return guard(async () => { await api.logout(); location.reload(); }, 'Failed to sign out'); if (a === 'add') return openAddModal(); doAction(a); })); document.getElementById('viewtabs').addEventListener('click', (e) => { const tab = e.target.closest('.vtab[data-view]'); if (tab) switchViewTab(tab.dataset.view); }); const qf = document.getElementById('quickFilter'); qf.addEventListener('input', () => { state.quick = qf.value; if (state.view === 'torrents') renderGrid(); }); document.addEventListener('click', (e) => { if (!e.target.closest('#ctxmenu')) closeContextMenu(); }); document.getElementById('modalBackdrop').addEventListener('click', (e) => { if (e.target.id === 'modalBackdrop') closeModal(); }); // detail close + esc handling delegated document.addEventListener('click', (e) => { if (e.target.closest('[data-act="closeDetail"]')) closeDetailPanel(); }); document.addEventListener('keydown', (e) => { const typing = /input|textarea|select/i.test(document.activeElement.tagName); if (e.key === 'Escape') { closeContextMenu(); if (!document.getElementById('modalBackdrop').hidden) return closeModal(); if (state.detailHash) return closeDetailPanel(); } if (typing) return; if (e.key === '/') { e.preventDefault(); qf.focus(); } else if (e.key.toLowerCase() === 'n') openAddModal(); else if (e.key === 'Delete') doAction('delete'); else if (e.key === ' ') { e.preventDefault(); // toggle pause/resume on selection const sel = state.snapshot.torrents.filter((t) => state.selected.has(t.hash)); const anyRunning = sel.some((t) => !t.state.startsWith('paused')); doAction(anyRunning ? 'pause' : 'resume'); } else if (e.key === 'Enter' && state.selected.size === 1) { openDetail([...state.selected][0]); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && state.view === 'torrents') { e.preventDefault(); visibleTorrents().forEach((t) => state.selected.add(t.hash)); renderGrid(); } }); } boot();