UNPKG

parse-torrent

Version:

Parse a torrent identifier (magnet uri, .torrent file, info hash)

263 lines (222 loc) 8.32 kB
/*! parse-torrent. MIT License. WebTorrent LLC <https://webtorrent.io/opensource> */ import bencode from 'bencode' import fs from 'fs' // browser exclude import fetch from 'cross-fetch-ponyfill' import magnet, { encode } from 'magnet-uri' import path from 'path' import { hash, arr2hex, text2arr, arr2text } from 'uint8-util' import queueMicrotask from 'queue-microtask' /** * Parse a torrent identifier (magnet uri, .torrent file, info hash) * @param {string|ArrayBufferView|Object} torrentId * @return {Object} */ async function parseTorrent (torrentId) { if (typeof torrentId === 'string' && /^(stream-)?magnet:/.test(torrentId)) { // if magnet uri (string) const torrentObj = magnet(torrentId) // infoHash won't be defined if a non-bittorrent magnet is passed if (!torrentObj.infoHash) { throw new Error('Invalid torrent identifier') } return torrentObj } else if (typeof torrentId === 'string' && (/^[a-f0-9]{40}$/i.test(torrentId) || /^[a-z2-7]{32}$/i.test(torrentId))) { // if info hash (hex/base-32 string) return magnet(`magnet:?xt=urn:btih:${torrentId}`) } else if (ArrayBuffer.isView(torrentId) && torrentId.length === 20) { // if info hash (buffer) return magnet(`magnet:?xt=urn:btih:${arr2hex(torrentId)}`) } else if (ArrayBuffer.isView(torrentId)) { // if .torrent file (buffer) return await decodeTorrentFile(torrentId) // might throw } else if (torrentId && torrentId.infoHash) { // if parsed torrent (from `parse-torrent` or `magnet-uri`) torrentId.infoHash = torrentId.infoHash.toLowerCase() if (!torrentId.announce) torrentId.announce = [] if (typeof torrentId.announce === 'string') { torrentId.announce = [torrentId.announce] } if (!torrentId.urlList) torrentId.urlList = [] return torrentId } else { throw new Error('Invalid torrent identifier') } } async function parseTorrentRemote (torrentId, opts, cb) { if (typeof opts === 'function') return parseTorrentRemote(torrentId, {}, opts) if (typeof cb !== 'function') throw new Error('second argument must be a Function') let parsedTorrent try { parsedTorrent = await parseTorrent(torrentId) } catch (err) { // If torrent fails to parse, it could be a Blob, http/https URL or // filesystem path, so don't consider it an error yet. } if (parsedTorrent && parsedTorrent.infoHash) { queueMicrotask(() => { cb(null, parsedTorrent) }) } else if (isBlob(torrentId)) { try { const torrentBuf = new Uint8Array(await torrentId.arrayBuffer()) parseOrThrow(torrentBuf) } catch (err) { return cb(new Error(`Error converting Blob: ${err.message}`)) } } else if (/^https?:/.test(torrentId)) { try { const res = await fetch(torrentId, { headers: { 'user-agent': 'WebTorrent (https://webtorrent.io)' }, signal: AbortSignal.timeout(30 * 1000), ...opts }) const torrentBuf = new Uint8Array(await res.arrayBuffer()) parseOrThrow(torrentBuf) } catch (err) { return cb(new Error(`Error downloading torrent: ${err.message}`)) } } else if (typeof fs.readFile === 'function' && typeof torrentId === 'string') { // assume it's a filesystem path fs.readFile(torrentId, (err, torrentBuf) => { if (err) return cb(new Error('Invalid torrent identifier')) parseOrThrow(torrentBuf) }) } else { queueMicrotask(() => { cb(new Error('Invalid torrent identifier')) }) } async function parseOrThrow (torrentBuf) { try { parsedTorrent = await parseTorrent(torrentBuf) } catch (err) { return cb(err) } if (parsedTorrent && parsedTorrent.infoHash) cb(null, parsedTorrent) else cb(new Error('Invalid torrent identifier')) } } /** * Parse a torrent. Throws an exception if the torrent is missing required fields. * @param {ArrayBufferView|Object} torrent * @return {Object} parsed torrent */ async function decodeTorrentFile (torrent) { if (ArrayBuffer.isView(torrent)) { torrent = bencode.decode(torrent) } // sanity check ensure(torrent.info, 'info') ensure(torrent.info['name.utf-8'] || torrent.info.name, 'info.name') ensure(torrent.info['piece length'], 'info[\'piece length\']') ensure(torrent.info.pieces, 'info.pieces') if (torrent.info.files) { torrent.info.files.forEach(file => { ensure(typeof file.length === 'number', 'info.files[0].length') ensure(file['path.utf-8'] || file.path, 'info.files[0].path') }) } else { ensure(typeof torrent.info.length === 'number', 'info.length') } const result = { info: torrent.info, infoBuffer: bencode.encode(torrent.info), name: arr2text(torrent.info['name.utf-8'] || torrent.info.name), announce: [] } result.infoHashBuffer = await hash(result.infoBuffer) result.infoHash = arr2hex(result.infoHashBuffer) if (torrent.info.private !== undefined) result.private = !!torrent.info.private if (torrent['creation date']) result.created = new Date(torrent['creation date'] * 1000) if (torrent['created by']) result.createdBy = arr2text(torrent['created by']) if (ArrayBuffer.isView(torrent.comment)) result.comment = arr2text(torrent.comment) // announce and announce-list will be missing if metadata fetched via ut_metadata if (Array.isArray(torrent['announce-list']) && torrent['announce-list'].length > 0) { torrent['announce-list'].forEach(urls => { urls.forEach(url => { result.announce.push(arr2text(url)) }) }) } else if (torrent.announce) { result.announce.push(arr2text(torrent.announce)) } // handle url-list (BEP19 / web seeding) if (ArrayBuffer.isView(torrent['url-list'])) { // some clients set url-list to empty string torrent['url-list'] = torrent['url-list'].length > 0 ? [torrent['url-list']] : [] } result.urlList = (torrent['url-list'] || []).map(url => arr2text(url)) // remove duplicates by converting to Set and back result.announce = Array.from(new Set(result.announce)) result.urlList = Array.from(new Set(result.urlList)) let sum = 0 const files = torrent.info.files || [torrent.info] result.files = files.map((file, i) => { const parts = [].concat(result.name, file['path.utf-8'] || file.path || []).map(p => ArrayBuffer.isView(p) ? arr2text(p) : p) sum += file.length return { path: path.join.apply(null, [path.sep].concat(parts)).slice(1), name: parts[parts.length - 1], length: file.length, offset: sum - file.length } }) result.length = sum const lastFile = result.files[result.files.length - 1] result.pieceLength = torrent.info['piece length'] result.lastPieceLength = ((lastFile.offset + lastFile.length) % result.pieceLength) || result.pieceLength result.pieces = splitPieces(torrent.info.pieces) return result } /** * Convert a parsed torrent object back into a .torrent file buffer. * @param {Object} parsed parsed torrent * @return {Uint8Array} */ function encodeTorrentFile (parsed) { const torrent = { info: parsed.info } torrent['announce-list'] = (parsed.announce || []).map(url => { if (!torrent.announce) torrent.announce = url url = text2arr(url) return [url] }) torrent['url-list'] = parsed.urlList || [] if (parsed.private !== undefined) { torrent.private = Number(parsed.private) } if (parsed.created) { torrent['creation date'] = (parsed.created.getTime() / 1000) | 0 } if (parsed.createdBy) { torrent['created by'] = parsed.createdBy } if (parsed.comment) { torrent.comment = parsed.comment } return bencode.encode(torrent) } /** * Check if `obj` is a W3C `Blob` or `File` object * @param {*} obj * @return {boolean} */ function isBlob (obj) { return typeof Blob !== 'undefined' && obj instanceof Blob } function splitPieces (buf) { const pieces = [] for (let i = 0; i < buf.length; i += 20) { pieces.push(arr2hex(buf.slice(i, i + 20))) } return pieces } function ensure (bool, fieldName) { if (!bool) throw new Error(`Torrent is missing required field: ${fieldName}`) } export default parseTorrent const toMagnetURI = encode export { parseTorrentRemote as remote, encodeTorrentFile as toTorrentFile, toMagnetURI }