Naut-Plugin-WebUI/public/js/app.js
ookami125 0b35571565 Make existing controls real + add robustness pass
Wire up controls that were rendered but inert, expose unused server
actions, and harden the live update path.

Controls now functional:
- File priority dropdowns (Content tab) now persist via new
  setFilePriority server action
- Context menu: Set location, Limit download/upload rate, Set share
  limit (wire setSavePath/setDownLimit/setUpLimit/setRatioLimit)
- Trackers sidebar filter actually filters (snapshot now carries
  per-torrent trackerHosts)

Robustness:
- Grid updates cells in place when row order/columns are unchanged
  (preserves text selection/scroll, no per-second listener re-bind)
- Boot retries until the server responds; SSE drops show a
  "Reconnecting" banner; failed actions/add/delete surface a toast
- Prune selection/detail for torrents that disappear
- Drop Backspace-as-delete (kept Delete)

Cleanup:
- Remove pseudo DHT/PeX/LSD names from the real-tracker host pool in
  mock data (they leaked into announce URLs and the sidebar)
- Remove dead detailTab import/export

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:24:20 -04:00

944 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// NAUT — main application controller.
// Wires the live stream into the sidebar, torrent grid, detail panel,
// status bar, RSS/Search/Engine views, selection, context menu, hotkeys.
import { api } from './api.js';
import * as f from './format.js';
import { renderDetailShell, closeDetail } from './detail.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: '',
};
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() {
bindGlobal();
await loadMeta(); // retries until the server answers
syncAltToggle();
renderSidebar();
renderView();
api.stream(onSnapshot, onStatus);
}
async function loadMeta() {
for (;;) {
try { state.meta = await api.meta(); return; }
catch { onStatus('reconnecting'); await new Promise((r) => setTimeout(r, 2000)); }
}
}
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('');
el.innerHTML = `
<div class="side-group"><div class="side-head">Status</div>${statusItems}</div>
<div class="side-group"><div class="side-head">Categories<button class="side-add" data-add="category" title="New category">+</button></div>${cats}</div>
<div class="side-group"><div class="side-head">Tags<button class="side-add" data-add="tag" title="New tag">+</button></div>${tags || '<div class="side-item dim"><span class="lbl">none</span></div>'}</div>
<div class="side-group"><div class="side-head">Trackers</div>${trackers}</div>`;
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(); }));
}
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 `<div class="side-item ${active ? 'active' : ''}" data-type="${type}" data-value="${f.esc(value)}" data-removable="${removable ? 1 : 0}">
<span class="ic">${ic}</span><span class="lbl">${f.esc(label)}</span><span class="cnt">${cnt}</span></div>`;
}
async function refreshMeta() {
state.meta = await api.meta();
renderSidebar();
}
/* ===================== views ===================== */
function renderView() {
const host = document.getElementById('viewHost');
if (state.view === 'torrents') return renderTorrentsView(host);
if (state.view === 'rss') return renderRssView(host);
if (state.view === 'search') return renderSearchView(host);
if (state.view === 'settings') return renderSettingsView(host);
}
/* ---------- torrents view ---------- */
function renderTorrentsView(host) {
host.innerHTML = `
<div class="torrents-view">
<div class="grid-wrap" id="gridWrap"></div>
<div id="detailHost"></div>
</div>`;
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 ? `<span class="sort">${state.sort.dir < 0 ? '▾' : '▴'}</span>` : '';
return `<th data-col="${k}" class="${c.num ? 'num' : ''}" ${c.w ? `style="width:${c.w}"` : ''}>${c.label}${arrow}</th>`;
}).join('');
const rows = list.map((t) => `<tr data-hash="${t.hash}" class="${state.selected.has(t.hash) ? 'sel' : ''}">${cols.map((k) => cell(t, k)).join('')}</tr>`).join('');
wrap.innerHTML = `<table class="grid"><thead><tr>${head}</tr></thead><tbody>${rows || emptyRow(cols.length)}</tbody></table>`;
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 `<tr><td colspan="${span}"><div class="empty" style="height:200px">No torrents match this filter</div></td></tr>`;
}
function cell(t, k) {
switch (k) {
case 'name': return `<td><div class="name-cell">
<span class="state-dot" style="color:${f.stateColor(t.state)};background:${f.stateColor(t.state)}"></span>
${t.private ? '<span class="priv-badge">PRIV</span>' : ''}
<span title="${f.esc(t.name)}">${f.esc(t.name)}</span></div></td>`;
case 'size': return `<td class="num">${f.bytes(t.size)}</td>`;
case 'progress': return `<td>${progressBar(t)}</td>`;
case 'state': return `<td class="s-${t.state}">${f.stateLabel(t.state)}</td>`;
case 'seeds': return `<td class="num">${t.seeds}<span class="faint"> (${t.seedsTotal})</span></td>`;
case 'peers': return `<td class="num">${t.peers}<span class="faint"> (${t.peersTotal})</span></td>`;
case 'dlspeed': return `<td class="num" style="color:${t.dlspeed ? 'var(--dl)' : 'var(--txt-faint)'}">${t.dlspeed ? f.rate(t.dlspeed) : ''}</td>`;
case 'upspeed': return `<td class="num" style="color:${t.upspeed ? 'var(--up)' : 'var(--txt-faint)'}">${t.upspeed ? f.rate(t.upspeed) : ''}</td>`;
case 'eta': return `<td class="num dim">${f.eta(t.eta)}</td>`;
case 'ratio': return `<td class="num">${f.ratio(t.ratio)}</td>`;
case 'availability': return `<td class="num dim">${t.availability.toFixed(2)}</td>`;
case 'category': return `<td>${t.category ? `<span class="cat-chip">${f.esc(t.category)}</span>` : '<span class="faint">—</span>'}</td>`;
case 'tags': return `<td><div class="tags-cell">${t.tags.map((x) => `<span class="tag">${f.esc(x)}</span>`).join('') || '<span class="faint">—</span>'}</div></td>`;
case 'addedOn': return `<td class="num dim">${f.date(t.addedOn)}</td>`;
case 'completionOn': return `<td class="num dim">${f.date(t.completionOn)}</td>`;
case 'savePath': return `<td class="dim">${f.esc(t.savePath)}</td>`;
case 'priority': return `<td class="num dim">${t.priority || '—'}</td>`;
default: return '<td></td>';
}
}
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 `<div class="pbar ${cls}"><i style="width:${(t.progress * 100).toFixed(1)}%"></i><span>${f.pct(t.progress)}</span></div>`;
}
/* ---------- 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)?`, `
<p class="dim">Choose whether to also delete the downloaded data from disk.</p>
<div class="checks"><label><input type="checkbox" id="delFiles"> Also delete files on disk</label></div>`,
[{ 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 = state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join('');
openModal('Add torrent', `
<div class="field"><label>Magnet link / URL</label>
<input type="text" id="addMagnet" placeholder="magnet:?xt=urn:btih:…" /></div>
<div class="field"><label>Or upload .torrent file(s)</label>
<input type="file" id="addFile" accept=".torrent,application/x-bittorrent" multiple />
<div id="addFileInfo" class="dim" style="margin-top:6px;font-size:12px"></div></div>
<div class="field"><label>Category</label><select id="addCat">${cats}</select></div>
<div class="field"><label>Save path</label>
<input type="text" id="addPath" value="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>
<div class="checks">
<label><input type="checkbox" id="addPaused"> Add paused</label>
<label><input type="checkbox" id="addSeq"> Sequential download</label>
<label><input type="checkbox" id="addSkip"> Skip hash check</label>
</div>`,
[{ 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,
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'),
}]);
// 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) =>
`📄 <b>${f.esc(m.name)}</b> — ${f.bytes(m.size)} · ${m.pieceCount} pieces · ${(m.files || []).length || 1} file(s)`).join('<br>');
});
}
/* ---------- 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', `
<div class="field"><label>Save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
<p class="dim" style="font-size:12px">Applies to ${hashes.length} torrent(s).</p>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Apply', primary: true, act: () => guard(async () => {
const savePath = document.getElementById('locPath').value.trim();
if (savePath) await api.action('setSavePath', hashes, { savePath });
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)`, `
<div class="field"><label>Rate (KiB/s — 0 = unlimited)</label>
<input type="text" id="rateVal" placeholder="0" inputmode="numeric" /></div>`,
[{ 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)`, `
<div class="field"><label>Seeding ratio limit</label>
<input type="text" id="ratioVal" placeholder="-1" inputmode="decimal" /></div>
<p class="dim" style="font-size:12px">1 = use global · 0 = unlimited</p>`,
[{ 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();
}
async function readTorrentFile(file) {
const td = new TextDecoder('utf-8');
try {
const meta = bdecode(new Uint8Array(await file.arrayBuffer()));
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 };
} catch {
// Not parseable as bencode — fall back to the filename.
return { name: file.name.replace(/\.torrent$/i, '') };
}
}
/* ===================== 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 opts = state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join('');
openModal('Set category', `
<div class="field"><label>Category</label><select id="catSel">${opts}</select></div>
<p class="dim" style="font-size:12px">Need a new one? Use <b>+</b> next to “Categories” in the sidebar.</p>`,
[{ 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', `
<div class="field"><label>Name</label><input type="text" id="catName" placeholder="e.g. Documentaries" /></div>
<div class="field"><label>Save path</label>
<input type="text" id="catPath" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>`,
[{ 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', `<div class="field"><label>Name</label><input type="text" id="tagName" placeholder="e.g. seed-2weeks" /></div>`,
[{ 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}`, `<p class="dim">Remove the ${label}? It will be unassigned from all torrents. Downloaded files are not affected.</p>`,
[{ 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 `<label class="check-row"><input type="checkbox" data-tag="${f.esc(tag)}" ${have === torrents.length ? 'checked' : ''}>
<span>${f.esc(tag)}</span>${some ? '<span class="dim"> · on some</span>' : ''}</label>`;
};
openModal(`Edit tags · ${hashes.length} torrent(s)`, `
<div class="field"><label>Add new tag</label>
<div style="display:flex;gap:8px">
<input type="text" id="newTag" placeholder="tag name" style="flex:1" />
<button class="btn" id="addTagBtn">Add</button></div></div>
<div class="taglist" id="tagList">${state.meta.tags.map(tagRow).join('') || '<span class="dim">No tags yet — add one above.</span>'}</div>`,
[{ 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 = `<input type="checkbox" data-tag="${f.esc(v)}" checked><span>${f.esc(v)}</span>`;
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 '<div class="sep"></div>';
if (it.sub) return `<div class="sub">${it.sub}</div>`;
return `<div class="mi ${it.danger ? 'danger' : ''}" data-idx="${items.indexOf(it)}"><span>${it.label}</span><span class="sc">${it.sc || ''}</span></div>`;
}).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) {
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
host.innerHTML = `<div class="pane"><div class="split2">
<div>
<div class="card"><h3>Feeds</h3>
${feeds.map((fd) => `<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b></span><span class="dim">${f.ago(fd.lastUpdate)}</span></div>
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>`).join('<div style="height:8px"></div>')}
</div>
</div>
<div>
<div class="card"><h3>Unread articles</h3>
<table class="dtbl"><thead><tr><th>Feed</th><th>Title</th><th class="num">Size</th><th class="num">Published</th><th></th></tr></thead><tbody>
${feeds.flatMap((fd) => fd.articles.map((a) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
<td class="dim">${f.esc(fd.name)}</td><td>${f.esc(a.title)}</td>
<td class="num">${f.bytes(a.size)}</td><td class="num dim">${f.ago(a.date)}</td>
<td>${a.isRead ? '<span class="faint">read</span>' : '<span style="color:var(--accent)">● new</span>'}</td></tr>`)).join('')}
</tbody></table>
</div>
<h3 style="margin:16px 0 8px">Auto-download rules</h3>
${rules.map((r) => renderRule(r)).join('')}
</div>
</div></div>`;
}
function renderRule(r) {
return `<div class="rule ${r.enabled ? '' : 'off'}">
<div class="rule-head">
<span class="rule-name">${f.esc(r.name)}</span>
<span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span>
${r.useRegex ? '<span class="pill">regex</span>' : ''}
${r.addPaused ? '<span class="pill paused">add paused</span>' : ''}
</div>
<div class="kvrow">must contain <code class="inline">${f.esc(r.mustContain)}</code></div>
${r.mustNotContain ? `<div class="kvrow">must not contain <code class="inline">${f.esc(r.mustNotContain)}</code></div>` : ''}
<div class="kvrow">→ category <b>${f.esc(r.assignedCategory)}</b> · save to <b>${f.esc(r.savePath)}</b></div>
<div class="kvrow dim">feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}</div>
</div>`;
}
/* ===================== Search view ===================== */
function renderSearchView(host) {
const plugins = state.meta.searchPlugins.map((p) =>
`<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ');
host.innerHTML = `<div class="pane">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Search indexers… e.g. debian, sintel, dataset" value="${f.esc(state.searchQuery)}" />
<button class="btn primary" id="searchBtn">Search</button>
</div>
<div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}</div>
<div id="searchResults">${state.searchResults ? searchTable(state.searchResults) : '<div class="empty" style="height:200px">Enter a query to search configured indexers</div>'}</div>
</div>`;
const input = document.getElementById('searchInput');
const run = async () => {
state.searchQuery = input.value.trim();
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
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 '<div class="empty" style="height:160px">No results</div>';
return `<table class="dtbl"><thead><tr>
<th>Name</th><th class="num">Size</th><th class="num">Seeds</th><th class="num">Leeches</th>
<th>Engine</th><th class="num">Published</th><th></th></tr></thead><tbody>
${rows.map((r, i) => `<tr>
<td>${f.esc(r.name)}</td><td class="num">${f.bytes(r.size)}</td>
<td class="num" style="color:var(--up)">${r.seeds}</td><td class="num dim">${r.leeches}</td>
<td class="dim">${f.esc(r.engine)}</td><td class="num dim">${f.ago(r.pubDate)}</td>
<td><button class="btn" data-sr="${i}"> Add</button></td></tr>`).join('')}
</tbody></table>`;
}
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) => `<div class="card"><h3>${title}</h3>${rows.map(([k, v]) =>
`<div class="prop"><span class="k">${k}</span><span class="v mono">${v}</span></div>`).join('')}</div>`;
host.innerHTML = `<div class="pane"><div class="settings-grid">
${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`],
])}
</div>
<p class="dim" style="margin-top:14px">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.</p>
</div>`;
}
/* ===================== status bar ===================== */
function renderStatusbar() {
const s = state.snapshot.server || {};
const el = document.getElementById('statusbar');
el.innerHTML = `
<span class="sb"><span class="led"></span><span class="conn-ok">${s.connection_status || 'connecting'}</span></span>
<span class="sb">DHT: <b>${s.dht_nodes ?? ''}</b> nodes</span>
<span class="sb">Port: <b>${s.listen_port ?? ''}</b></span>
<span class="sb">Active: <b>${s.active_torrents ?? 0}</b>/${s.total_torrents ?? 0}</span>
<span class="sb">Session ▼ <b>${f.bytes(s.dl_info_data)}</b> ▲ <b>${f.bytes(s.up_info_data)}</b></span>
<span class="sb">Global ratio: <b>${f.ratio(s.global_ratio)}</b></span>
<span class="spacer"></span>
<span class="sb">Cache hit: <b>${s.read_cache_hits ?? ''}%</b></span>
<span class="sb">Queued I/O: <b>${s.queued_io_jobs ?? 0}</b></span>
<span class="sb">Free space: <b>${f.bytes(s.free_space)}</b></span>`;
}
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 = `<h2>${f.esc(title)}</h2><div class="mbody">${bodyHtml}</div>
<div class="mfoot">${buttons.map((b, i) => `<button class="btn ${b.primary ? 'primary' : ''} ${b.cls || ''}" data-mb="${i}">${b.label}</button>`).join('')}</div>`;
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 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 === 'add') return openAddModal();
doAction(a);
}));
document.querySelectorAll('.vtab').forEach((b) =>
b.addEventListener('click', () => switchViewTab(b.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();