let _PROJECT_VERSION = '0.0.0';
try { _PROJECT_VERSION = require('../package.json').version; } catch (_) {}
let _fetch;
if (typeof fetch !== 'undefined') {
_fetch = fetch.bind(globalThis);
} else {
_fetch = function (url, opts) {
return new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? require('https') : require('http');
const parsed = require('url').parse(url);
const reqOpts = { ...parsed, method: 'GET', headers: (opts && opts.headers) || {} };
const req = mod.request(reqOpts, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
_fetch(res.headers.location, opts).then(resolve).catch(reject);
return;
}
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
arrayBuffer: () => {
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
return Promise.resolve(ab);
}
});
});
});
req.on('error', reject);
req.end();
});
};
}
console.log('[RtcTorrent] bundle v25 loaded');
const MSG_PIECE_REQUEST = 0x01;
const MSG_PIECE_DATA = 0x02;
const MSG_HAVE = 0x03;
const MSG_PIECE_CHUNK = 0x04;
const MAX_IN_FLIGHT = 64;
function nextPow2(n) {
if (n === 0) return 1;
let p = 1;
while (p < n) p <<= 1;
return p;
}
function sumFileTreeSizes(node) {
if (!node || typeof node !== 'object') return 0;
const attrs = node[''];
if (attrs !== undefined && attrs !== null) {
const len = attrs.length;
if (len != null) return Number(Buffer.isBuffer(len) ? len.toString() : len);
}
let total = 0;
for (const key of Object.keys(node)) {
const k = Buffer.isBuffer(key) ? key.toString() : key;
if (k === '') continue;
total += sumFileTreeSizes(node[key]);
}
return total;
}
function extractFilesFromTree(node, pathParts) {
if (!node || typeof node !== 'object') return [];
const attrs = node[''];
if (attrs !== undefined && attrs !== null) {
const len = attrs.length;
const length = len != null ? Number(Buffer.isBuffer(len) ? len.toString() : len) : 0;
return [{ name: pathParts.join('/'), length }];
}
const files = [];
for (const key of Object.keys(node).sort()) {
const k = Buffer.isBuffer(key) ? key.toString() : key;
if (k === '') continue;
files.push(...extractFilesFromTree(node[key], [...pathParts, k]));
}
return files;
}
class RtcTorrent {
constructor(options = {}) {
this.options = {
trackerUrl: options.trackerUrl || '',
announceInterval: options.announceInterval || 30000,
rtcInterval: options.rtcInterval || 10000,
maxPeers: options.maxPeers || 50,
iceServers: options.iceServers || [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
],
...options
};
this.pieces = new Map();
this.downloaded = 0;
this.requestedPieces = new Set();
this.torrents = new Map();
this.connections = new Map();
this.announcer = null;
this.webRtcManager = null;
this.bencoder = null;
this.isBrowser = typeof window !== 'undefined';
this.isFileApiAvailable = this.isBrowser && typeof File !== 'undefined';
if (!this.isBrowser) {
try { this.fs = require('fs'); } catch (_) {}
try { this.path = require('path'); } catch (_) {}
}
}
getRTCPeerConnection() {
if (typeof RTCPeerConnection !== 'undefined') {
return RTCPeerConnection;
}
const nativeRequire = (typeof __non_webpack_require__ !== 'undefined')
? __non_webpack_require__
: require;
for (const pkg of ['wrtc', '@roamhq/wrtc', 'node-webrtc']) {
try {
const m = nativeRequire(pkg);
if (m.RTCPeerConnection) return m.RTCPeerConnection;
} catch (_) {}
}
return null;
}
async _sha256Buffer(data) {
if (this.isBrowser) {
const ab = data instanceof Uint8Array
? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
: data;
const hash = await crypto.subtle.digest('SHA-256', ab);
return new Uint8Array(hash);
}
try {
const hash = require('crypto').createHash('sha256')
.update(data instanceof Buffer ? data : Buffer.from(data))
.digest();
return new Uint8Array(hash);
} catch (_) {
return new Uint8Array(32).fill(0);
}
}
async _sha256Hex(data) {
const buf = await this._sha256Buffer(data);
return Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('');
}
async _buildMerkleTree(fileBuffer, pieceLength) {
const BLOCK_SIZE = 16384;
const fileSize = fileBuffer.length || fileBuffer.byteLength || 0;
if (fileSize === 0) {
return { root: new Uint8Array(32), pieceLayerHashes: [] };
}
const blockCount = Math.ceil(fileSize / BLOCK_SIZE);
const leafHashes = [];
const block = new Uint8Array(BLOCK_SIZE);
for (let i = 0; i < blockCount; i++) {
const start = i * BLOCK_SIZE;
const end = Math.min(start + BLOCK_SIZE, fileSize);
const slice = fileBuffer instanceof Buffer
? fileBuffer.slice(start, end)
: (fileBuffer.subarray ? fileBuffer.subarray(start, end) : fileBuffer.slice(start, end));
block.fill(0);
block.set(slice instanceof Uint8Array ? slice : new Uint8Array(slice.buffer || slice));
leafHashes.push(await this._sha256Buffer(block));
}
const paddedCount = nextPow2(blockCount);
let current = [...leafHashes];
const zeroPad = new Uint8Array(32);
while (current.length < paddedCount) current.push(zeroPad);
const blocksPerPiece = Math.max(1, Math.floor(pieceLength / BLOCK_SIZE));
const levelOffset = Math.round(Math.log2(blocksPerPiece));
const pair = new Uint8Array(64);
let level = 0;
let pieceLayerHashes = null;
while (true) {
if (level === levelOffset) {
const realPieceCount = Math.max(1, Math.ceil(blockCount / blocksPerPiece));
pieceLayerHashes = current.slice(0, realPieceCount);
}
if (current.length === 1) break;
const next = [];
for (let i = 0; i < current.length; i += 2) {
pair.set(current[i], 0);
pair.set(current[i + 1], 32);
next.push(await this._sha256Buffer(pair));
}
current = next;
level++;
}
if (!pieceLayerHashes) pieceLayerHashes = [current[0]];
return {
root: current[0],
pieceLayerHashes: fileSize > pieceLength ? pieceLayerHashes : [],
};
}
_v2BStr(s) {
const b = Buffer.isBuffer(s) ? s : Buffer.from(s, 'utf8');
return Buffer.concat([Buffer.from(`${b.length}:`), b]);
}
_buildV2FileTree(filesInfo) {
const bStr = (s) => this._v2BStr(s);
const buildNode = (entries, depth) => {
const groups = new Map();
for (const e of entries) {
if (e.name.length > depth) {
const key = e.name[depth];
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
}
const parts = [Buffer.from('d')];
for (const key of [...groups.keys()].sort()) {
const children = groups.get(key);
parts.push(bStr(key));
if (children.length === 1 && children[0].name.length === depth + 1) {
const e = children[0];
const root = e.root ? Buffer.from(e.root) : null;
parts.push(Buffer.from('d'));
parts.push(Buffer.from('0:'));
parts.push(Buffer.from('d'));
parts.push(Buffer.from(`6:lengthi${e.length}e`));
if (e.length > 0 && root) {
parts.push(Buffer.from('11:pieces root'));
parts.push(Buffer.from(`${root.length}:`));
parts.push(root);
}
parts.push(Buffer.from('e'));
parts.push(Buffer.from('e'));
} else {
parts.push(buildNode(children, depth + 1));
}
}
parts.push(Buffer.from('e'));
return Buffer.concat(parts);
};
const sorted = [...filesInfo].sort((a, b) => {
const pa = a.name.join('/'), pb = b.name.join('/');
return pa < pb ? -1 : pa > pb ? 1 : 0;
});
return buildNode(sorted, 0);
}
_buildPieceLayers(layersInfo) {
const sorted = [...layersInfo].sort((a, b) => {
for (let i = 0; i < 32; i++) {
if (a.root[i] !== b.root[i]) return a.root[i] - b.root[i];
}
return 0;
});
const parts = [Buffer.from('d')];
for (const { root, hashes } of sorted) {
const rootBuf = Buffer.from(root);
parts.push(Buffer.from(`${rootBuf.length}:`));
parts.push(rootBuf);
const value = Buffer.concat(hashes.map(h => Buffer.from(h)));
parts.push(Buffer.from(`${value.length}:`));
parts.push(value);
}
parts.push(Buffer.from('e'));
return Buffer.concat(parts);
}
_buildV2InfoBencode(name, pieceLength, filesInfo) {
const bStr = (s) => this._v2BStr(s);
const fileTree = this._buildV2FileTree(filesInfo);
const parts = [Buffer.from('d')];
parts.push(bStr('file tree'));
parts.push(fileTree);
parts.push(Buffer.from('12:meta versioni2e'));
parts.push(bStr('name'));
parts.push(bStr(name));
parts.push(Buffer.from(`12:piece lengthi${pieceLength}e`));
parts.push(Buffer.from('e'));
return Buffer.concat(parts);
}
_buildHybridInfoBencode(name, pieceLength, v1Pieces, filesInfo, totalSize) {
const bStr = (s) => this._v2BStr(s);
const fileTree = this._buildV2FileTree(filesInfo);
const parts = [Buffer.from('d')];
parts.push(bStr('file tree')); parts.push(fileTree);
if (filesInfo.length > 1) {
parts.push(Buffer.from('5:filesl'));
for (const f of filesInfo) {
parts.push(Buffer.from('d'));
parts.push(Buffer.from(`6:lengthi${f.length}e`));
parts.push(Buffer.from('4:pathl'));
for (const comp of f.name) parts.push(bStr(comp));
parts.push(Buffer.from('e'));
parts.push(Buffer.from('e'));
}
parts.push(Buffer.from('e'));
} else {
parts.push(Buffer.from(`6:lengthi${totalSize}e`));
}
parts.push(Buffer.from('12:meta versioni2e'));
parts.push(bStr('name'));
parts.push(bStr(name));
parts.push(Buffer.from(`12:piece lengthi${pieceLength}e`));
const piecesBuf = Buffer.isBuffer(v1Pieces) ? v1Pieces : Buffer.from(v1Pieces);
parts.push(bStr('pieces'));
parts.push(Buffer.from(`${piecesBuf.length}:`));
parts.push(piecesBuf);
parts.push(Buffer.from('e'));
return Buffer.concat(parts);
}
_buildV2TorrentBencode(infoBytes, trackerUrl, creationDate, webseeds, pieceLayersBytes) {
const bStr = (s) => this._v2BStr(s);
const infoBuf = Buffer.isBuffer(infoBytes) ? infoBytes : Buffer.from(infoBytes);
const layersBuf = Buffer.isBuffer(pieceLayersBytes) ? pieceLayersBytes : Buffer.from(pieceLayersBytes);
const parts = [Buffer.from('d')];
parts.push(bStr('announce'));
parts.push(bStr(trackerUrl));
parts.push(bStr('created by'));
parts.push(bStr(`Torrust-Actix v${_PROJECT_VERSION}`));
parts.push(bStr('creation date'));
parts.push(Buffer.from(`i${creationDate}e`));
parts.push(bStr('info'));
parts.push(infoBuf);
parts.push(bStr('piece layers'));
parts.push(layersBuf);
if (webseeds && webseeds.length > 0) {
parts.push(bStr('url-list'));
if (webseeds.length === 1) {
parts.push(bStr(webseeds[0]));
} else {
parts.push(Buffer.from('l'));
for (const u of webseeds) parts.push(bStr(u));
parts.push(Buffer.from('e'));
}
}
parts.push(Buffer.from('e'));
return Buffer.concat(parts);
}
async initBencoder() {
if (this.bencoder) return this.bencoder;
try {
this.bencoder = require('bencode');
} catch (_) {
this.bencoder = this.createMinimalBencoder();
}
return this.bencoder;
}
createMinimalBencoder() {
const enc = (v) => {
if (typeof v === 'string') return `${v.length}:${v}`;
if (typeof v === 'number') return `i${v}e`;
if (Buffer.isBuffer(v) || v instanceof Uint8Array) {
const b = Buffer.isBuffer(v) ? v : Buffer.from(v);
return `${b.length}:${b.toString('binary')}`;
}
if (Array.isArray(v)) return 'l' + v.map(enc).join('') + 'e';
if (v && typeof v === 'object') {
return 'd' + Object.keys(v).sort().map(k => enc(k) + enc(v[k])).join('') + 'e';
}
return '';
};
return { encode: enc };
}
async create(files, options = {}) {
const bencoder = await this.initBencoder();
const version = options.version || 'v1';
let totalSize = 0, fileInfos = [], allFileBuffers = [], fileNames = [];
if (Array.isArray(files)) {
for (const file of files) {
if (!this.isBrowser && this.fs) {
const filePath = typeof file === 'string' ? file : file.path;
const fileName = typeof file === 'string'
? this.path.basename(filePath)
: (file.name || this.path.basename(filePath));
const stats = this.fs.statSync(filePath);
totalSize += stats.size;
fileInfos.push({ length: stats.size, path: [fileName] });
fileNames.push([fileName]);
allFileBuffers.push(this.fs.readFileSync(filePath));
} else if (this.isBrowser && this.isFileApiAvailable && file instanceof File) {
totalSize += file.size;
fileInfos.push({ length: file.size, path: [file.name] });
fileNames.push([file.name]);
const ab = await file.arrayBuffer();
allFileBuffers.push(Buffer.from(ab));
} else {
totalSize += file.size || 0;
fileInfos.push({ length: file.size || 0, path: [file.name || 'file'] });
fileNames.push([file.name || 'file']);
}
}
}
let pieceLength = 16 * 1024;
if (totalSize > 8 * 1024 * 1024) pieceLength = 32 * 1024;
if (totalSize > 64 * 1024 * 1024) pieceLength = 64 * 1024;
const torrentName = options.name || 'UnnamedTorrent';
const creationDate = Math.floor(Date.now() / 1000);
const webseedUrls = options.webseedUrls
? (Array.isArray(options.webseedUrls) ? options.webseedUrls : [options.webseedUrls])
: [];
if (version === 'v2' || version === 'hybrid') {
const filesInfo = fileInfos.map((fi, i) => ({
name: fileNames[i],
length: fi.length,
root: null,
}));
const layersInfo = [];
for (let i = 0; i < allFileBuffers.length; i++) {
const { root, pieceLayerHashes } = await this._buildMerkleTree(allFileBuffers[i], pieceLength);
filesInfo[i].root = root;
if (pieceLayerHashes.length > 0) {
layersInfo.push({ root, hashes: pieceLayerHashes });
}
}
const pieceLayersBuf = this._buildPieceLayers(layersInfo);
let infoBytes, v1PiecesArray;
if (version === 'hybrid') {
const combinedBuffer = allFileBuffers.length > 0
? Buffer.concat(allFileBuffers) : Buffer.alloc(totalSize);
const numPieces = Math.ceil(totalSize / pieceLength);
v1PiecesArray = new Uint8Array(numPieces * 20);
for (let i = 0; i < numPieces; i++) {
const hash = await this.sha1Buffer(
combinedBuffer.slice(i * pieceLength, Math.min((i + 1) * pieceLength, combinedBuffer.length))
);
v1PiecesArray.set(hash, i * 20);
}
infoBytes = this._buildHybridInfoBencode(torrentName, pieceLength, v1PiecesArray, filesInfo, totalSize);
} else {
infoBytes = this._buildV2InfoBencode(torrentName, pieceLength, filesInfo);
}
const v2Hash = await this._sha256Hex(infoBytes);
let infoHash, magnetUri;
if (version === 'v2') {
infoHash = v2Hash.slice(0, 40);
magnetUri = `magnet:?xt=urn:btmh:1220${v2Hash}&dn=${encodeURIComponent(torrentName)}&tr=${encodeURIComponent(this.options.trackerUrl)}`;
} else {
const v1Hash = await this.sha1Hex(infoBytes);
infoHash = v1Hash;
magnetUri = `magnet:?xt=urn:btih:${v1Hash}&xt=urn:btmh:1220${v2Hash}&dn=${encodeURIComponent(torrentName)}&tr=${encodeURIComponent(this.options.trackerUrl)}`;
}
const torrentBytes = this._buildV2TorrentBencode(
infoBytes, this.options.trackerUrl, creationDate, webseedUrls, pieceLayersBuf
);
const infoObj = {
name: torrentName,
'piece length': pieceLength,
...(fileInfos.length > 1
? { files: fileInfos }
: { length: totalSize }),
};
if (v1PiecesArray) infoObj.pieces = Buffer.from(v1PiecesArray);
const torrent = {
info: infoObj,
announce: this.options.trackerUrl,
'creation date': creationDate,
'created by': `Torrust-Actix v${_PROJECT_VERSION}`,
_originalFiles: allFileBuffers,
_pieceLength: pieceLength,
infoHash,
};
return { torrent, magnetUri, infoHash, v2InfoHash: v2Hash, encodedTorrent: torrentBytes };
}
const combinedBuffer = allFileBuffers.length > 0
? Buffer.concat(allFileBuffers)
: Buffer.alloc(totalSize);
const numPieces = Math.ceil(totalSize / pieceLength);
const piecesArray = new Uint8Array(numPieces * 20);
for (let i = 0; i < numPieces; i++) {
const hash = await this.sha1Buffer(
combinedBuffer.slice(i * pieceLength, Math.min((i + 1) * pieceLength, combinedBuffer.length))
);
piecesArray.set(hash, i * 20);
}
const info = {
name: torrentName,
'piece length': pieceLength,
pieces: Buffer.from(piecesArray),
length: totalSize
};
if (fileInfos.length > 1) {
info.files = fileInfos;
delete info.length;
} else if (fileInfos.length === 1) {
info.length = fileInfos[0].length;
}
const torrent = {
info,
announce: this.options.trackerUrl,
'creation date': creationDate,
'created by': `Torrust-Actix v${_PROJECT_VERSION}`,
_originalFiles: allFileBuffers,
_pieceLength: pieceLength
};
const bencodedInfo = bencoder.encode(torrent.info);
const infoHash = await this.sha1Hex(bencodedInfo);
const exportable = {
info: torrent.info,
announce: torrent.announce,
'creation date': torrent['creation date'],
'created by': torrent['created by'],
};
if (webseedUrls.length > 0) {
exportable['url-list'] = webseedUrls.length === 1 ? webseedUrls[0] : webseedUrls;
}
return {
torrent,
magnetUri: this.createMagnetURI(torrent, infoHash),
infoHash,
encodedTorrent: bencoder.encode(exportable)
};
}
sha1Hex(data) {
if (this.isBrowser) return this.sha1Browser(data);
try {
return require('crypto').createHash('sha1').update(data).digest('hex');
} catch (_) {
return '0'.repeat(40);
}
}
async sha1Browser(data) {
if (typeof data === 'string') data = new TextEncoder().encode(data);
const buf = await crypto.subtle.digest('SHA-1', data);
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
async sha1Buffer(data) {
if (this.isBrowser) {
const buf = await crypto.subtle.digest('SHA-1', data);
return new Uint8Array(buf);
}
try {
return require('crypto').createHash('sha1').update(data).digest();
} catch (_) {
return new Uint8Array(20).fill(0x42);
}
}
createMagnetURI(torrent, infoHash) {
const trackers = Array.isArray(torrent.announce) ? torrent.announce : [torrent.announce];
const params = [`xt=urn:btih:${infoHash}`];
if (torrent.info?.name) params.push(`dn=${encodeURIComponent(torrent.info.name)}`);
trackers.forEach(tr => tr && params.push(`tr=${encodeURIComponent(tr)}`));
return `magnet:?${params.join('&')}`;
}
async calculateInfoHash(info) {
const bencoder = this.createMinimalBencoder();
return await this.sha1Hex(bencoder.encode(info));
}
async download(torrentData) {
let torrent = torrentData;
if (typeof torrentData === 'string') {
torrent = torrentData.startsWith('magnet:')
? await this.parseMagnet(torrentData)
: await this.fetchTorrentFile(torrentData);
} else if (Buffer.isBuffer(torrentData) || torrentData instanceof Uint8Array) {
torrent = await this.parseTorrentFile(torrentData);
}
const id = torrent.infoHash || await this.calculateInfoHash(torrent.info);
if (this.torrents.has(id)) throw new Error('Torrent already exists');
const inst = new Torrent(this, torrent, this.options);
this.torrents.set(id, inst);
return await inst.start();
}
async seed(torrentData, files = null) {
let torrent;
if (typeof torrentData === 'string') {
torrent = torrentData.startsWith('magnet:')
? await this.parseMagnet(torrentData)
: await this.fetchTorrentFile(torrentData);
} else if (Buffer.isBuffer(torrentData) || torrentData instanceof Uint8Array) {
torrent = await this.parseTorrentFile(torrentData);
} else {
torrent = torrentData;
}
if (!torrent.info && torrent.torrent?.info) {
torrent = { ...torrent, info: torrent.torrent.info };
}
if (files !== null && !this.isBrowser && this.fs) {
const fileMeta = files
.map(f => {
const p = typeof f === 'string' ? f : f.path;
if (!this.fs.existsSync(p)) return null;
return { path: p, size: this.fs.statSync(p).size };
})
.filter(Boolean);
if (fileMeta.length > 0) {
if (!torrent.torrent) torrent.torrent = {};
torrent.torrent._filePaths = fileMeta;
torrent.torrent._pieceLength = torrent.info?.['piece length'] || 256 * 1024;
}
}
if (files !== null && this.isBrowser && this.isFileApiAvailable) {
const fileMeta = [];
for (const f of files) {
if (f instanceof File) fileMeta.push({ file: f, size: f.size });
}
if (fileMeta.length > 0) {
if (!torrent.torrent) torrent.torrent = {};
torrent.torrent._fileObjects = fileMeta;
torrent.torrent._pieceLength = torrent.info?.['piece length'] || 256 * 1024;
}
}
const id = torrent.infoHash || await this.calculateInfoHash(torrent.info);
if (this.torrents.has(id)) throw new Error('Torrent already exists');
const inst = new Torrent(this, torrent, this.options);
this.torrents.set(id, inst);
return await inst.seed();
}
async parseTorrentFile(torrentData) {
const bencoder = await this.initBencoder();
let buf;
if (Buffer.isBuffer(torrentData)) buf = torrentData;
else if (torrentData instanceof Uint8Array) buf = Buffer.from(torrentData);
else throw new Error('Invalid torrent data type');
const decoded = bencoder.decode(buf);
const info = decoded.info;
const bencodedInfo = bencoder.encode(info);
const name = Buffer.isBuffer(info?.name) ? info.name.toString() : (info?.name || 'Unknown');
const urlList = decoded['url-list'];
const webseeds = urlList
? (Array.isArray(urlList)
? urlList.map(u => Buffer.isBuffer(u) ? u.toString() : String(u))
: [Buffer.isBuffer(urlList) ? urlList.toString() : String(urlList)])
: [];
const ftKey = Object.keys(info || {}).find(k => {
const ks = Buffer.isBuffer(k) ? k.toString() : k;
return ks === 'file tree';
});
const metaVer = info?.['meta version'];
const isV2 = ftKey !== undefined || metaVer === 2;
let infoHash, v2InfoHash = null;
if (isV2) {
const v2Hash = await this._sha256Hex(bencodedInfo);
v2InfoHash = v2Hash;
const hasPieces = info?.pieces &&
(Buffer.isBuffer(info.pieces) ? info.pieces.length : (info.pieces?.length || 0)) > 0;
if (hasPieces) {
infoHash = await this.sha1Hex(bencodedInfo);
} else {
infoHash = v2Hash.slice(0, 40);
}
} else {
infoHash = await this.sha1Hex(bencodedInfo);
}
return { info, announce: decoded.announce, announceList: decoded['announce-list'], infoHash, v2InfoHash, name, webseeds };
}
async fetchTorrentFile(url) {
const r = await _fetch(url);
if (!r.ok) throw new Error(`Failed to fetch torrent: ${r.status}`);
return this.parseTorrentFile(Buffer.from(await r.arrayBuffer()));
}
async parseMagnet(magnetUri) {
const url = new URL(magnetUri);
if (url.protocol !== 'magnet:') throw new Error('Invalid magnet URI');
const xtValues = url.searchParams.getAll('xt');
let v1Hash = null, v2Hash = null;
for (const xt of xtValues) {
if (xt.startsWith('urn:btih:')) v1Hash = xt.replace('urn:btih:', '').toLowerCase();
else if (xt.startsWith('urn:btmh:1220')) v2Hash = xt.replace('urn:btmh:1220', '').toLowerCase();
}
if (!v1Hash && !v2Hash) {
const xt = url.searchParams.get('xt') || '';
if (xt.startsWith('urn:btih:')) v1Hash = xt.replace('urn:btih:', '').toLowerCase();
else if (xt.startsWith('urn:btmh:1220')) v2Hash = xt.replace('urn:btmh:1220', '').toLowerCase();
}
let infoHash;
if (v1Hash) {
infoHash = v1Hash;
} else if (v2Hash) {
infoHash = v2Hash.slice(0, 40);
} else {
throw new Error('No recognized info hash in magnet URI');
}
const tr = url.searchParams.getAll('tr');
const dn = url.searchParams.get('dn');
return {
infoHash,
v2InfoHash: v2Hash || null,
announce: tr.length > 0 ? tr : [this.options.trackerUrl],
name: dn ? decodeURIComponent(dn) : 'Unknown',
};
}
async start() {
this.webRtcManager = new WebRTCManager(this.options);
this.announcer = new Announcer(this.options);
console.log('RtcTorrent client started');
}
async stop() {
for (const [, torrent] of this.torrents) await torrent.stop();
if (this.webRtcManager) await this.webRtcManager.close();
console.log('RtcTorrent client stopped');
}
async streamVideo(infoHash, fileIndex = 0, videoElement) {
const torrent = this.torrents.get(infoHash);
if (!torrent) throw new Error(`Torrent ${infoHash} not found`);
return torrent.streamVideo(fileIndex, videoElement);
}
}
class Torrent {
constructor(client, torrentData, options) {
this.client = client;
this.data = torrentData;
this.options = options;
this.peers = new Map();
this.pieces = new Map();
this.downloaded = 0;
this.uploaded = 0;
this.active = false;
this.isSeeder = false;
this.signalChannel = null;
this.pieceLength = torrentData.info?.['piece length'] || 256 * 1024;
this.totalSize = this.calculateTotalSize(torrentData);
this.files = this.extractFiles(torrentData);
this.pieceCount = Math.ceil(this.totalSize / this.pieceLength) || 0;
this._peerIdBytes = this._generatePeerIdBytes();
this._peerIdHex = Buffer.from(this._peerIdBytes).toString('hex');
this._localPc = null;
this._localSdp = null;
this.mediaSource = null;
this.sourceBuffer = null;
this.requestedPieces = new Set();
this._downloadCompleted = false;
this._sendQueue = Promise.resolve();
this._fdCache = new Map();
this._peerStats = new Map();
this._pieceChunks = new Map();
this.webseeds = torrentData.webseeds || [];
this._webseedFetching = new Set();
this._trackers = this._buildTrackerList(torrentData);
this._ignoredTrackers = new Set();
}
calculateTotalSize(torrentData) {
if (torrentData.info?.length) return torrentData.info.length;
if (Array.isArray(torrentData.info?.files)) {
return torrentData.info.files.reduce((s, f) => s + (f.length || 0), 0);
}
const ftKey = Object.keys(torrentData.info || {}).find(k => {
const ks = Buffer && Buffer.isBuffer(k) ? k.toString() : k;
return ks === 'file tree';
});
if (ftKey !== undefined) {
return sumFileTreeSizes(torrentData.info[ftKey]);
}
return 0;
}
extractFiles(torrentData) {
if (torrentData.info?.length && torrentData.info?.name) {
const name = Buffer.isBuffer(torrentData.info.name)
? torrentData.info.name.toString()
: torrentData.info.name;
return [{ name, length: torrentData.info.length, offset: 0 }];
}
if (Array.isArray(torrentData.info?.files)) {
let offset = 0;
return torrentData.info.files.map(f => {
const fi = {
name: Array.isArray(f.path)
? f.path.map(p => Buffer.isBuffer(p) ? p.toString() : p).join('/')
: (Buffer.isBuffer(f.path) ? f.path.toString() : f.path),
length: f.length,
offset
};
offset += f.length;
return fi;
});
}
const ftKey = Object.keys(torrentData.info || {}).find(k => {
const ks = Buffer && Buffer.isBuffer(k) ? k.toString() : k;
return ks === 'file tree';
});
if (ftKey !== undefined) {
const files = extractFilesFromTree(torrentData.info[ftKey], []);
let offset = 0;
for (const f of files) { f.offset = offset; offset += f.length; }
return files;
}
return [];
}
_generatePeerIdBytes() {
const prefix = '-RT1000-';
let id = prefix;
for (let i = 0; i < 20 - prefix.length; i++) {
id += Math.floor(Math.random() * 10).toString();
}
return Buffer.from(id, 'ascii');
}
_webseedPieceRanges(pieceIndex) {
const globalStart = pieceIndex * this.pieceLength;
const globalEnd = Math.min(globalStart + this.pieceLength, this.totalSize);
if (this.files.length <= 1) {
return [{ url: this.webseeds[0], start: globalStart, end: globalEnd - 1, offset: 0 }];
}
const baseUrl = this.webseeds[0].endsWith('/') ? this.webseeds[0] : this.webseeds[0] + '/';
const torrentName = Buffer.isBuffer(this.data.info?.name)
? this.data.info.name.toString()
: (this.data.info?.name || '');
const ranges = [];
for (const file of this.files) {
const fileEnd = file.offset + file.length;
const overlapStart = Math.max(globalStart, file.offset);
const overlapEnd = Math.min(globalEnd, fileEnd);
if (overlapStart >= overlapEnd) continue;
const fileUrl = baseUrl + encodeURIComponent(torrentName) + '/' +
file.name.split('/').map(encodeURIComponent).join('/');
ranges.push({
url: fileUrl,
start: overlapStart - file.offset,
end: overlapEnd - file.offset - 1,
offset: overlapStart - globalStart,
length: overlapEnd - overlapStart
});
}
return ranges;
}
async _fetchPieceFromWebseed(pieceIndex) {
if (!this.webseeds.length || this._webseedFetching.has(pieceIndex)) return;
this._webseedFetching.add(pieceIndex);
try {
const ranges = this._webseedPieceRanges(pieceIndex);
const pieceSize = Math.min(this.pieceLength, this.totalSize - pieceIndex * this.pieceLength);
const pieceData = new Uint8Array(pieceSize);
for (const r of ranges) {
const res = await _fetch(r.url, { headers: { Range: `bytes=${r.start}-${r.end}` } });
if (!res.ok && res.status !== 206) {
throw new Error(`HTTP ${res.status} from webseed`);
}
const ab = await res.arrayBuffer();
const bytes = new Uint8Array(ab);
if (this.files.length <= 1) {
pieceData.set(bytes.slice(0, pieceSize));
} else {
pieceData.set(bytes.slice(0, r.length), r.offset);
}
}
const ok = await this._verifyPiece(pieceIndex, pieceData);
if (!ok) {
console.warn(`[Webseed] Piece ${pieceIndex} hash mismatch — discarding`);
return;
}
if (!this.pieces.has(pieceIndex)) {
this.pieces.set(pieceIndex, pieceData);
this.requestedPieces.delete(pieceIndex);
const added = Math.min(pieceData.length, this.totalSize - this.downloaded);
this.downloaded += added;
console.log(`[Webseed] Piece ${pieceIndex} OK (${pieceData.length} B) — ` +
`${this.pieces.size}/${this.pieceCount} pieces`);
if (typeof this.onPieceReceived === 'function') {
this.onPieceReceived(pieceIndex, pieceData);
}
this.updateProgress();
if (!this._downloadCompleted) this._requestMissingPieces();
}
} catch (err) {
console.warn(`[Webseed] Failed to fetch piece ${pieceIndex}:`, err.message);
} finally {
this._webseedFetching.delete(pieceIndex);
}
}
_buildTrackerList(torrentData) {
const seen = new Set();
const add = (v) => {
const s = Buffer.isBuffer(v) ? v.toString() : (typeof v === 'string' ? v : null);
if (s) seen.add(s);
};
if (torrentData.announce) {
const ann = torrentData.announce;
Array.isArray(ann) ? ann.forEach(add) : add(ann);
}
const annList = torrentData.announceList;
if (Array.isArray(annList)) {
for (const tier of annList) {
if (Array.isArray(tier)) tier.forEach(add);
else add(tier);
}
}
if (seen.size === 0 && this.client.options.trackerUrl) {
seen.add(this.client.options.trackerUrl);
}
const all = [...seen];
const http = all.filter(u => /^https?:\/\//i.test(u));
const skipped = all.filter(u => !/^https?:\/\//i.test(u));
for (const s of skipped) {
console.log(`[RtcTorrent] Skipping unsupported tracker protocol (not HTTP/HTTPS): ${s}`);
}
if (http.length === 0) {
console.warn('[RtcTorrent] No HTTP/HTTPS trackers found — WebRTC signaling will not work');
}
return http;
}
urlEncodeBytes(bytes) {
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += '%' + bytes[i].toString(16).padStart(2, '0');
}
return out;
}
hexToUint8Array(hex) {
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16);
return b;
}
async start() {
this.active = true;
this.signalChannel = new SignalChannel(this.client.options.trackerUrl, this.data.infoHash);
this.signalChannel.on('offer', sdp => this.handleOffer(sdp));
this.signalChannel.on('answer', sdp => this.handleAnswer(sdp));
this.announceLoop();
if (!this.isSeeder) this._startPeerSpeedCheck();
console.log(`Started torrent ${this.data.infoHash}`);
return this;
}
async seed() {
this.isSeeder = true;
this.downloaded = this.totalSize;
return this.start();
}
async stop() {
this.active = false;
for (const [, peer] of this.peers) {
try { peer.connection?.close(); } catch (_) {}
}
if (this.signalChannel) this.signalChannel.close();
if (this._localPc) { try { this._localPc.close(); } catch (_) {} }
if (this._fdCache && this.client.fs) {
for (const [, fd] of this._fdCache) {
try { this.client.fs.closeSync(fd); } catch (_) {}
}
this._fdCache.clear();
}
console.log(`Stopped torrent ${this.data.infoHash}`);
}
async announceLoop() {
if (!this.active) return;
try {
const isSeeder = this.isSeeder || (this.totalSize > 0 && this.downloaded >= this.totalSize);
const activeTrackers = this._trackers.filter(t => !this._ignoredTrackers.has(t));
if (activeTrackers.length === 0 && this._trackers.length > 0) {
console.warn('[RtcTorrent] All trackers are on the ignore list — no WebRTC signaling possible');
}
for (const trackerUrl of activeTrackers) {
try {
let rtcResponse;
if (isSeeder) {
const sdpOffer = await this.getOrCreateSdpOffer();
rtcResponse = await this.announce(
{ event: 'started', numwant: 50 },
{ rtctorrent: true, rtcoffer: sdpOffer },
trackerUrl
);
if (rtcResponse?.rtc_answers?.length) {
for (const answer of rtcResponse.rtc_answers) {
await this.handleAnswerFromTracker(answer);
}
}
} else {
rtcResponse = await this.announce(
{ event: 'started', numwant: 50 },
{ rtctorrent: true, rtcrequest: true },
trackerUrl
);
if (rtcResponse?.rtc_peers?.length) {
for (const peer of rtcResponse.rtc_peers) {
if (!this.peers.has(peer.peer_id)) {
await this.connectToWebRTCPeer(peer, trackerUrl);
}
}
}
if (!this.isSeeder) this._requestMissingPieces();
}
if (rtcResponse?.rtc_interval) {
this.client.options.rtcInterval = rtcResponse.rtc_interval;
}
if (rtcResponse?.rtcNotSupported) {
this._ignoredTrackers.add(trackerUrl);
console.warn(
`[RtcTorrent] Tracker does not support WebRTC signaling: ${trackerUrl}` +
` — added to temporary ignore list (will retry on restart)`
);
}
} catch (err) {
console.error(`[RtcTorrent] Announce to ${trackerUrl} failed:`, err.message);
}
}
} catch (err) {
console.error('RtcTorrent announce error:', err);
} finally {
if (this.active) {
setTimeout(() => this.announceLoop(), this.client.options.rtcInterval);
}
}
}
_requestMissingPieces() {
const toRequest = Math.max(0, MAX_IN_FLIGHT - this.requestedPieces.size);
if (toRequest === 0) return;
let requested = 0;
for (let i = 0; i < this.pieceCount && requested < toRequest; i++) {
if (!this.pieces.has(i) && !this.requestedPieces.has(i)) {
this.requestPieceFromPeers(i);
this.requestedPieces.add(i);
requested++;
}
}
}
async announce(params = {}, rtcParams = null, trackerUrl = null) {
const baseUrl = trackerUrl || this.client.options.trackerUrl;
const infoHashBytes = this.hexToUint8Array(this.data.infoHash);
const left = Math.max(0, this.totalSize - this.downloaded);
const parts = [
'info_hash=' + this.urlEncodeBytes(infoHashBytes),
'peer_id=' + this.urlEncodeBytes(this._peerIdBytes),
'port=6881',
'uploaded=' + this.uploaded,
'downloaded=' + this.downloaded,
'left=' + left,
'compact=1'
];
if (params.event) parts.push('event=' + params.event);
if (params.numwant) parts.push('numwant=' + params.numwant);
if (rtcParams) {
parts.push('rtctorrent=1');
if (rtcParams.rtcoffer) parts.push('rtcoffer=' + encodeURIComponent(rtcParams.rtcoffer));
if (rtcParams.rtcrequest) parts.push('rtcrequest=1');
if (rtcParams.rtcanswer) parts.push('rtcanswer=' + encodeURIComponent(rtcParams.rtcanswer));
if (rtcParams.rtcanswerfor) parts.push('rtcanswerfor=' + encodeURIComponent(rtcParams.rtcanswerfor));
}
const url = `${baseUrl}?${parts.join('&')}`;
try {
const res = await _fetch(url);
if (!res.ok) throw new Error(`Tracker returned ${res.status}`);
const ab = await res.arrayBuffer();
return this.parseBencodedResponse(new Uint8Array(ab));
} catch (err) {
console.error('Announce failed:', err.message);
throw err;
}
}
parseBencodedResponse(responseBytes) {
try {
let bencode;
try { bencode = require('bencode'); } catch (_) { bencode = null; }
if (!bencode) {
console.warn('bencode library not available – tracker response may not be fully parsed');
return {};
}
const decoded = bencode.decode(Buffer.from(responseBytes));
const str = (v) => Buffer.isBuffer(v) ? v.toString() : (v != null ? String(v) : null);
const num = (v) => v != null ? Number(v) : undefined;
const result = {};
if (decoded.interval) result.interval = num(decoded.interval);
if (decoded['rtc interval']) result.rtc_interval = num(decoded['rtc interval']) * 1000;
if (decoded.complete != null) result.complete = num(decoded.complete);
if (decoded.incomplete != null) result.incomplete = num(decoded.incomplete);
if (Array.isArray(decoded.rtc_peers)) {
result.rtc_peers = decoded.rtc_peers
.map(p => ({
peer_id: Buffer.isBuffer(p.peer_id) ? p.peer_id.toString('hex') : str(p.peer_id),
sdp_offer: str(p.sdp_offer)
}))
.filter(p => p.peer_id && p.sdp_offer);
}
if (Array.isArray(decoded.rtc_answers)) {
result.rtc_answers = decoded.rtc_answers
.map(a => ({
peer_id: Buffer.isBuffer(a.peer_id) ? a.peer_id.toString('hex') : str(a.peer_id),
sdp_answer: str(a.sdp_answer)
}))
.filter(a => a.peer_id && a.sdp_answer);
}
if (decoded['failure reason']) {
result.failure_reason = str(decoded['failure reason']);
console.error('Tracker failure:', result.failure_reason);
if (result.failure_reason.toLowerCase().includes('rtctorrent')) {
result.rtcNotSupported = true;
}
} else if (decoded.rtc_peers === undefined && decoded.rtc_answers === undefined) {
result.rtcNotSupported = true;
}
return result;
} catch (err) {
console.error('Error parsing bencoded response:', err.message);
return {};
}
}
async getOrCreateSdpOffer() {
if (this._localSdp && this._localPc &&
this._localPc.signalingState !== 'closed' &&
this._localPc.connectionState !== 'connected') {
return this._localSdp;
}
const RTCPeerConnection = this.client.getRTCPeerConnection();
if (!RTCPeerConnection) {
console.warn('RTCPeerConnection not available. ' +
'In Node.js, install "wrtc" or "@roamhq/wrtc" for WebRTC support.');
return null;
}
try {
const pc = new RTCPeerConnection({ iceServers: this.client.options.iceServers });
const dc = pc.createDataChannel('torrent', { ordered: false, maxRetransmits: 3 });
this._setupDataChannelSeeder(dc);
const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false });
await pc.setLocalDescription(offer);
await this._waitForIceGathering(pc);
this._localPc = pc;
this._localSdp = pc.localDescription.sdp;
console.log(`[Seeder] SDP offer created (${this._localSdp.length} bytes)`);
return this._localSdp;
} catch (err) {
console.error('Failed to create SDP offer:', err);
return null;
}
}
_waitForIceGathering(pc) {
return new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') { resolve(); return; }
const done = () => { if (pc.iceGatheringState === 'complete') resolve(); };
pc.onicegatheringstatechange = done;
setTimeout(resolve, 5000);
});
}
_setupDataChannelSeeder(dc) {
dc.onopen = () => {
console.log('[Seeder] Data channel opened');
};
dc.onerror = (e) => console.error('[Seeder] Data channel error:', e);
dc.onclose = () => console.log('[Seeder] Data channel closed');
dc.onmessage = (event) => this.handleMessage(event.data, null, dc);
}
async connectToWebRTCPeer(peerInfo, trackerUrl = null) {
if (!peerInfo?.sdp_offer) {
console.warn('connectToWebRTCPeer: peerInfo.sdp_offer missing');
return;
}
const RTCPeerConnection = this.client.getRTCPeerConnection();
if (!RTCPeerConnection) {
console.warn('RTCPeerConnection not available – skipping peer connection');
return;
}
try {
const pc = new RTCPeerConnection({ iceServers: this.client.options.iceServers });
pc.ondatachannel = (event) => {
const dc = event.channel;
this.setupDataChannel(dc, peerInfo);
const existing = this.peers.get(peerInfo.peer_id);
if (existing) existing.channel = dc;
if (dc.readyState === 'open') {
this.requestedPieces.clear();
this._requestMissingPieces();
}
};
await pc.setRemoteDescription({ type: 'offer', sdp: peerInfo.sdp_offer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await this._waitForIceGathering(pc);
const answerSdp = pc.localDescription.sdp;
this.peers.set(peerInfo.peer_id, {
connection: pc,
channel: null,
info: peerInfo,
connected: false
});
const answerTracker = trackerUrl ||
this._trackers.find(t => !this._ignoredTrackers.has(t)) ||
this.client.options.trackerUrl;
await this.announce(
{ event: 'started', numwant: 0 },
{ rtctorrent: true, rtcanswer: answerSdp, rtcanswerfor: peerInfo.peer_id },
answerTracker
);
console.log(`[Leecher] Sent SDP answer to seeder ${peerInfo.peer_id}`);
} catch (err) {
console.error('Error connecting to WebRTC peer:', err);
}
}
async handleAnswerFromTracker(answerInfo) {
if (!this._localPc || !answerInfo?.sdp_answer) return;
try {
if (this._localPc.signalingState !== 'have-local-offer') {
console.warn('[Seeder] Cannot apply answer: signalingState =', this._localPc.signalingState);
return;
}
await this._localPc.setRemoteDescription({ type: 'answer', sdp: answerInfo.sdp_answer });
console.log(`[Seeder] WebRTC connection established with leecher ${answerInfo.peer_id}`);
this.peers.set(answerInfo.peer_id, {
connection: this._localPc,
channel: null,
info: { peer_id: answerInfo.peer_id },
connected: false
});
this._localPc = null;
this._localSdp = null;
} catch (err) {
console.error('[Seeder] Error applying SDP answer:', err);
}
}
setupDataChannel(channel, peerInfo) {
channel.binaryType = 'arraybuffer';
channel.onopen = () => {
console.log('[Leecher] Data channel opened with peer', peerInfo?.peer_id);
if (peerInfo?.peer_id && this.peers.has(peerInfo.peer_id)) {
this.peers.get(peerInfo.peer_id).connected = true;
}
this.requestedPieces.clear();
this._requestMissingPieces();
};
channel.onclose = () => console.log('[Leecher] Data channel closed');
channel.onerror = (e) => console.error('[Leecher] Data channel error:', e);
channel.onmessage = (event) => this.handleMessage(event.data, peerInfo, channel);
}
handleMessage(data, peerInfo, channel) {
try {
let view;
if (data instanceof ArrayBuffer) {
view = new DataView(data);
} else if (ArrayBuffer.isView(data)) {
view = new DataView(data.buffer, data.byteOffset, data.byteLength);
} else {
return;
}
const msgType = view.getUint8(0);
if (msgType === MSG_PIECE_REQUEST) {
const pieceIndex = view.getUint32(1, false);
this.handlePieceRequest(pieceIndex, channel);
} else if (msgType === MSG_PIECE_DATA) {
const pieceIndex = view.getUint32(1, false);
const pieceData = data instanceof ArrayBuffer
? new Uint8Array(data, 5)
: data.subarray(5);
this.handlePieceData(pieceIndex, pieceData, peerInfo);
} else if (msgType === MSG_PIECE_CHUNK) {
if (view.byteLength < 13) return;
const pieceIndex = view.getUint32(1, false);
const totalSize = view.getUint32(5, false);
const offset = view.getUint32(9, false);
const chunkData = data instanceof ArrayBuffer
? new Uint8Array(data, 13)
: data.subarray(13);
this._handlePieceChunk(pieceIndex, totalSize, offset, chunkData, peerInfo);
}
} catch (err) {
console.error('Error handling message:', err);
}
}
_handlePieceChunk(pieceIndex, totalSize, offset, chunkData, peerInfo) {
if (!this._pieceChunks.has(pieceIndex)) {
this._pieceChunks.set(pieceIndex, {
total: totalSize,
buf: new Uint8Array(totalSize),
received: 0
});
}
const entry = this._pieceChunks.get(pieceIndex);
entry.buf.set(chunkData, offset);
entry.received += chunkData.length;
if (entry.received >= entry.total) {
this._pieceChunks.delete(pieceIndex);
this.handlePieceData(pieceIndex, entry.buf, peerInfo);
}
}
handlePieceRequest(pieceIndex, channel) {
if (!channel._sendQueue) channel._sendQueue = Promise.resolve();
channel._sendQueue = channel._sendQueue.then(() => this._servePiece(pieceIndex, channel));
}
async _servePiece(pieceIndex, channel) {
if (!channel || channel.readyState !== 'open') return;
try {
const pieceData = await this._readPieceAsync(pieceIndex);
if (!pieceData || pieceData.length === 0) return;
const SCTP_MAX_PAYLOAD = 65531;
const CHUNK_SIZE = 16 * 1024;
if (pieceData.length <= SCTP_MAX_PAYLOAD) {
if (channel.bufferedAmount > 512 * 1024) {
await this._waitForSendBuffer(channel);
}
if (channel.readyState !== 'open') return;
const frame = new Uint8Array(5 + pieceData.length);
frame[0] = MSG_PIECE_DATA;
new DataView(frame.buffer).setUint32(1, pieceIndex, false);
frame.set(pieceData, 5);
channel.send(frame.buffer);
} else {
const totalSize = pieceData.length;
const dv = new DataView(new ArrayBuffer(13));
dv.setUint8(0, MSG_PIECE_CHUNK);
dv.setUint32(1, pieceIndex, false);
dv.setUint32(5, totalSize, false);
for (let offset = 0; offset < totalSize; offset += CHUNK_SIZE) {
if (channel.readyState !== 'open') return;
if (channel.bufferedAmount > 512 * 1024) {
await this._waitForSendBuffer(channel);
}
if (channel.readyState !== 'open') return;
const end = Math.min(offset + CHUNK_SIZE, totalSize);
const chunk = pieceData.subarray(offset, end);
const frame = new Uint8Array(13 + chunk.length);
dv.setUint32(9, offset, false);
frame.set(new Uint8Array(dv.buffer), 0);
frame.set(chunk, 13);
channel.send(frame.buffer);
}
}
console.log(`[Seeder] Sent piece ${pieceIndex} (${pieceData.length} bytes)`);
} catch (err) {
console.error('[Seeder] Error serving piece:', err);
}
}
_waitForSendBuffer(channel) {
return new Promise(resolve => {
if (channel.readyState !== 'open') { resolve(); return; }
channel.bufferedAmountLowThreshold = 64 * 1024;
channel.addEventListener('bufferedamountlow', resolve, { once: true });
const poll = () => {
if (channel.readyState !== 'open' || channel.bufferedAmount <= 64 * 1024) {
resolve();
} else {
setTimeout(poll, 10);
}
};
setTimeout(poll, 10);
});
}
_evaluatePeerSpeed(peerId, stats) {
if (stats.blacklisted) return;
const WINDOW = 5;
const threshold = this.client.options.slowPeerThresholdMs ?? 8000;
if (stats.responseTimes.length < WINDOW) return;
const recent = stats.responseTimes.slice(-WINDOW);
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
if (avg > threshold) {
this._blacklistPeer(peerId, `avg response ${avg.toFixed(0)} ms > ${threshold} ms`);
}
}
_blacklistPeer(peerId, reason) {
const stats = this._peerStats.get(peerId);
if (!stats || stats.blacklisted) return;
stats.blacklisted = true;
console.warn(`[Leecher] Peer ${String(peerId).slice(0, 16)}… blacklisted — ${reason}`);
for (const [pieceIndex] of stats.pendingRequests) {
this.requestedPieces.delete(pieceIndex);
}
stats.pendingRequests.clear();
this._requestMissingPieces();
}
_startPeerSpeedCheck() {
const timeoutMs = this.client.options.peerRequestTimeoutMs ?? 30000;
const maxTimeouts = this.client.options.maxPeerTimeouts ?? 3;
const check = () => {
if (!this.active) return;
const now = Date.now();
for (const [peerId, stats] of this._peerStats) {
if (stats.blacklisted) continue;
let timedOut = 0;
for (const [pieceIndex, sentAt] of stats.pendingRequests) {
if (now - sentAt > timeoutMs) {
stats.pendingRequests.delete(pieceIndex);
this.requestedPieces.delete(pieceIndex);
timedOut++;
}
}
if (timedOut > 0) this._requestMissingPieces();
if (timedOut >= maxTimeouts) {
this._blacklistPeer(peerId, `${timedOut} requests timed out (>${timeoutMs / 1000} s)`);
}
}
setTimeout(check, 10000);
};
setTimeout(check, 10000);
}
_readPiece(pieceIndex) {
const pl = this.data.torrent?._pieceLength || this.pieceLength;
const start = pieceIndex * pl;
const end = Math.min(start + pl, this.totalSize);
if (start >= this.totalSize) return null;
const filePaths = this.data.torrent?._filePaths;
if (filePaths && this.client.fs) {
return this._readRangeFromDisk(filePaths, start, end);
}
const buffers = this.data.torrent?._originalFiles;
if (buffers) {
const combined = Buffer.concat(buffers);
return combined.slice(start, Math.min(end, combined.length));
}
return null;
}
_readRangeFromDisk(filePaths, start, end) {
const fs = this.client.fs;
const result = Buffer.alloc(end - start);
let resultOffset = 0;
let fileStart = 0;
for (const fileMeta of filePaths) {
const fileEnd = fileStart + fileMeta.size;
if (fileEnd <= start) { fileStart = fileEnd; continue; }
if (fileStart >= end) break;
const readFrom = Math.max(start, fileStart) - fileStart;
const readTo = Math.min(end, fileEnd) - fileStart;
const len = readTo - readFrom;
let fd = this._fdCache.get(fileMeta.path);
if (fd === undefined) {
fd = fs.openSync(fileMeta.path, 'r');
this._fdCache.set(fileMeta.path, fd);
}
fs.readSync(fd, result, resultOffset, len, readFrom);
resultOffset += len;
fileStart = fileEnd;
}
return result;
}
async _readPieceAsync(pieceIndex) {
const pl = this.data.torrent?._pieceLength || this.pieceLength;
const start = pieceIndex * pl;
const end = Math.min(start + pl, this.totalSize);
if (start >= this.totalSize) return null;
const fileObjects = this.data.torrent?._fileObjects;
if (fileObjects) {
return await this._readRangeFromFileObjects(fileObjects, start, end);
}
return this._readPiece(pieceIndex);
}
async _readRangeFromFileObjects(fileObjects, start, end) {
const result = new Uint8Array(end - start);
let resultOffset = 0;
let fileStart = 0;
for (const fm of fileObjects) {
const fileEnd = fileStart + fm.size;
if (fileEnd <= start) { fileStart = fileEnd; continue; }
if (fileStart >= end) break;
const readFrom = Math.max(start, fileStart) - fileStart;
const readTo = Math.min(end, fileEnd) - fileStart;
const len = readTo - readFrom;
const ab = await fm.file.slice(readFrom, readTo).arrayBuffer();
result.set(new Uint8Array(ab), resultOffset);
resultOffset += len;
fileStart = fileEnd;
}
return result;
}
handlePieceData(pieceIndex, pieceData, peerInfo) {
if (this.pieces.has(pieceIndex)) return;
this._verifyPiece(pieceIndex, pieceData).then(ok => {
if (!ok) {
console.warn(`[Leecher] Piece ${pieceIndex} hash mismatch — discarding, will re-request`);
this.requestedPieces.delete(pieceIndex);
return;
}
const peerId = peerInfo?.peer_id;
if (peerId) {
const stats = this._peerStats.get(peerId);
if (stats) {
const sentAt = stats.pendingRequests.get(pieceIndex);
if (sentAt !== undefined) {
stats.responseTimes.push(Date.now() - sentAt);
if (stats.responseTimes.length > 10) stats.responseTimes.shift();
stats.pendingRequests.delete(pieceIndex);
this._evaluatePeerSpeed(peerId, stats);
}
}
}
this.pieces.set(pieceIndex, pieceData);
this.requestedPieces.delete(pieceIndex);
const added = Math.min(pieceData.length, this.totalSize - this.downloaded);
this.downloaded += added;
console.log(`[Leecher] Piece ${pieceIndex} OK (${pieceData.length} B) — ` +
`${this.pieces.size}/${this.pieceCount} pieces`);
if (typeof this.onPieceReceived === 'function') {
this.onPieceReceived(pieceIndex, pieceData);
}
this.updateProgress();
if (!this._downloadCompleted) this._requestMissingPieces();
});
}
async _verifyPiece(pieceIndex, pieceData) {
const piecesHash = this.data.info?.pieces;
if (!piecesHash || (Buffer.isBuffer(piecesHash) ? piecesHash.length : (piecesHash?.length || 0)) === 0) {
return true;
}
const expected = piecesHash.slice(pieceIndex * 20, pieceIndex * 20 + 20);
if (!expected || expected.length < 20) return true;
let actual;
try {
if (typeof window !== 'undefined' && window.crypto?.subtle) {
const buf = pieceData instanceof Uint8Array
? pieceData.buffer.slice(pieceData.byteOffset, pieceData.byteOffset + pieceData.byteLength)
: pieceData;
const hash = await window.crypto.subtle.digest('SHA-1', buf);
actual = new Uint8Array(hash);
} else {
const hash = require('crypto').createHash('sha1')
.update(pieceData instanceof Buffer ? pieceData : Buffer.from(pieceData))
.digest();
actual = new Uint8Array(hash);
}
} catch (e) {
console.warn('[RtcTorrent] SHA-1 verify failed:', e.message);
return true;
}
for (let i = 0; i < 20; i++) {
if (actual[i] !== (Buffer.isBuffer(expected) ? expected[i] : expected[i])) return false;
}
return true;
}
async handleOffer(sdpOffer) {
const RTCPeerConnection = this.client.getRTCPeerConnection();
if (!RTCPeerConnection) return;
const pc = new RTCPeerConnection({ iceServers: this.client.options.iceServers });
pc.ondatachannel = (e) => this.setupDataChannel(e.channel, null);
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.signalChannel.sendAnswer(answer.sdp);
this.peers.set(`incoming_${Date.now()}`, { connection: pc, channel: null, info: {}, connected: false });
}
async handleAnswer(sdpAnswer) {
for (const [, peer] of this.peers) {
if (!peer.connected && peer.connection?.signalingState === 'have-local-offer') {
try {
await peer.connection.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
peer.connected = true;
break;
} catch (err) {
console.error('Error setting remote answer:', err);
}
}
}
}
async requestPieceFromPeers(pieceIndex) {
const frame = new Uint8Array(5);
frame[0] = MSG_PIECE_REQUEST;
new DataView(frame.buffer).setUint32(1, pieceIndex, false);
const openPeers = [];
for (const [peerId, peer] of this.peers) {
if (peer.channel?.readyState === 'open' &&
!this._peerStats.get(peerId)?.blacklisted) {
openPeers.push({ peer, peerId });
}
}
let sent = false;
if (openPeers.length > 0) {
const { peer, peerId } = openPeers[pieceIndex % openPeers.length];
try {
peer.channel.send(frame.buffer);
sent = true;
if (!this._peerStats.has(peerId)) {
this._peerStats.set(peerId, { pendingRequests: new Map(), responseTimes: [], blacklisted: false });
}
this._peerStats.get(peerId).pendingRequests.set(pieceIndex, Date.now());
} catch (_) {}
}
if (!sent) {
if (this.webseeds.length) {
this._fetchPieceFromWebseed(pieceIndex);
} else {
console.log(`[Leecher] No open channel to request piece ${pieceIndex}`);
}
}
}
updateProgress() {
if (!this._downloadCompleted && this.pieceCount > 0 && this.pieces.size >= this.pieceCount) {
this._downloadCompleted = true;
this.onDownloadComplete();
}
}
onDownloadComplete() {
console.log('[Leecher] Download complete!');
}
async streamVideo(fileIndex = 0, videoElement, opts = {}) {
if (!videoElement) throw new Error('videoElement is required');
const file = this.files[fileIndex];
if (!file) throw new Error(`File at index ${fileIndex} not found`);
if (typeof window === 'undefined') throw new Error('streamVideo is browser-only');
const firstPiece = Math.floor(file.offset / this.pieceLength);
await this._waitForPiece(firstPiece);
if (navigator.serviceWorker?.controller) {
try {
console.log('[RtcTorrent] Service Worker active — seamless SW stream');
await this._streamWithSW(file, videoElement, opts);
return;
} catch (e) {
console.warn('[RtcTorrent] SW streaming failed, falling back:', e.message);
}
}
const moovEndByte = this._getFaststartMoovEnd(this.pieces.get(firstPiece));
if (moovEndByte > 0) {
console.log('[RtcTorrent] Faststart MP4 — blob rebuild streaming');
this._moovEndByte = moovEndByte;
await this._streamFaststart(file, videoElement, opts);
return;
}
const detectedMime = opts.mimeType || this._detectMimeType(this.pieces.get(firstPiece));
if (detectedMime && window.MediaSource && MediaSource.isTypeSupported(detectedMime)) {
try {
await this._streamWithMSE(file, videoElement, detectedMime);
return;
} catch (e) {
console.warn('[RtcTorrent] MSE streaming failed, falling back to Blob URL:', e.message);
if (videoElement.src?.startsWith('blob:')) URL.revokeObjectURL(videoElement.src);
}
}
await this._playAsBlob(file, videoElement, opts.onProgress);
}
async _streamWithSW(file, videoElement, opts = {}) {
const { onProgress } = opts;
const sw = navigator.serviceWorker.controller;
if (!sw) throw new Error('No active Service Worker controller');
const streamId = `${(this.data.infoHash || 'x').slice(0, 8)}-${Date.now()}`;
const mimeType = this._getMimeForFile(file) || 'video/mp4';
const streamUrl = `__rtc_stream__/${streamId}?mime=${encodeURIComponent(mimeType)}&size=${file.length}`;
const startPiece = Math.floor(file.offset / this.pieceLength);
const endPiece = Math.ceil((file.offset + file.length) / this.pieceLength);
const startOffset = file.offset % this.pieceLength;
const total = endPiece - startPiece;
videoElement.src = streamUrl;
videoElement.play().catch(e =>
console.warn('[RtcTorrent] SW autoplay blocked (click play):', e.message)
);
let bytesSent = 0;
for (let i = startPiece; i < endPiece; i++) {
await this._waitForPiece(i);
if (!videoElement.src.includes(streamId)) break;
const p = this.pieces.get(i);
const data = p instanceof Uint8Array ? p : new Uint8Array(p);
const from = (i === startPiece) ? startOffset : 0;
let to = data.length;
if (i === endPiece - 1) to = from + (file.length - bytesSent);
const slice = data.slice(from, to);
bytesSent += slice.byteLength;
sw.postMessage({ type: 'chunk', streamId, chunk: slice.buffer }, [slice.buffer]);
if (typeof onProgress === 'function') {
onProgress(Math.floor((i - startPiece + 1) / total * 100));
}
}
sw.postMessage({ type: 'end', streamId });
console.log(`[RtcTorrent] SW stream complete (${(bytesSent / 1024 / 1024).toFixed(1)} MB sent)`);
try {
const blob = await this.getBlob(file);
const blobUrl = URL.createObjectURL(blob);
if (videoElement.src.includes(streamId)) {
const savedTime = videoElement.currentTime;
const wasPaused = videoElement.paused;
videoElement.addEventListener('loadedmetadata', () => {
videoElement.currentTime = savedTime;
if (!wasPaused) videoElement.play().catch(() => {});
if (typeof this.onSeekReady === 'function') this.onSeekReady();
}, { once: true });
videoElement.src = blobUrl;
}
} catch (e) {
console.warn('[RtcTorrent] Blob switch failed:', e.message);
if (typeof this.onSeekReady === 'function') this.onSeekReady();
}
}
_detectMimeType(piece0) {
if (!piece0 || piece0.length < 12) return null;
const bytes = piece0 instanceof Uint8Array ? piece0 : new Uint8Array(piece0);
if (String.fromCharCode(bytes[4], bytes[5], bytes[6], bytes[7]) !== 'ftyp') return null;
const ftypSize = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
if (ftypSize < 16) return null;
const FRAG_BRANDS = new Set(['iso5', 'iso6', 'cmfc', 'cmff', 'dash']);
const major = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
if (FRAG_BRANDS.has(major)) return 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"';
const end = Math.min(ftypSize, bytes.length);
for (let off = 16; off + 4 <= end; off += 4) {
const brand = String.fromCharCode(bytes[off], bytes[off+1], bytes[off+2], bytes[off+3]);
if (FRAG_BRANDS.has(brand)) return 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"';
}
return null;
}
_getFaststartMoovEnd(piece0) {
if (!piece0 || piece0.length < 16) return 0;
const b = piece0 instanceof Uint8Array ? piece0 : new Uint8Array(piece0);
if (String.fromCharCode(b[4], b[5], b[6], b[7]) !== 'ftyp') return 0;
const ftypSize = (b[0] << 24 | b[1] << 16 | b[2] << 8 | b[3]) >>> 0;
if (ftypSize < 8 || ftypSize + 8 > b.length) return 0;
let off = ftypSize;
while (off + 8 <= b.length) {
const boxSize = (b[off] << 24 | b[off+1] << 16 | b[off+2] << 8 | b[off+3]) >>> 0;
const boxType = String.fromCharCode(b[off+4], b[off+5], b[off+6], b[off+7]);
if (boxSize < 8) return 0;
if (boxType === 'moov') {
return off + boxSize;
}
if (boxType === 'mdat') {
return 0;
}
off += boxSize;
}
return 0;
}
async _streamFaststart(file, videoElement, opts = {}) {
const { onProgress, initialExtra = 50 } = opts;
const INITIAL_EXTRA = initialExtra;
const REBUILD_MIN_GAIN = 200;
const REBUILD_COOLDOWN_MS = 5_000;
const CHECK_INTERVAL = 2000;
const startPiece = Math.floor(file.offset / this.pieceLength);
const endPiece = Math.ceil((file.offset + file.length) / this.pieceLength);
const totalPieces = endPiece - startPiece;
const moovEndByte = this._moovEndByte || 0;
const moovEndPiece = Math.max(
startPiece,
Math.floor((file.offset + moovEndByte - 1) / this.pieceLength)
);
const initialEndPiece = Math.min(moovEndPiece + INITIAL_EXTRA, endPiece - 1);
const initialPieces = initialEndPiece - startPiece + 1;
console.log(
`[RtcTorrent] faststart stream: moovEndByte=${moovEndByte}` +
` moovEndPiece=${moovEndPiece} initialEndPiece=${initialEndPiece}` +
` totalPieces=${totalPieces}`
);
for (let i = startPiece; i <= initialEndPiece; i++) {
await this._waitForPiece(i);
if (typeof onProgress === 'function') {
onProgress(Math.floor((i - startPiece + 1) / initialPieces * 100));
}
}
let blobUrl = null;
let lastBlobPiece = -1;
let buildInProgress = false;
let pendingBuildTo = -1;
let firstPlayDone = false;
let lastRebuildTime = 0;
const getContiguousEnd = () => {
let latest = (lastBlobPiece >= startPiece) ? lastBlobPiece : startPiece - 1;
while (latest + 1 < endPiece && this.pieces.has(latest + 1)) latest++;
return latest;
};
const forceRequestPiece = (index) => {
if (index >= endPiece || this.pieces.has(index)) return;
this.requestedPieces.delete(index);
this.requestPieceFromPeers(index);
this.requestedPieces.add(index);
};
const buildAndApplyBlob = async (upToPiece) => {
if (upToPiece <= lastBlobPiece) return;
if (buildInProgress) {
pendingBuildTo = Math.max(pendingBuildTo, upToPiece);
return;
}
buildInProgress = true;
try {
const chunks = [];
const startOffset = file.offset % this.pieceLength;
let bytesSoFar = 0;
for (let i = startPiece; i <= upToPiece; i++) {
const p = this.pieces.get(i);
if (!p) break;
const data = p instanceof Uint8Array ? p : new Uint8Array(p);
const from = (i === startPiece) ? startOffset : 0;
let to = data.length;
if (i === endPiece - 1) to = from + (file.length - bytesSoFar);
chunks.push(data.slice(from, to));
bytesSoFar += (to - from);
lastBlobPiece = i;
}
if (chunks.length === 0) return;
const prevTime = isNaN(videoElement.currentTime) ? 0 : videoElement.currentTime;
const shouldPlay = !videoElement.paused
|| videoElement.readyState < 3
|| videoElement.ended;
if (blobUrl) URL.revokeObjectURL(blobUrl);
const mimeType = this._getMimeForFile(file) || 'video/mp4';
const blob = new Blob(chunks, { type: mimeType });
blobUrl = URL.createObjectURL(blob);
videoElement.src = blobUrl;
videoElement.load();
await new Promise(resolve => {
const onMeta = () => {
videoElement.removeEventListener('loadedmetadata', onMeta);
const target = Math.min(prevTime, videoElement.duration || 0);
if (target > 0.5) videoElement.currentTime = target;
resolve();
};
videoElement.addEventListener('loadedmetadata', onMeta, { once: true });
setTimeout(resolve, 2000);
});
if (!firstPlayDone || shouldPlay) {
firstPlayDone = true;
videoElement.play().catch(e => {
console.warn('[RtcTorrent] Faststart autoplay blocked (click play):', e.message);
});
}
lastRebuildTime = Date.now();
console.log(`[RtcTorrent] blob extended to piece ${lastBlobPiece}/${endPiece - 1}` +
` (${(blob.size / 1024 / 1024).toFixed(1)} MB)`);
} finally {
buildInProgress = false;
if (pendingBuildTo > lastBlobPiece) {
const next = pendingBuildTo;
pendingBuildTo = -1;
const delay = Math.max(0, REBUILD_COOLDOWN_MS - (Date.now() - lastRebuildTime));
setTimeout(() => buildAndApplyBlob(next), delay);
}
}
};
const origOnPieceReceived = this.onPieceReceived;
const cleanup = (checkIntervalRef) => {
clearInterval(checkIntervalRef);
videoElement.removeEventListener('waiting', onVideoStall);
videoElement.removeEventListener('ended', onVideoStall);
this.onPieceReceived = origOnPieceReceived;
};
await buildAndApplyBlob(initialEndPiece);
const onVideoStall = () => {
const latest = getContiguousEnd();
if (latest > lastBlobPiece) {
if (Date.now() - lastRebuildTime >= REBUILD_COOLDOWN_MS) {
buildAndApplyBlob(latest).catch(() => {});
}
} else {
forceRequestPiece(lastBlobPiece + 1);
}
};
videoElement.addEventListener('waiting', onVideoStall);
videoElement.addEventListener('ended', onVideoStall);
this.onPieceReceived = (pieceIndex, pieceData) => {
if (origOnPieceReceived) origOnPieceReceived.call(this, pieceIndex, pieceData);
};
const checkIntervalId = setInterval(async () => {
const latest = getContiguousEnd();
if (typeof onProgress === 'function') {
const got = [...this.pieces.keys()].filter(k => k >= startPiece && k < endPiece).length;
onProgress(Math.floor(got / totalPieces * 100));
}
if (this._downloadCompleted || latest >= endPiece - 1) {
cleanup(checkIntervalId);
await buildAndApplyBlob(endPiece - 1);
console.log('[RtcTorrent] faststart stream complete — full blob playing');
return;
}
if (latest <= lastBlobPiece) {
const gapPiece = lastBlobPiece + 1;
if (gapPiece < endPiece && !this.pieces.has(gapPiece)) {
forceRequestPiece(gapPiece);
console.log(`[RtcTorrent] gap recovery: re-requesting piece ${gapPiece}`);
}
}
if (latest > lastBlobPiece + REBUILD_MIN_GAIN
&& Date.now() - lastRebuildTime >= REBUILD_COOLDOWN_MS) {
await buildAndApplyBlob(latest);
}
}, CHECK_INTERVAL);
}
async _streamWithMSEProgressive(file, videoElement, opts = {}) {
const { onProgress } = opts;
const mimeType = [
'video/mp4; codecs="avc1.64001E,mp4a.40.2"',
'video/mp4; codecs="avc1.42E01E,mp4a.40.2"',
'video/mp4; codecs="avc1.64001E"',
'video/mp4',
].find(m => MediaSource.isTypeSupported(m));
if (!mimeType) throw new Error('MSE: no supported MP4 MIME type found');
const startPiece = Math.floor(file.offset / this.pieceLength);
const endPiece = Math.ceil((file.offset + file.length) / this.pieceLength);
const startOffset = file.offset % this.pieceLength;
const totalPieces = endPiece - startPiece;
const BATCH_BYTES = 512 * 1024;
const ms = new MediaSource();
const url = URL.createObjectURL(ms);
videoElement.src = url;
await new Promise((resolve, reject) => {
ms.addEventListener('sourceopen', resolve, { once: true });
setTimeout(() => reject(new Error('sourceopen timeout')), 10_000);
});
URL.revokeObjectURL(url);
const sb = ms.addSourceBuffer(mimeType);
const waitUpdate = () => new Promise((resolve, reject) => {
sb.addEventListener('updateend', resolve, { once: true });
sb.addEventListener('error', reject, { once: true });
});
const appendChunk = async (chunk) => {
try {
sb.appendBuffer(chunk);
await waitUpdate();
} catch (e) {
if (e.name !== 'QuotaExceededError') throw e;
const evictTo = Math.max(0, videoElement.currentTime - 5);
sb.remove(0, evictTo);
await waitUpdate();
sb.appendBuffer(chunk);
await waitUpdate();
}
};
let metadataReady = false;
videoElement.addEventListener('loadedmetadata', () => { metadataReady = true; }, { once: true });
let pendingChunks = [];
let pendingBytes = 0;
let bytesSoFar = 0;
const flushPending = async () => {
if (!pendingChunks.length) return;
let combined;
if (pendingChunks.length === 1) {
combined = pendingChunks[0];
} else {
combined = new Uint8Array(pendingBytes);
let off = 0;
for (const c of pendingChunks) { combined.set(c, off); off += c.length; }
}
pendingChunks = [];
pendingBytes = 0;
await appendChunk(combined);
};
for (let i = startPiece; i < endPiece; i++) {
if (ms.readyState !== 'open') break;
await this._waitForPiece(i);
if (ms.readyState !== 'open') break;
const raw = this.pieces.get(i);
const data = raw instanceof Uint8Array ? raw : new Uint8Array(raw.buffer || raw);
const from = (i === startPiece) ? startOffset : 0;
let to = data.length;
if (i === endPiece - 1) to = from + (file.length - bytesSoFar);
const chunk = (from === 0 && to === data.length) ? data : data.slice(from, to);
bytesSoFar += chunk.length;
pendingChunks.push(chunk);
pendingBytes += chunk.length;
const isLast = (i === endPiece - 1);
if (!metadataReady || pendingBytes >= BATCH_BYTES || isLast) {
await flushPending();
}
if (typeof onProgress === 'function') {
onProgress(Math.floor((i - startPiece + 1) / totalPieces * 100));
}
}
if (ms.readyState === 'open') ms.endOfStream();
}
async _waitForPiece(index) {
const RETRY_MS = 3000;
let sinceLastRequest = 0;
while (!this.pieces.has(index)) {
if (!this.requestedPieces.has(index) || sinceLastRequest >= RETRY_MS) {
this.requestedPieces.delete(index);
this.requestPieceFromPeers(index);
this.requestedPieces.add(index);
sinceLastRequest = 0;
}
await new Promise(r => setTimeout(r, 100));
sinceLastRequest += 100;
}
}
async _streamWithMSE(file, videoElement, mimeType) {
const startPiece = Math.floor(file.offset / this.pieceLength);
const endPiece = Math.ceil((file.offset + file.length) / this.pieceLength);
const ms = new MediaSource();
this.mediaSource = ms;
videoElement.src = URL.createObjectURL(ms);
await new Promise((resolve, reject) => {
ms.addEventListener('sourceopen', async () => {
let sb;
try {
sb = ms.addSourceBuffer(mimeType);
this.sourceBuffer = sb;
const raw = this.pieces.get(startPiece);
await this._appendToSourceBuffer(sb, raw);
resolve();
} catch (e) {
reject(e);
return;
}
await (async () => {
try {
for (let i = startPiece + 1; i < endPiece; i++) {
if (ms.readyState !== 'open') break;
await this._waitForPiece(i);
if (ms.readyState !== 'open') break;
await this._appendToSourceBuffer(sb, this.pieces.get(i));
}
if (ms.readyState === 'open') ms.endOfStream();
} catch (e) {
console.warn('[RtcTorrent] MSE feed error:', e.message);
try {
if (ms.readyState === 'open') ms.endOfStream('decode');
} catch (_) {
}
}
})();
}, { once: true });
});
}
_appendToSourceBuffer(sb, data) {
return new Promise((resolve, reject) => {
const done = () => { sb.removeEventListener('updateend', done); sb.removeEventListener('error', fail); resolve(); };
const fail = (e) => { sb.removeEventListener('updateend', done); sb.removeEventListener('error', fail); reject(e); };
sb.addEventListener('updateend', done);
sb.addEventListener('error', fail);
let buf;
if (data instanceof ArrayBuffer) {
buf = data;
} else if (ArrayBuffer.isView(data)) {
buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
} else {
buf = data.buffer || data;
}
sb.appendBuffer(buf);
});
}
async _playAsBlob(file, videoElement, onProgress) {
const startPiece = Math.floor(file.offset / this.pieceLength);
const endPiece = Math.ceil((file.offset + file.length) / this.pieceLength);
const total = endPiece - startPiece;
for (let i = startPiece; i < endPiece; i++) {
await this._waitForPiece(i);
if (typeof onProgress === 'function' && !this._downloadCompleted) {
const pct = Math.floor((i - startPiece + 1) / total * 100);
onProgress(pct);
}
}
console.log('[RtcTorrent] Assembling Blob…');
const blob = await this.getBlob(file);
console.log(`[RtcTorrent] Blob ready (${(blob.size / 1024 / 1024).toFixed(1)} MB), setting video src`);
const url = URL.createObjectURL(blob);
videoElement.src = url;
videoElement.load();
const playPromise = videoElement.play();
if (playPromise !== undefined) {
playPromise.catch(e => {
console.warn('[RtcTorrent] Autoplay blocked (click play to start):', e.message);
});
}
}
async getBlob(fileOrIndex = 0) {
const file = (typeof fileOrIndex === 'number') ? this.files[fileOrIndex] : fileOrIndex;
if (!file) throw new Error('File not found');
const startPiece = Math.floor(file.offset / this.pieceLength);
const endPiece = Math.ceil((file.offset + file.length) / this.pieceLength);
const startOff = file.offset % this.pieceLength;
const chunks = [];
for (let i = startPiece; i < endPiece; i++) {
const raw = this.pieces.get(i);
if (!raw) throw new Error(`Piece ${i} not available`);
const from = (i === startPiece) ? startOff : 0;
const to = (i === endPiece - 1)
? from + (file.length - chunks.reduce((s, c) => s + c.byteLength, 0))
: raw.length;
chunks.push(raw instanceof Uint8Array
? raw.slice(from, to)
: new Uint8Array(raw.buffer || raw, raw.byteOffset || 0, raw.byteLength || raw.length).slice(from, to));
}
return new Blob(chunks, { type: this._getMimeForFile(file) });
}
_getMimeForFile(file) {
const ext = ((file.name || '').split('/').pop()?.split('.').pop() || '').toLowerCase();
const map = {
mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime',
webm: 'video/webm', ogv: 'video/ogg',
mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
wav: 'audio/wav', flac: 'audio/flac', aac: 'audio/aac',
};
return map[ext] || 'application/octet-stream';
}
async saveFile(fileIndex = 0) {
const file = this.files[fileIndex];
if (!file) throw new Error(`File at index ${fileIndex} not found`);
const startPiece = Math.floor(file.offset / this.pieceLength);
const endPiece = Math.ceil((file.offset + file.length) / this.pieceLength);
for (let i = startPiece; i < endPiece; i++) {
await this._waitForPiece(i);
}
const blob = await this.getBlob(file);
const filename = (file.name || 'download').split('/').pop();
await this._saveBlob(blob, filename);
}
async _saveBlob(blob, filename) {
if (typeof window === 'undefined') return;
if (window.showSaveFilePicker) {
try {
const fh = await window.showSaveFilePicker({ suggestedName: filename });
const writable = await fh.createWritable();
await writable.write(blob);
await writable.close();
return;
} catch (e) {
if (e.name === 'AbortError') return;
console.warn('[RtcTorrent] showSaveFilePicker failed, using <a> fallback:', e.message);
}
}
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
isPlayable(filename) {
if (typeof window === 'undefined') return false;
const ext = ((filename || '').split('/').pop()?.split('.').pop() || '').toLowerCase();
const map = {
mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime',
webm: 'video/webm', ogv: 'video/ogg',
mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
wav: 'audio/wav', flac: 'audio/flac', aac: 'audio/aac',
opus: 'audio/ogg; codecs=opus',
};
const mime = map[ext];
if (!mime) return false;
const tag = mime.startsWith('audio/') ? 'audio' : 'video';
return document.createElement(tag).canPlayType(mime) !== '';
}
}
class SignalChannel extends EventTarget {
constructor(trackerUrl, infoHash) {
super();
this.trackerUrl = trackerUrl;
this.infoHash = infoHash;
}
_urlEncodeBytes(bytes) {
let out = '';
for (let i = 0; i < bytes.length; i++) out += '%' + bytes[i].toString(16).padStart(2, '0');
return out;
}
_hexToBytes(hex) {
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16);
return b;
}
_peerId() {
let id = '-RT1000-';
for (let i = 0; i < 12; i++) id += Math.floor(Math.random() * 10);
return id;
}
async sendOffer(sdpOffer, _peerId) {
const infoHashBytes = this._hexToBytes(this.infoHash);
const parts = [
'info_hash=' + this._urlEncodeBytes(infoHashBytes),
'peer_id=' + encodeURIComponent(this._peerId()),
'rtcoffer=' + encodeURIComponent(sdpOffer),
'rtctorrent=1',
'event=started',
'port=6881', 'uploaded=0', 'downloaded=0', 'left=0', 'compact=1'
];
await _fetch(`${this.trackerUrl}?${parts.join('&')}`).catch(() => {});
}
async sendAnswer(sdpAnswer) {
const infoHashBytes = this._hexToBytes(this.infoHash);
const parts = [
'info_hash=' + this._urlEncodeBytes(infoHashBytes),
'peer_id=' + encodeURIComponent(this._peerId()),
'rtcanswer=' + encodeURIComponent(sdpAnswer),
'rtctorrent=1',
'event=started',
'port=6881', 'uploaded=0', 'downloaded=0', 'left=0', 'compact=1'
];
await _fetch(`${this.trackerUrl}?${parts.join('&')}`).catch(() => {});
}
async sendICECandidate(_candidate, _peerId) {}
close() {}
emit(name, data) {
this.dispatchEvent(new CustomEvent(name, { detail: data }));
}
on(name, cb) {
this.addEventListener(name, e => cb(e.detail));
}
}
class WebRTCManager {
constructor(options) {
this.options = options;
this.connections = new Map();
this.nextId = 0;
}
createConnection(config = {}) {
const RTCPeerConnection = (() => {
if (typeof globalThis.RTCPeerConnection !== 'undefined') return globalThis.RTCPeerConnection;
const nativeRequire = (typeof __non_webpack_require__ !== 'undefined')
? __non_webpack_require__
: require;
for (const pkg of ['wrtc', '@roamhq/wrtc', 'node-webrtc']) {
try { const m = nativeRequire(pkg); if (m.RTCPeerConnection) return m.RTCPeerConnection; } catch (_) {}
}
throw new Error('RTCPeerConnection not available. Install "wrtc" for Node.js support.');
})();
const pc = new RTCPeerConnection({
iceServers: this.options.iceServers || [{ urls: 'stun:stun.l.google.com:19302' }],
...config
});
const id = `conn_${this.nextId++}`;
this.connections.set(id, pc);
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'closed' || pc.connectionState === 'failed') {
this.connections.delete(id);
}
};
return pc;
}
async close() {
for (const [, pc] of this.connections) { try { pc.close(); } catch (_) {} }
this.connections.clear();
}
}
class Announcer {
constructor(options) { this.options = options; }
async announce(_torrent, _params) {}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = RtcTorrent;
} else if (typeof window !== 'undefined') {
window.RtcTorrent = RtcTorrent;
}
export default RtcTorrent;