Initial commit: NAUT torrent web UI with stubbed server
Advanced torrent client web UI aimed at power users, with a zero-dependency Node stub server (built-in http + SSE) serving live mock data. - Dense sortable/multi-select torrent grid with live updates - Detail panel: General/Trackers/Peers/Content/Pieces (resizable) - Sidebar filters: status, categories, tags, trackers - Create/delete categories and tags (sidebar + right-click) - Add via magnet or client-side-parsed .torrent upload - RSS, integrated search, and read-only engine views - Hand-drawn SVG icon set, dark theme, keyboard shortcuts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
bc1be49a37
12 changed files with 2677 additions and 0 deletions
459
server/data.js
Normal file
459
server/data.js
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
// Mock data store for the torrent UI.
|
||||
// Generates a realistic "fleet" of torrents with full per-torrent detail
|
||||
// (trackers, peers, files, piece map) plus categories, tags, RSS and search stubs.
|
||||
|
||||
const KiB = 1024;
|
||||
const MiB = 1024 * KiB;
|
||||
const GiB = 1024 * MiB;
|
||||
|
||||
// ---- deterministic-ish PRNG so reloads look stable but varied ----
|
||||
let _seed = 1337;
|
||||
function rng() {
|
||||
_seed = (_seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return _seed / 0x7fffffff;
|
||||
}
|
||||
function pick(arr) { return arr[Math.floor(rng() * arr.length)]; }
|
||||
function between(min, max) { return min + rng() * (max - min); }
|
||||
function intBetween(min, max) { return Math.floor(between(min, max + 1)); }
|
||||
function chance(p) { return rng() < p; }
|
||||
|
||||
function fakeHash() {
|
||||
const hex = '0123456789abcdef';
|
||||
let s = '';
|
||||
for (let i = 0; i < 40; i++) s += hex[Math.floor(rng() * 16)];
|
||||
return s;
|
||||
}
|
||||
|
||||
const COUNTRIES = ['US', 'DE', 'NL', 'FR', 'GB', 'SE', 'CA', 'JP', 'AU', 'RU', 'BR', 'CN', 'IN', 'PL', 'UA', 'CH'];
|
||||
const CLIENTS = [
|
||||
'qBittorrent 4.6.5', 'qBittorrent 5.0.0', 'Transmission 4.0.5', 'Deluge 2.1.1',
|
||||
'libtorrent 2.0.10', 'Vuze 5.7.7', 'rTorrent 0.9.8', 'BiglyBT 3.6.0',
|
||||
'µTorrent 3.6.0', 'WebTorrent 2.1.0', 'Tixati 3.27',
|
||||
];
|
||||
const PEER_FLAGS = ['D', 'U', 'O', 'S', 'I', 'E', 'X', 'H', 'P', 'K', '?'];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ name: 'Linux ISOs', savePath: '/data/iso' },
|
||||
{ name: 'Movies', savePath: '/data/media/movies' },
|
||||
{ name: 'TV', savePath: '/data/media/tv' },
|
||||
{ name: 'Music', savePath: '/data/media/music' },
|
||||
{ name: 'Books', savePath: '/data/books' },
|
||||
{ name: 'Datasets', savePath: '/data/datasets' },
|
||||
{ name: 'Games', savePath: '/data/games' },
|
||||
{ name: '', savePath: '/data/downloads' }, // uncategorized
|
||||
];
|
||||
|
||||
const TAGS = ['archive', 'public', 'private', 'seed-forever', 'hit-and-run', 'verified', '4k', 'remux', 'incomplete', 'priority'];
|
||||
|
||||
const TRACKER_HOSTS = [
|
||||
'tracker.opentrackr.org:1337', 'open.demonii.com:1337', 'tracker.torrent.eu.org:451',
|
||||
'exodus.desync.com:6969', 'tracker.openbittorrent.com:6969', 'private.tracker.lan:2710',
|
||||
'tracker.dler.org:6969', 'open.stealth.si:80', '** [DHT] **', '** [PeX] **', '** [LSD] **',
|
||||
];
|
||||
|
||||
const NAME_TEMPLATES = [
|
||||
'debian-12.5.0-amd64-netinst.iso',
|
||||
'ubuntu-24.04-desktop-amd64.iso',
|
||||
'archlinux-2024.06.01-x86_64.iso',
|
||||
'Fedora-Workstation-Live-x86_64-40.iso',
|
||||
'NixOS-24.05-x86_64-linux.iso',
|
||||
'The.Expanse.S01.2160p.UHD.BluRay.REMUX.HDR.DV',
|
||||
'Cosmos.A.Spacetime.Odyssey.S01.1080p.BluRay',
|
||||
'Blender.Open.Movies.Collection.2006-2023',
|
||||
'Big.Buck.Bunny.4K.60fps.HDR',
|
||||
'Sintel.2010.2160p.BluRay.x265',
|
||||
'MIT.OCW.6.006.Introduction.to.Algorithms.2020',
|
||||
'Stanford.CS231n.2017.Lectures',
|
||||
'Wikipedia.en.all.maxi.2024-05.zim',
|
||||
'OpenStreetMap.planet-240603.osm.pbf',
|
||||
'Common.Voice.Corpus.17.0.en',
|
||||
'ImageNet.ILSVRC2012.tar',
|
||||
'GPL.Source.Mirror.linux-6.9.4',
|
||||
'Project.Gutenberg.2024.Snapshot',
|
||||
'FreeBSD-14.0-RELEASE-amd64-dvd1.iso',
|
||||
'TempleOS.Distro.5.03',
|
||||
'Public.Domain.Jazz.Collection.FLAC',
|
||||
'NASA.Apollo.Archive.4K.Scans',
|
||||
'OpenStax.Textbook.Bundle.2024',
|
||||
'Rocky.Linux.9.4.x86_64.dvd.iso',
|
||||
];
|
||||
|
||||
const STATES = {
|
||||
downloading: 'downloading',
|
||||
stalledDL: 'stalledDL',
|
||||
uploading: 'uploading',
|
||||
stalledUP: 'stalledUP',
|
||||
pausedDL: 'pausedDL',
|
||||
pausedUP: 'pausedUP',
|
||||
checkingDL: 'checkingDL',
|
||||
queuedDL: 'queuedDL',
|
||||
forcedDL: 'forcedDL',
|
||||
forcedUP: 'forcedUP',
|
||||
metaDL: 'metaDL',
|
||||
error: 'error',
|
||||
missingFiles: 'missingFiles',
|
||||
moving: 'moving',
|
||||
};
|
||||
|
||||
function makeFiles(name, totalSize) {
|
||||
// multi-file torrents get a tree; single-file otherwise.
|
||||
const isMulti = chance(0.55);
|
||||
if (!isMulti) {
|
||||
return [{
|
||||
name,
|
||||
size: totalSize,
|
||||
progress: 1,
|
||||
priority: 1,
|
||||
availability: 1,
|
||||
}];
|
||||
}
|
||||
const folder = name.replace(/\.[a-z0-9]+$/i, '');
|
||||
const count = intBetween(2, 9);
|
||||
const files = [];
|
||||
let remaining = totalSize;
|
||||
const subdirs = ['', '', 'extras/', 'subs/', 'sample/'];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const portion = i === count - 1 ? remaining : Math.floor(remaining * between(0.1, 0.5));
|
||||
remaining -= portion;
|
||||
const ext = pick(['.mkv', '.mp4', '.flac', '.iso', '.pdf', '.tar', '.zim', '.nfo', '.srt']);
|
||||
const prio = pick([0, 1, 1, 1, 6, 7]); // 0=do not download,1=normal,6=high,7=max
|
||||
files.push({
|
||||
name: `${folder}/${pick(subdirs)}part${i + 1}${ext}`,
|
||||
size: Math.max(portion, MiB),
|
||||
progress: prio === 0 ? 0 : between(0.2, 1),
|
||||
priority: prio,
|
||||
availability: between(0.8, 2.5),
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function makePieces(count, progress) {
|
||||
// 0 = missing, 1 = downloading, 2 = done
|
||||
const arr = new Array(count).fill(0);
|
||||
const done = Math.floor(count * progress);
|
||||
// make completed pieces somewhat scattered to look realistic
|
||||
let filled = 0;
|
||||
for (let i = 0; i < count && filled < done; i++) {
|
||||
if (chance(0.85)) { arr[i] = 2; filled++; }
|
||||
}
|
||||
// remaining done pieces fill from the front
|
||||
for (let i = 0; i < count && filled < done; i++) {
|
||||
if (arr[i] === 0) { arr[i] = 2; filled++; }
|
||||
}
|
||||
// a few in-flight
|
||||
if (progress < 1) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (arr[i] === 0 && chance(0.02)) arr[i] = 1;
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function makeTrackers(seeds, peers) {
|
||||
const n = intBetween(2, 4);
|
||||
const hosts = [...TRACKER_HOSTS];
|
||||
const trackers = [];
|
||||
// always include DHT/PeX/LSD pseudo-trackers
|
||||
for (const pseudo of ['** [DHT] **', '** [PeX] **', '** [LSD] **']) {
|
||||
trackers.push({
|
||||
url: pseudo,
|
||||
tier: -1,
|
||||
status: 'working',
|
||||
seeds: pseudo.includes('DHT') ? intBetween(0, seeds) : -1,
|
||||
peers: pseudo.includes('DHT') ? intBetween(0, peers) : -1,
|
||||
leeches: -1,
|
||||
downloaded: -1,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const url = `udp://${pick(hosts)}/announce`;
|
||||
const working = chance(0.78);
|
||||
trackers.push({
|
||||
url,
|
||||
tier: i,
|
||||
status: working ? 'working' : pick(['not contacted', 'updating', 'error']),
|
||||
seeds: working ? intBetween(0, seeds + 50) : 0,
|
||||
peers: working ? intBetween(0, peers + 30) : 0,
|
||||
leeches: working ? intBetween(0, peers + 30) : 0,
|
||||
downloaded: working ? intBetween(100, 50000) : 0,
|
||||
message: working ? '' : pick(['Connection timed out', 'Host not found', 'Not working', 'unregistered torrent']),
|
||||
});
|
||||
}
|
||||
return trackers;
|
||||
}
|
||||
|
||||
function makePeers(count, dlActive) {
|
||||
const peers = [];
|
||||
const realCount = Math.min(count, intBetween(0, 40));
|
||||
for (let i = 0; i < realCount; i++) {
|
||||
const flags = [];
|
||||
if (dlActive && chance(0.5)) flags.push('D');
|
||||
if (chance(0.4)) flags.push('U');
|
||||
if (chance(0.3)) flags.push('O'); // optimistic unchoke
|
||||
if (chance(0.2)) flags.push('I'); // incoming
|
||||
if (chance(0.5)) flags.push('E'); // encrypted
|
||||
if (chance(0.1)) flags.push('X'); // PEX
|
||||
if (chance(0.1)) flags.push('H'); // DHT
|
||||
const prog = between(0, 1);
|
||||
peers.push({
|
||||
ip: `${intBetween(1, 254)}.${intBetween(0, 254)}.${intBetween(0, 254)}.${intBetween(1, 254)}`,
|
||||
port: intBetween(1024, 65535),
|
||||
country: pick(COUNTRIES),
|
||||
client: pick(CLIENTS),
|
||||
flags: flags.join(' '),
|
||||
progress: prog,
|
||||
dlspeed: flags.includes('D') ? intBetween(10 * KiB, 4 * MiB) : 0,
|
||||
upspeed: flags.includes('U') ? intBetween(1 * KiB, 800 * KiB) : 0,
|
||||
downloaded: intBetween(0, 500 * MiB),
|
||||
uploaded: intBetween(0, 500 * MiB),
|
||||
relevance: prog,
|
||||
connection: pick(['µTP', 'BT', 'BT', 'WEB']),
|
||||
});
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
|
||||
function makeTorrent(name, idx) {
|
||||
const size = Math.floor(between(150 * MiB, 60 * GiB));
|
||||
const pieceSizeOptions = [256 * KiB, 512 * KiB, 1 * MiB, 2 * MiB, 4 * MiB, 8 * MiB, 16 * MiB];
|
||||
const pieceSize = pick(pieceSizeOptions);
|
||||
const pieceCount = Math.max(8, Math.min(2400, Math.ceil(size / pieceSize)));
|
||||
|
||||
// pick a plausible state
|
||||
const stateRoll = rng();
|
||||
let state, progress;
|
||||
if (stateRoll < 0.30) { state = STATES.downloading; progress = between(0.05, 0.95); }
|
||||
else if (stateRoll < 0.40) { state = STATES.stalledDL; progress = between(0.0, 0.6); }
|
||||
else if (stateRoll < 0.62) { state = STATES.uploading; progress = 1; }
|
||||
else if (stateRoll < 0.74) { state = STATES.stalledUP; progress = 1; }
|
||||
else if (stateRoll < 0.82) { state = STATES.pausedUP; progress = 1; }
|
||||
else if (stateRoll < 0.87) { state = STATES.pausedDL; progress = between(0.1, 0.8); }
|
||||
else if (stateRoll < 0.90) { state = STATES.queuedDL; progress = between(0, 0.3); }
|
||||
else if (stateRoll < 0.93) { state = STATES.checkingDL; progress = between(0.3, 0.99); }
|
||||
else if (stateRoll < 0.95) { state = STATES.forcedUP; progress = 1; }
|
||||
else if (stateRoll < 0.97) { state = STATES.metaDL; progress = 0; }
|
||||
else if (stateRoll < 0.99) { state = STATES.error; progress = between(0, 0.9); }
|
||||
else { state = STATES.missingFiles; progress = between(0.5, 1); }
|
||||
|
||||
const isDL = ['downloading', 'forcedDL', 'metaDL'].includes(state);
|
||||
const isUP = ['uploading', 'forcedUP'].includes(state);
|
||||
|
||||
const seedsTotal = intBetween(0, 800);
|
||||
const peersTotal = intBetween(0, 600);
|
||||
const seeds = Math.min(seedsTotal, intBetween(0, 50));
|
||||
const peers = Math.min(peersTotal, intBetween(0, 40));
|
||||
|
||||
const dlspeed = isDL ? intBetween(50 * KiB, 18 * MiB) : 0;
|
||||
const upspeed = (isUP || isDL) ? intBetween(0, 6 * MiB) : 0;
|
||||
|
||||
const downloaded = Math.floor(size * progress + intBetween(0, 200 * MiB));
|
||||
const ratio = between(0, 8.5);
|
||||
const uploaded = Math.floor(downloaded * ratio);
|
||||
|
||||
const now = Date.now();
|
||||
const addedOn = now - intBetween(60, 60 * 60 * 24 * 90) * 1000;
|
||||
const completionOn = progress >= 1 ? addedOn + intBetween(60, 60 * 60 * 24) * 1000 : -1;
|
||||
|
||||
const cat = pick(CATEGORIES);
|
||||
const tagSet = new Set();
|
||||
const tagCount = intBetween(0, 3);
|
||||
for (let i = 0; i < tagCount; i++) tagSet.add(pick(TAGS));
|
||||
|
||||
const eta = isDL && dlspeed > 0
|
||||
? Math.floor((size - downloaded) / dlspeed)
|
||||
: 8640000; // ∞ sentinel (100 days)
|
||||
|
||||
const files = makeFiles(name, size);
|
||||
const isPrivate = chance(0.35);
|
||||
|
||||
return {
|
||||
hash: fakeHash(),
|
||||
name,
|
||||
size,
|
||||
progress: Math.min(1, progress),
|
||||
dlspeed,
|
||||
upspeed,
|
||||
state,
|
||||
eta,
|
||||
seeds, seedsTotal,
|
||||
peers, peersTotal,
|
||||
ratio,
|
||||
ratioLimit: chance(0.3) ? between(1, 4) : -1,
|
||||
category: cat.name,
|
||||
tags: [...tagSet],
|
||||
savePath: cat.savePath,
|
||||
contentPath: `${cat.savePath}/${name}`,
|
||||
addedOn,
|
||||
completionOn,
|
||||
lastActivity: now - intBetween(0, 60 * 60 * 12) * 1000,
|
||||
seenComplete: completionOn,
|
||||
downloaded,
|
||||
uploaded,
|
||||
downloadedSession: Math.floor(downloaded * between(0.01, 0.3)),
|
||||
uploadedSession: Math.floor(uploaded * between(0.01, 0.3)),
|
||||
availability: state.includes('paused') ? 0 : between(0.5, 4),
|
||||
priority: ['queuedDL', 'queuedUP'].includes(state) ? intBetween(1, 12) : 0,
|
||||
seqDl: chance(0.15),
|
||||
superSeeding: progress >= 1 && chance(0.1),
|
||||
autoTMM: chance(0.5),
|
||||
forceStart: state.startsWith('forced'),
|
||||
pieceSize,
|
||||
pieceCount,
|
||||
pieces: makePieces(pieceCount, Math.min(1, progress)),
|
||||
downLimit: chance(0.2) ? intBetween(50 * KiB, 5 * MiB) : 0,
|
||||
upLimit: chance(0.2) ? intBetween(20 * KiB, 2 * MiB) : 0,
|
||||
timeActive: intBetween(60, 60 * 60 * 24 * 60),
|
||||
comment: chance(0.4) ? pick(['Verified release', 'Please seed!', 'Official mirror', 'See README for checksums']) : '',
|
||||
createdBy: pick(['mktorrent 1.1', 'qBittorrent v5.0.0', 'Transmission/4.0.5', 'libtorrent']),
|
||||
creationDate: addedOn - intBetween(60 * 60 * 24, 60 * 60 * 24 * 365) * 1000,
|
||||
private: isPrivate,
|
||||
magnetUri: `magnet:?xt=urn:btih:${''}`,
|
||||
files,
|
||||
trackers: makeTrackers(seeds, peers),
|
||||
peersList: makePeers(peers, isDL),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFleet() {
|
||||
_seed = 1337;
|
||||
const torrents = NAME_TEMPLATES.map((n, i) => makeTorrent(n, i));
|
||||
for (const t of torrents) t.magnetUri = `magnet:?xt=urn:btih:${t.hash}&dn=${encodeURIComponent(t.name)}`;
|
||||
return torrents;
|
||||
}
|
||||
|
||||
const torrents = buildFleet();
|
||||
|
||||
const rssFeeds = [
|
||||
{
|
||||
uid: 'feed-1',
|
||||
name: 'Linux ISO Tracker',
|
||||
url: 'https://tracker.example.org/rss/linux',
|
||||
lastUpdate: Date.now() - 8 * 60 * 1000,
|
||||
articles: [
|
||||
{ title: 'debian-12.6.0-amd64-netinst.iso', date: Date.now() - 60 * 60 * 1000, size: 660 * MiB, isRead: false },
|
||||
{ title: 'ubuntu-24.04.1-desktop-amd64.iso', date: Date.now() - 5 * 60 * 60 * 1000, size: 5.9 * GiB, isRead: true },
|
||||
{ title: 'archlinux-2024.07.01-x86_64.iso', date: Date.now() - 26 * 60 * 60 * 1000, size: 1.1 * GiB, isRead: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: 'feed-2',
|
||||
name: 'Public Domain Media',
|
||||
url: 'https://media.example.org/rss',
|
||||
lastUpdate: Date.now() - 22 * 60 * 1000,
|
||||
articles: [
|
||||
{ title: 'Sintel.2010.2160p.BluRay.x265', date: Date.now() - 2 * 60 * 60 * 1000, size: 4.2 * GiB, isRead: false },
|
||||
{ title: 'Big.Buck.Bunny.4K.60fps.HDR', date: Date.now() - 9 * 60 * 60 * 1000, size: 2.8 * GiB, isRead: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const rssRules = [
|
||||
{
|
||||
name: 'New Debian stable',
|
||||
enabled: true,
|
||||
mustContain: 'debian amd64 netinst',
|
||||
mustNotContain: 'rc beta alpha',
|
||||
useRegex: false,
|
||||
episodeFilter: '',
|
||||
affectedFeeds: ['feed-1'],
|
||||
assignedCategory: 'Linux ISOs',
|
||||
savePath: '/data/iso',
|
||||
addPaused: false,
|
||||
lastMatch: Date.now() - 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
name: '4K Public Domain',
|
||||
enabled: true,
|
||||
mustContain: '2160p',
|
||||
mustNotContain: 'cam ts',
|
||||
useRegex: false,
|
||||
episodeFilter: '',
|
||||
affectedFeeds: ['feed-2'],
|
||||
assignedCategory: 'Movies',
|
||||
savePath: '/data/media/movies',
|
||||
addPaused: true,
|
||||
lastMatch: Date.now() - 2 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
name: 'Ubuntu LTS only',
|
||||
enabled: false,
|
||||
mustContain: 'ubuntu.*desktop.*amd64',
|
||||
mustNotContain: 'daily beta',
|
||||
useRegex: true,
|
||||
episodeFilter: '',
|
||||
affectedFeeds: ['feed-1'],
|
||||
assignedCategory: 'Linux ISOs',
|
||||
savePath: '/data/iso',
|
||||
addPaused: false,
|
||||
lastMatch: -1,
|
||||
},
|
||||
];
|
||||
|
||||
const searchPlugins = [
|
||||
{ name: 'LinuxTracker', enabled: true, url: 'https://linuxtracker.org' },
|
||||
{ name: 'PublicMediaDB', enabled: true, url: 'https://media.example.org' },
|
||||
{ name: 'AcademicTorrents', enabled: true, url: 'https://academictorrents.com' },
|
||||
{ name: 'LegacyIndexer', enabled: false, url: 'https://legacy.example.net' },
|
||||
];
|
||||
|
||||
function runSearch(query) {
|
||||
const q = (query || '').toLowerCase().trim();
|
||||
const pool = [
|
||||
...NAME_TEMPLATES,
|
||||
'OpenWRT-23.05.3-x86-64-generic.img',
|
||||
'KDE.neon.User.Edition.2024.iso',
|
||||
'Manjaro.KDE.24.0.iso',
|
||||
'Public.Domain.Films.1920s.Collection',
|
||||
'LibreOffice.24.2.SDK.docs',
|
||||
];
|
||||
return pool
|
||||
.filter((n) => !q || n.toLowerCase().includes(q))
|
||||
.map((n) => ({
|
||||
name: n,
|
||||
size: Math.floor(between(100 * MiB, 50 * GiB)),
|
||||
seeds: intBetween(0, 1200),
|
||||
leeches: intBetween(0, 600),
|
||||
engine: pick(searchPlugins.filter((p) => p.enabled).map((p) => p.name)),
|
||||
pubDate: Date.now() - intBetween(60, 60 * 60 * 24 * 400) * 1000,
|
||||
descrLink: 'https://example.org/details',
|
||||
}))
|
||||
.sort((a, b) => b.seeds - a.seeds);
|
||||
}
|
||||
|
||||
export const db = {
|
||||
torrents,
|
||||
categories: CATEGORIES,
|
||||
tags: TAGS,
|
||||
rssFeeds,
|
||||
rssRules,
|
||||
searchPlugins,
|
||||
runSearch,
|
||||
// server preferences (subset, advanced)
|
||||
preferences: {
|
||||
dl_limit: 0,
|
||||
up_limit: 0,
|
||||
alt_dl_limit: 1 * MiB,
|
||||
alt_up_limit: 256 * KiB,
|
||||
alt_speed_enabled: false,
|
||||
max_connec: 500,
|
||||
max_connec_per_torrent: 100,
|
||||
max_uploads: 20,
|
||||
max_active_downloads: 5,
|
||||
max_active_uploads: 10,
|
||||
max_active_torrents: 12,
|
||||
dht: true,
|
||||
pex: true,
|
||||
lsd: true,
|
||||
encryption: 1, // 0=prefer,1=force on,2=force off
|
||||
utp: true,
|
||||
listen_port: 6881,
|
||||
upnp: true,
|
||||
queueing_enabled: true,
|
||||
save_path: '/data/downloads',
|
||||
scan_interval: 15,
|
||||
},
|
||||
};
|
||||
|
||||
export const constants = { KiB, MiB, GiB, STATES };
|
||||
333
server/index.js
Normal file
333
server/index.js
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
// Zero-dependency stub server for the torrent web UI.
|
||||
// Built-in `http` only: serves the static frontend, a JSON API, and a
|
||||
// Server-Sent Events stream that pushes a live snapshot every second.
|
||||
|
||||
import http from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { db } from './data.js';
|
||||
import { tick, globalStats } from './simulator.js';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
||||
const PORT = process.env.PORT || 8088;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
// ---- run the simulator on a fixed cadence ----
|
||||
setInterval(tick, 1000);
|
||||
|
||||
// ---- SSE clients ----
|
||||
const sseClients = new Set();
|
||||
function broadcast() {
|
||||
if (sseClients.size === 0) return;
|
||||
const payload = JSON.stringify(snapshot());
|
||||
const frame = `event: snapshot\ndata: ${payload}\n\n`;
|
||||
for (const res of sseClients) res.write(frame);
|
||||
}
|
||||
setInterval(broadcast, 1000);
|
||||
|
||||
// A compact snapshot for the live grid (omits heavy per-torrent detail).
|
||||
function snapshot() {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
server: globalStats(),
|
||||
torrents: db.torrents.map((t) => ({
|
||||
hash: t.hash,
|
||||
name: t.name,
|
||||
size: t.size,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
state: t.state,
|
||||
eta: t.eta,
|
||||
seeds: t.seeds, seedsTotal: t.seedsTotal,
|
||||
peers: t.peers, peersTotal: t.peersTotal,
|
||||
ratio: t.ratio,
|
||||
category: t.category,
|
||||
tags: t.tags,
|
||||
savePath: t.savePath,
|
||||
addedOn: t.addedOn,
|
||||
completionOn: t.completionOn,
|
||||
lastActivity: t.lastActivity,
|
||||
downloaded: t.downloaded,
|
||||
uploaded: t.uploaded,
|
||||
availability: t.availability,
|
||||
priority: t.priority,
|
||||
seqDl: t.seqDl,
|
||||
superSeeding: t.superSeeding,
|
||||
forceStart: t.forceStart,
|
||||
timeActive: t.timeActive,
|
||||
private: t.private,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function json(res, data, code = 200) {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
if (!chunks.length) return {};
|
||||
try { return JSON.parse(Buffer.concat(chunks).toString()); } catch { return {}; }
|
||||
}
|
||||
|
||||
function byHash(hash) { return db.torrents.find((t) => t.hash === hash); }
|
||||
function findMany(hashes) {
|
||||
const set = new Set(hashes);
|
||||
return db.torrents.filter((t) => set.has(t.hash));
|
||||
}
|
||||
|
||||
// ---- action handlers (mutate the stub state) ----
|
||||
const ACTIONS = {
|
||||
pause: (t) => { if (!t.state.startsWith('paused')) { t.state = t.progress >= 1 ? 'pausedUP' : 'pausedDL'; t.dlspeed = 0; t.upspeed = 0; t.forceStart = false; } },
|
||||
resume: (t) => { t.state = t.progress >= 1 ? 'uploading' : 'downloading'; },
|
||||
forceStart: (t) => { t.forceStart = true; t.state = t.progress >= 1 ? 'forcedUP' : 'forcedDL'; },
|
||||
recheck: (t) => { t.state = t.progress >= 1 ? 'checkingUP' : 'checkingDL'; setTimeout(() => { t.state = t.progress >= 1 ? 'uploading' : 'downloading'; }, 3000); },
|
||||
reannounce: (t) => { t.lastActivity = Date.now(); },
|
||||
toggleSeqDl: (t) => { t.seqDl = !t.seqDl; },
|
||||
toggleSuperSeeding: (t) => { t.superSeeding = !t.superSeeding; },
|
||||
setCategory: (t, p) => { t.category = p.category ?? ''; },
|
||||
setSavePath: (t, p) => { if (p.savePath) { t.savePath = p.savePath; t.contentPath = `${p.savePath}/${t.name}`; } },
|
||||
addTags: (t, p) => { for (const tag of p.tags || []) if (!t.tags.includes(tag)) t.tags.push(tag); },
|
||||
removeTags: (t, p) => { t.tags = t.tags.filter((x) => !(p.tags || []).includes(x)); },
|
||||
setDownLimit: (t, p) => { t.downLimit = p.limit ?? 0; },
|
||||
setUpLimit: (t, p) => { t.upLimit = p.limit ?? 0; },
|
||||
setRatioLimit: (t, p) => { t.ratioLimit = p.limit ?? -1; },
|
||||
topPriority: (t) => { t.priority = 1; },
|
||||
bottomPriority: (t) => { t.priority = 99; },
|
||||
increasePriority: (t) => { t.priority = Math.max(1, (t.priority || 1) - 1); },
|
||||
decreasePriority: (t) => { t.priority = (t.priority || 1) + 1; },
|
||||
};
|
||||
|
||||
async function handleApi(req, res, url) {
|
||||
const parts = url.pathname.split('/').filter(Boolean); // ['api', ...]
|
||||
const seg = parts.slice(1); // drop 'api'
|
||||
|
||||
// GET /api/stream (SSE)
|
||||
if (seg[0] === 'stream') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
res.write(`event: snapshot\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on('close', () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/snapshot
|
||||
if (seg[0] === 'snapshot' && req.method === 'GET') return json(res, snapshot());
|
||||
|
||||
// GET /api/meta (sidebar data)
|
||||
if (seg[0] === 'meta' && req.method === 'GET') {
|
||||
return json(res, {
|
||||
categories: db.categories,
|
||||
tags: db.tags,
|
||||
trackers: trackerSummary(),
|
||||
preferences: db.preferences,
|
||||
searchPlugins: db.searchPlugins,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/preferences / POST to update
|
||||
if (seg[0] === 'preferences') {
|
||||
if (req.method === 'GET') return json(res, db.preferences);
|
||||
if (req.method === 'POST') {
|
||||
Object.assign(db.preferences, await readBody(req));
|
||||
return json(res, db.preferences);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/altspeed (toggle alt speed)
|
||||
if (seg[0] === 'altspeed' && req.method === 'POST') {
|
||||
db.preferences.alt_speed_enabled = !db.preferences.alt_speed_enabled;
|
||||
return json(res, { alt_speed_enabled: db.preferences.alt_speed_enabled });
|
||||
}
|
||||
|
||||
// Categories: create (POST /api/categories) / delete (POST /api/categories/delete)
|
||||
if (seg[0] === 'categories' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
if (seg[1] === 'delete') {
|
||||
const name = (body.name || '').trim();
|
||||
if (name) {
|
||||
db.categories = db.categories.filter((c) => c.name !== name);
|
||||
for (const t of db.torrents) if (t.category === name) t.category = '';
|
||||
}
|
||||
return json(res, db.categories);
|
||||
}
|
||||
const name = (body.name || '').trim();
|
||||
if (name && !db.categories.some((c) => c.name === name)) {
|
||||
db.categories.push({ name, savePath: (body.savePath || `${db.preferences.save_path}/${name}`).trim() });
|
||||
} else if (name && body.savePath) {
|
||||
db.categories.find((c) => c.name === name).savePath = body.savePath.trim(); // edit existing
|
||||
}
|
||||
return json(res, db.categories);
|
||||
}
|
||||
|
||||
// Tags: create (POST /api/tags) / delete (POST /api/tags/delete)
|
||||
if (seg[0] === 'tags' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
if (seg[1] === 'delete') {
|
||||
const name = (body.name || '').trim();
|
||||
if (name) {
|
||||
db.tags = db.tags.filter((x) => x !== name);
|
||||
for (const t of db.torrents) t.tags = t.tags.filter((x) => x !== name);
|
||||
}
|
||||
return json(res, db.tags);
|
||||
}
|
||||
const name = (body.name || '').trim();
|
||||
if (name && !db.tags.includes(name)) db.tags.push(name);
|
||||
return json(res, db.tags);
|
||||
}
|
||||
|
||||
// /api/torrents ...
|
||||
if (seg[0] === 'torrents') {
|
||||
// GET /api/torrents/:hash/:tab
|
||||
if (req.method === 'GET' && seg[1]) {
|
||||
const t = byHash(seg[1]);
|
||||
if (!t) return json(res, { error: 'not found' }, 404);
|
||||
const tab = seg[2];
|
||||
if (tab === 'trackers') return json(res, t.trackers);
|
||||
if (tab === 'peers') return json(res, t.peersList);
|
||||
if (tab === 'files') return json(res, t.files);
|
||||
if (tab === 'pieces') return json(res, { pieceSize: t.pieceSize, pieceCount: t.pieceCount, pieces: t.pieces });
|
||||
// default: full general properties
|
||||
return json(res, t);
|
||||
}
|
||||
// GET /api/torrents -> full list (rarely needed; stream is primary)
|
||||
if (req.method === 'GET') return json(res, snapshot().torrents);
|
||||
}
|
||||
|
||||
// POST /api/action { action, hashes:[], params:{} }
|
||||
if (seg[0] === 'action' && req.method === 'POST') {
|
||||
const { action, hashes, params } = await readBody(req);
|
||||
const fn = ACTIONS[action];
|
||||
if (!fn) return json(res, { error: `unknown action: ${action}` }, 400);
|
||||
const targets = findMany(hashes || []);
|
||||
for (const t of targets) fn(t, params || {});
|
||||
return json(res, { ok: true, affected: targets.length });
|
||||
}
|
||||
|
||||
// DELETE via POST /api/delete { hashes:[], deleteFiles:bool }
|
||||
if (seg[0] === 'delete' && req.method === 'POST') {
|
||||
const { hashes } = await readBody(req);
|
||||
const set = new Set(hashes || []);
|
||||
const before = db.torrents.length;
|
||||
db.torrents = db.torrents.filter((t) => !set.has(t.hash));
|
||||
return json(res, { ok: true, removed: before - db.torrents.length });
|
||||
}
|
||||
|
||||
// POST /api/add { magnet | name+size+pieceCount+pieceSize+files, category, savePath, paused, skipCheck, seqDl }
|
||||
if (seg[0] === 'add' && req.method === 'POST') {
|
||||
const p = await readBody(req);
|
||||
const name = (p.magnet && decodeURIComponent((p.magnet.match(/dn=([^&]+)/) || [])[1] || '')) || p.name || 'new-torrent.iso';
|
||||
const GiB = 1024 * 1024 * 1024;
|
||||
const size = p.size || GiB;
|
||||
const pieceSize = p.pieceSize || 1024 * 1024;
|
||||
const pieceCount = Math.min(p.pieceCount || Math.max(1, Math.ceil(size / pieceSize)), 4000);
|
||||
const files = (Array.isArray(p.files) && p.files.length)
|
||||
? p.files.map((fl) => ({ name: fl.name, size: fl.size, progress: 0, priority: 1, availability: 1 }))
|
||||
: [{ name, size, progress: 0, priority: 1, availability: 1 }];
|
||||
const savePath = p.savePath || db.preferences.save_path;
|
||||
const hash = Math.random().toString(16).slice(2).padEnd(40, '0').slice(0, 40);
|
||||
db.torrents.unshift({
|
||||
hash, name, size, progress: 0,
|
||||
dlspeed: p.paused ? 0 : 256 * 1024, upspeed: 0, state: p.paused ? 'pausedDL' : (p.magnet ? 'metaDL' : 'downloading'),
|
||||
eta: 8640000, seeds: 4, seedsTotal: 40, peers: 2, peersTotal: 20, ratio: 0, ratioLimit: -1,
|
||||
category: p.category || '', tags: [], savePath,
|
||||
contentPath: `${savePath}/${name}`,
|
||||
addedOn: Date.now(), completionOn: -1, lastActivity: Date.now(), seenComplete: -1,
|
||||
downloaded: 0, uploaded: 0, downloadedSession: 0, uploadedSession: 0,
|
||||
availability: 1, priority: 1, seqDl: !!p.seqDl, superSeeding: false, autoTMM: true,
|
||||
forceStart: false, pieceSize, pieceCount, pieces: new Array(pieceCount).fill(0),
|
||||
downLimit: 0, upLimit: 0, timeActive: 0, comment: '', createdBy: 'uploaded .torrent',
|
||||
creationDate: Date.now(), private: false, magnetUri: p.magnet || '',
|
||||
files,
|
||||
trackers: [
|
||||
{ url: '** [DHT] **', tier: -1, status: 'working', seeds: 4, peers: 2, leeches: -1, downloaded: -1, message: '' },
|
||||
{ url: 'udp://tracker.opentrackr.org:1337/announce', tier: 0, status: 'working', seeds: 10, peers: 4, leeches: 4, downloaded: 0, message: '' },
|
||||
],
|
||||
peersList: [],
|
||||
});
|
||||
return json(res, { ok: true, hash });
|
||||
}
|
||||
|
||||
// RSS
|
||||
if (seg[0] === 'rss') {
|
||||
if (seg[1] === 'rules') return json(res, db.rssRules);
|
||||
return json(res, db.rssFeeds);
|
||||
}
|
||||
|
||||
// Search: GET /api/search?q=...
|
||||
if (seg[0] === 'search' && req.method === 'GET') {
|
||||
return json(res, db.runSearch(url.searchParams.get('q') || ''));
|
||||
}
|
||||
|
||||
return json(res, { error: 'not found' }, 404);
|
||||
}
|
||||
|
||||
function trackerSummary() {
|
||||
const map = new Map();
|
||||
for (const t of db.torrents) {
|
||||
for (const tr of t.trackers) {
|
||||
if (tr.tier === -1) continue; // skip DHT/PeX/LSD pseudo
|
||||
let host;
|
||||
try { host = new URL(tr.url).host; } catch { host = tr.url; }
|
||||
map.set(host, (map.get(host) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...map.entries()].map(([host, count]) => ({ host, count })).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, url) {
|
||||
let pathname = decodeURIComponent(url.pathname);
|
||||
if (pathname === '/') pathname = '/index.html';
|
||||
const filePath = normalize(join(PUBLIC_DIR, pathname));
|
||||
if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403).end('forbidden'); return; }
|
||||
try {
|
||||
const data = await readFile(filePath);
|
||||
const type = MIME[extname(filePath)] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' });
|
||||
res.end(data);
|
||||
} catch {
|
||||
// SPA fallback
|
||||
try {
|
||||
const data = await readFile(join(PUBLIC_DIR, 'index.html'));
|
||||
res.writeHead(200, { 'Content-Type': MIME['.html'] });
|
||||
res.end(data);
|
||||
} catch { res.writeHead(404).end('not found'); }
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
try {
|
||||
if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url);
|
||||
return await serveStatic(req, res, url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
json(res, { error: 'internal error', detail: String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`\n torrent-ui stub server running`);
|
||||
console.log(` → http://localhost:${PORT}\n`);
|
||||
console.log(` ${db.torrents.length} mock torrents, live simulator ticking every 1s\n`);
|
||||
});
|
||||
135
server/simulator.js
Normal file
135
server/simulator.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Mutates the in-memory fleet over time so the UI shows live activity:
|
||||
// speeds fluctuate, downloads progress, pieces fill in, ETA/ratio drift,
|
||||
// session totals accumulate, and the occasional torrent completes.
|
||||
|
||||
import { db, constants } from './data.js';
|
||||
|
||||
const { KiB, MiB } = constants;
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function jitter(v, frac) { return v * (1 + (Math.random() * 2 - 1) * frac); }
|
||||
|
||||
const DL_STATES = new Set(['downloading', 'forcedDL', 'metaDL']);
|
||||
const UP_STATES = new Set(['uploading', 'forcedUP']);
|
||||
|
||||
export function tick() {
|
||||
const now = Date.now();
|
||||
for (const t of db.torrents) {
|
||||
const isDL = DL_STATES.has(t.state);
|
||||
const isUP = UP_STATES.has(t.state) || isDL;
|
||||
|
||||
// --- speeds ---
|
||||
if (isDL) {
|
||||
const ceiling = t.downLimit > 0 ? t.downLimit : 18 * MiB;
|
||||
t.dlspeed = clamp(Math.round(jitter(t.dlspeed || 500 * KiB, 0.4)), 20 * KiB, ceiling);
|
||||
// occasionally stall
|
||||
if (Math.random() < 0.03) { t.state = 'stalledDL'; t.dlspeed = 0; }
|
||||
} else if (t.state === 'stalledDL') {
|
||||
t.dlspeed = 0;
|
||||
if (Math.random() < 0.08) { t.state = 'downloading'; t.dlspeed = 800 * KiB; }
|
||||
} else {
|
||||
t.dlspeed = 0;
|
||||
}
|
||||
|
||||
if (isUP) {
|
||||
const ceiling = t.upLimit > 0 ? t.upLimit : 6 * MiB;
|
||||
t.upspeed = clamp(Math.round(jitter(t.upspeed || 100 * KiB, 0.5)), 0, ceiling);
|
||||
} else {
|
||||
t.upspeed = 0;
|
||||
}
|
||||
|
||||
// --- progress ---
|
||||
if (isDL && t.progress < 1 && t.dlspeed > 0) {
|
||||
const gained = t.dlspeed; // ~1s of bytes
|
||||
t.downloaded += gained;
|
||||
t.downloadedSession += gained;
|
||||
t.progress = clamp(t.progress + gained / t.size, 0, 1);
|
||||
|
||||
// fill some pieces proportionally
|
||||
const targetDone = Math.floor(t.pieceCount * t.progress);
|
||||
let done = 0;
|
||||
for (const p of t.pieces) if (p === 2) done++;
|
||||
let toFill = targetDone - done;
|
||||
for (let i = 0; i < t.pieces.length && toFill > 0; i++) {
|
||||
if (t.pieces[i] !== 2) {
|
||||
if (t.pieces[i] === 1 || Math.random() < 0.5) { t.pieces[i] = 2; toFill--; }
|
||||
else t.pieces[i] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (t.state === 'metaDL' && t.progress > 0.01) t.state = 'downloading';
|
||||
|
||||
// completion
|
||||
if (t.progress >= 1) {
|
||||
t.progress = 1;
|
||||
t.state = 'uploading';
|
||||
t.completionOn = now;
|
||||
t.dlspeed = 0;
|
||||
for (let i = 0; i < t.pieces.length; i++) t.pieces[i] = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- uploaded / ratio ---
|
||||
if (t.upspeed > 0) {
|
||||
t.uploaded += t.upspeed;
|
||||
t.uploadedSession += t.upspeed;
|
||||
}
|
||||
t.ratio = t.downloaded > 0 ? t.uploaded / t.downloaded : (t.uploaded > 0 ? 9999 : 0);
|
||||
|
||||
// --- eta ---
|
||||
t.eta = isDL && t.dlspeed > 0
|
||||
? Math.floor((t.size - t.downloaded) / t.dlspeed)
|
||||
: 8640000;
|
||||
|
||||
// --- swarm drift ---
|
||||
if (!t.state.startsWith('paused')) {
|
||||
t.seeds = clamp(t.seeds + (Math.random() < 0.5 ? -1 : 1) * (Math.random() < 0.3 ? 1 : 0), 0, t.seedsTotal);
|
||||
t.peers = clamp(t.peers + (Math.random() < 0.5 ? -1 : 1) * (Math.random() < 0.3 ? 1 : 0), 0, t.peersTotal);
|
||||
t.lastActivity = now;
|
||||
t.timeActive += 1;
|
||||
}
|
||||
|
||||
// --- peer speeds drift ---
|
||||
for (const p of t.peersList) {
|
||||
if (p.dlspeed) p.dlspeed = clamp(Math.round(jitter(p.dlspeed, 0.5)), 0, 4 * MiB);
|
||||
if (p.upspeed) p.upspeed = clamp(Math.round(jitter(p.upspeed, 0.5)), 0, 800 * KiB);
|
||||
if (p.progress < 1) p.progress = clamp(p.progress + Math.random() * 0.01, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global session/server stats derived from the fleet.
|
||||
export function globalStats() {
|
||||
let dl = 0, up = 0, dlSession = 0, upSession = 0, totalDown = 0, totalUp = 0;
|
||||
let active = 0;
|
||||
for (const t of db.torrents) {
|
||||
dl += t.dlspeed;
|
||||
up += t.upspeed;
|
||||
dlSession += t.downloadedSession;
|
||||
upSession += t.uploadedSession;
|
||||
totalDown += t.downloaded;
|
||||
totalUp += t.uploaded;
|
||||
if (t.dlspeed > 0 || t.upspeed > 0) active++;
|
||||
}
|
||||
const p = db.preferences;
|
||||
return {
|
||||
dl_info_speed: dl,
|
||||
up_info_speed: up,
|
||||
dl_info_data: dlSession,
|
||||
up_info_data: upSession,
|
||||
dl_rate_limit: p.alt_speed_enabled ? p.alt_dl_limit : p.dl_limit,
|
||||
up_rate_limit: p.alt_speed_enabled ? p.alt_up_limit : p.up_limit,
|
||||
alt_speed_enabled: p.alt_speed_enabled,
|
||||
global_ratio: totalDown > 0 ? totalUp / totalDown : 0,
|
||||
dht_nodes: 312 + Math.floor(Math.random() * 40),
|
||||
connection_status: 'connected',
|
||||
listen_port: p.listen_port,
|
||||
free_space: 812 * 1024 * 1024 * 1024 + Math.floor(Math.random() * 1e9),
|
||||
active_torrents: active,
|
||||
total_torrents: db.torrents.length,
|
||||
queued_io_jobs: Math.floor(Math.random() * 4),
|
||||
read_cache_hits: (88 + Math.random() * 8).toFixed(1),
|
||||
total_buffer_size: 16 * MiB,
|
||||
average_time_queue: Math.floor(Math.random() * 12),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue