// 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 = `
`;
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 = `
${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 (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 = '';
}
/* ---------- 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.
Also delete files on 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 ---------- */
function openAddModal() {
const cats = 'Uncategorized ' +
state.meta.categories.map((c) => `${f.esc(c.name || 'Uncategorized')} `).join('');
const tagOpts = state.meta.tags.map((t) =>
`${f.esc(t)} `).join('');
openModal('Add torrent', `
Magnet link / URL
Category ${cats}
Save path
Add paused
Sequential download
Skip hash check
`,
[{ 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(); } });
})();
// 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', `
Current location
${f.esc(cur?.savePath || '—')}
New save path
Reset files to their original paths
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)`, `
Rate (KiB/s — 0 = unlimited)
`,
[{ 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)`, `
Seeding ratio limit
−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 = `Uncategorized ` +
state.meta.categories.map((c) => `${f.esc(c.name || 'Uncategorized')} `).join('');
openModal('Set category', `
Category ${opts}
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', `
Name
Save path
`,
[{ 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 openCreateTag() {
openModal('New tag', `Name
`,
[{ 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 `
${f.esc(tag)} ${some ? ' · on some ' : ''} `;
};
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 = '';
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
Feed Title Size Published
${feeds.flatMap((fd) => fd.articles.map((a) => `
${f.esc(fd.name)} ${f.esc(a.title)}
${f.bytes(a.size)} ${f.ago(a.date)}
${a.isRead ? 'read ' : '● new '} `)).join('')}
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 `
${label}
`;
}
const inputType = s.type === 'number' ? 'number' : 'text';
return `
${label}
`;
}
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 `
Settings
Configure the script without editing it
Save
${body}
`;
}
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'}
Save
Revert
${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 = `
Search
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 `
Name Size Seeds Leeches
Engine Published
${rows.map((r, i) => `
${f.esc(r.name)} ${f.bytes(r.size)}
${r.seeds} ${r.leeches}
${f.esc(r.engine)} ${f.ago(r.pubDate)}
+ Add `).join('')}
`;
}
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}
`;
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();