Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 107 additions & 33 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,43 @@ import queueMicrotask from 'queue-microtask'
* @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')
if (typeof torrentId === 'string') {
if (/^(stream-)?magnet:/.test(torrentId)) {
// if magnet uri (string)
const torrentObj = magnet(torrentId)

// infoHash (v1) or infoHashV2 (v2) won't be defined if a non-bittorrent magnet is passed
if (!torrentObj.infoHash && !torrentObj.infoHashV2) {
throw new Error('Invalid torrent identifier')
}

return torrentObj
} else if (/^[a-f0-9]{40}$/i.test(torrentId) || /^[a-z2-7]{32}$/i.test(torrentId)) {
// if info hash v1 (hex/base-32 string)
return magnet(`magnet:?xt=urn:btih:${torrentId}`)
} else if (/^[a-f0-9]{64}$/i.test(torrentId)) {
// if info hash v2 (hex string)
return magnet(`magnet:?xt=urn:btmh:1220${torrentId}`)
}

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 (torrentId.length === 20) {
// if info hash v1 (buffer)
return magnet(`magnet:?xt=urn:btih:${arr2hex(torrentId)}`)
} else if (torrentId.length === 32) {
// if info hash v2 (buffer)
return magnet(`magnet:?xt=urn:btmh:1220${arr2hex(torrentId)}`)
} else {
// if .torrent file (buffer)
return await decodeTorrentFile(torrentId) // might throw
}
} else if (torrentId && (torrentId.infoHash || torrentId.infoHashV2)) {
// if parsed torrent (from `parse-torrent` or `magnet-uri`)
torrentId.infoHash = torrentId.infoHash.toLowerCase()
if (torrentId.infoHash) {
torrentId.infoHash = torrentId.infoHash.toLowerCase()
}
if (torrentId.infoHashV2) {
torrentId.infoHashV2 = torrentId.infoHashV2.toLowerCase()
}

if (!torrentId.announce) torrentId.announce = []

Expand Down Expand Up @@ -63,7 +78,7 @@ async function parseTorrentRemote (torrentId, opts, cb) {
// filesystem path, so don't consider it an error yet.
}

if (parsedTorrent && parsedTorrent.infoHash) {
if (parsedTorrent && (parsedTorrent.infoHash || parsedTorrent.infoHashV2)) {
queueMicrotask(() => {
cb(null, parsedTorrent)
})
Expand Down Expand Up @@ -104,7 +119,7 @@ async function parseTorrentRemote (torrentId, opts, cb) {
} catch (err) {
return cb(err)
}
if (parsedTorrent && parsedTorrent.infoHash) cb(null, parsedTorrent)
if (parsedTorrent && (parsedTorrent.infoHash || parsedTorrent.infoHashV2)) cb(null, parsedTorrent)
else cb(new Error('Invalid torrent identifier'))
}
}
Expand All @@ -123,15 +138,28 @@ async function decodeTorrentFile (torrent) {
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 hasV1Structure = !!(torrent.info.pieces || torrent.info.files || typeof torrent.info.length === 'number')
const hasV2Structure = !!torrent.info['file tree']

// BitTorrent v2 validation (when v2 structures present)
if (hasV2Structure) {
ensure(torrent.info['file tree'], 'info[\'file tree\']')
Comment thread
leoherzog marked this conversation as resolved.
ensure(torrent['piece layers'], 'piece layers')
}

// BitTorrent v1 validation (when v1 structures present)
if (hasV1Structure) {
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 = {
Expand All @@ -141,8 +169,25 @@ async function decodeTorrentFile (torrent) {
announce: []
}

result.infoHashBuffer = await hash(result.infoBuffer)
result.infoHash = arr2hex(result.infoHashBuffer)
// Generate hashes based on torrent structure
if (hasV1Structure) {
result.infoHashBuffer = await hash(result.infoBuffer)
result.infoHash = arr2hex(result.infoHashBuffer)
}

if (hasV2Structure) {
result.infoHashV2Buffer = await hash(result.infoBuffer, undefined, 'sha-256')
result.infoHashV2 = arr2hex(result.infoHashV2Buffer)
}

// Set version for easy downstream detection
if (hasV1Structure && hasV2Structure) {
result.version = 'hybrid'
} else if (hasV2Structure) {
result.version = 'v2'
} else {
result.version = 'v1'
}

if (torrent.info.private !== undefined) result.private = !!torrent.info.private

Expand Down Expand Up @@ -176,7 +221,15 @@ async function decodeTorrentFile (torrent) {
result.urlList = Array.from(new Set(result.urlList))

let sum = 0
const files = torrent.info.files || [torrent.info]
// Create normalized files array for result without modifying original torrent
let files
if (hasV2Structure && !hasV1Structure) {
// Pure v2: flatten file tree for result.files (don't modify torrent.info.files)
files = flattenFileTree(torrent.info['file tree'])
} else {
// v1 or hybrid: use existing files structure
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
Expand All @@ -194,7 +247,11 @@ async function decodeTorrentFile (torrent) {

result.pieceLength = torrent.info['piece length']
result.lastPieceLength = ((lastFile.offset + lastFile.length) % result.pieceLength) || result.pieceLength
result.pieces = splitPieces(torrent.info.pieces)

// Only set pieces for v1 torrents; leave undefined for v2-only
if (torrent.info.pieces) {
result.pieces = splitPieces(torrent.info.pieces)
}

return result
}
Expand Down Expand Up @@ -245,6 +302,23 @@ function isBlob (obj) {
return typeof Blob !== 'undefined' && obj instanceof Blob
}

function flattenFileTree (tree, currentPath = []) {
const files = []
for (const [name, entry] of Object.entries(tree)) {
const fullPath = [...currentPath, name]
if (entry.length !== undefined) {
// Spread entry to preserve BEP-47 attributes (e.g., attr for padfiles)
files.push({
...entry,
'path.utf-8': fullPath
})
} else {
files.push(...flattenFileTree(entry, fullPath))
}
}
return files
}

function splitPieces (buf) {
const pieces = []
for (let i = 0; i < buf.length; i += 20) {
Expand Down
101 changes: 101 additions & 0 deletions test/bittorrent-v2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import fs from 'fs'
import parseTorrent from '../index.js'
import test from 'tape'

test('Test BitTorrent v2 hash support', async t => {
let parsed

// v2 info hash (as a hex string - 64 characters)
const v2Hash = 'a'.repeat(64)
parsed = await parseTorrent(v2Hash)
t.equal(parsed.infoHashV2, v2Hash.toLowerCase())
Comment thread
leoherzog marked this conversation as resolved.
t.equal(parsed.infoHash, undefined, 'v2-only should not have v1 infoHash')
t.equal(parsed.name, undefined)
t.deepEqual(parsed.announce, [])

// v2 info hash (as a Buffer - 32 bytes)
const v2HashBuffer = Buffer.from(v2Hash, 'hex')
parsed = await parseTorrent(v2HashBuffer)
t.equal(parsed.infoHashV2, v2Hash.toLowerCase())

// magnet uri with v2 hash (btmh)
const magnetV2 = `magnet:?xt=urn:btmh:1220${v2Hash}`
parsed = await parseTorrent(magnetV2)
t.equal(parsed.infoHashV2, v2Hash.toLowerCase(), 'magnet v2 hash should match')

// hybrid magnet uri (both btih and btmh)
const hybridMagnet = 'magnet:?xt=urn:btih:631a31dd0a46257d5078c0dee4e66e26f73e42ac&xt=urn:btmh:1220d8dd32ac93357c368556af3ac1d95c9d76bd0dff6fa9833ecdac3d53134efabb'
parsed = await parseTorrent(hybridMagnet)
t.equal(parsed.infoHash, '631a31dd0a46257d5078c0dee4e66e26f73e42ac', 'hybrid magnet should have v1 infoHash')
t.equal(parsed.infoHashV2, 'd8dd32ac93357c368556af3ac1d95c9d76bd0dff6fa9833ecdac3d53134efabb', 'hybrid magnet should have v2 infoHash')

// parsed torrent with both v1 and v2 hashes (hybrid)
const torrentObjHybrid = {
infoHash: 'd2474e86c95b19b8bcfdb92bc12c9d44667cfa36',
infoHashV2: v2Hash
}
parsed = await parseTorrent(torrentObjHybrid)
t.equal(parsed.infoHash, 'd2474e86c95b19b8bcfdb92bc12c9d44667cfa36')
t.equal(parsed.infoHashV2, v2Hash.toLowerCase())

t.end()
})
Comment thread
leoherzog marked this conversation as resolved.

test('Parse BitTorrent v2 torrent files', async t => {
const v2Buf = fs.readFileSync('./test/torrents/bittorrent-v2-test.torrent')
const hybridBuf = fs.readFileSync('./test/torrents/bittorrent-v2-hybrid-test.torrent')

// Test v2 torrent (should auto-detect and generate v2 hash)
const v2Parsed = await parseTorrent(v2Buf)
t.equal(v2Parsed.infoHashV2, 'caf1e1c30e81cb361b9ee167c4aa64228a7fa4fa9f6105232b28ad099f3a302e', 'v2 torrent should have correct v2 hash')
t.equal(v2Parsed.infoHash, undefined, 'v2-only torrent should not have v1 infoHash')
t.equal(v2Parsed.pieces, undefined, 'v2-only torrent should not have pieces')

// Test hybrid torrent (should auto-detect and generate both hashes)
const hybrid = await parseTorrent(hybridBuf)
t.equal(hybrid.infoHash, '631a31dd0a46257d5078c0dee4e66e26f73e42ac', 'Hybrid should have correct v1 hash')
t.equal(hybrid.infoHashV2, 'd8dd32ac93357c368556af3ac1d95c9d76bd0dff6fa9833ecdac3d53134efabb', 'Hybrid should have correct v2 hash')

// All should have standard properties
;[v2Parsed, hybrid].forEach(parsed => {
t.ok(parsed.name, 'Should have name')
t.ok(Array.isArray(parsed.files), 'Should have files array')
t.ok(typeof parsed.length === 'number', 'Should have length')
})

t.end()
})

test('Test auto-detection behavior', async t => {
const torrentBuf = fs.readFileSync('./test/torrents/bittorrent-v2-test.torrent')

// Test that v2 torrent auto-detects and generates appropriate hashes
const parsed = await parseTorrent(torrentBuf)
t.equal(parsed.infoHashV2, 'caf1e1c30e81cb361b9ee167c4aa64228a7fa4fa9f6105232b28ad099f3a302e', 'v2 torrent should have correct auto-generated v2 hash')
t.equal(parsed.infoHash, undefined, 'v2-only should not have v1 infoHash')
t.equal(parsed.pieces, undefined, 'v2-only should not have pieces')

Comment thread
leoherzog marked this conversation as resolved.
t.end()
})

test('Test validation requires either v1 or v2 hash', async t => {
// Test that magnet with no valid hash fails
try {
await parseTorrent('magnet:?xt=urn:invalid:123')
t.fail('Should have thrown error for invalid magnet')
} catch (err) {
t.ok(err instanceof Error)
t.ok(err.message.includes('Invalid torrent identifier'))
}

// Test that object with neither hash fails
try {
await parseTorrent({ name: 'test' })
t.fail('Should have thrown error for object without hashes')
} catch (err) {
t.ok(err instanceof Error)
t.ok(err.message.includes('Invalid torrent identifier'))
}

t.end()
})
3 changes: 2 additions & 1 deletion test/magnet-metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ const leavesMagnetParsed = {
'piece length': 16384,
pieces: new Uint8Array(Buffer.from('H5w/Wb7sB5cV7FMyS96FaeSgtOvsQjB9TOVVe105ZMXvVdNUz0puzHvxvK950R+l4L4GWTyPqvwMK6LPdtccWwFSayMAf56ZKb6vxRUeZREJMaG0TCG/Hmi5E4+QSV5pDbxV9XLkwpRMus8m5rOuinIp2IqvoF9h6q5qvz8Hy225Z3zGre1N05heRYYnVn+nY58GX3GxiVQwSspjZnKeC0dz13roDKqWpSSATf5Lm9PervmZyd1RAnRnUZ1eslYa4swBRn3l9kMKYLy6JHl2ku+odw0j3wqDDZHLNbNAeoi6oFkNyMmqahIPJ0Nn3Nhn6I6DOMVyoG48gBsp9RnfUys+dvZwz2ruUxB/PTk3hIP2nPgPpWix6sU7UGFZ6YjYvBaSLRJdd9gD1lLDyjBwwW7tkXKrUG0g5SLqPxq2dLP5I9dv6PRP8y43LDs3ZWTG+18NvlIWTwNin9EyJja6uywBS32uWC2kE2OWUmHmzhK0NwHwqMntFSCnDroARACiZ3ZfbT3Vx761vTx1898qVFYKYYARR/pOx89WjnA6ywTlYQpNVtzCQtAyk+lEbPXkV9jrPZWI/ZDGmN6bDa2SmAkGwCbYwUCPoI/k7A==', 'base64'))
},
infoBuffer: new Uint8Array(Buffer.from('ZDY6bGVuZ3RoaTM2MjAxN2U0Om5hbWUzNjpMZWF2ZXMgb2YgR3Jhc3MgYnkgV2FsdCBXaGl0bWFuLmVwdWIxMjpwaWVjZSBsZW5ndGhpMTYzODRlNjpwaWVjZXM0NjA6H5w/Wb7sB5cV7FMyS96FaeSgtOvsQjB9TOVVe105ZMXvVdNUz0puzHvxvK950R+l4L4GWTyPqvwMK6LPdtccWwFSayMAf56ZKb6vxRUeZREJMaG0TCG/Hmi5E4+QSV5pDbxV9XLkwpRMus8m5rOuinIp2IqvoF9h6q5qvz8Hy225Z3zGre1N05heRYYnVn+nY58GX3GxiVQwSspjZnKeC0dz13roDKqWpSSATf5Lm9PervmZyd1RAnRnUZ1eslYa4swBRn3l9kMKYLy6JHl2ku+odw0j3wqDDZHLNbNAeoi6oFkNyMmqahIPJ0Nn3Nhn6I6DOMVyoG48gBsp9RnfUys+dvZwz2ruUxB/PTk3hIP2nPgPpWix6sU7UGFZ6YjYvBaSLRJdd9gD1lLDyjBwwW7tkXKrUG0g5SLqPxq2dLP5I9dv6PRP8y43LDs3ZWTG+18NvlIWTwNin9EyJja6uywBS32uWC2kE2OWUmHmzhK0NwHwqMntFSCnDroARACiZ3ZfbT3Vx761vTx1898qVFYKYYARR/pOx89WjnA6ywTlYQpNVtzCQtAyk+lEbPXkV9jrPZWI/ZDGmN6bDa2SmAkGwCbYwUCPoI/k7GU=', 'base64'))
infoBuffer: new Uint8Array(Buffer.from('ZDY6bGVuZ3RoaTM2MjAxN2U0Om5hbWUzNjpMZWF2ZXMgb2YgR3Jhc3MgYnkgV2FsdCBXaGl0bWFuLmVwdWIxMjpwaWVjZSBsZW5ndGhpMTYzODRlNjpwaWVjZXM0NjA6H5w/Wb7sB5cV7FMyS96FaeSgtOvsQjB9TOVVe105ZMXvVdNUz0puzHvxvK950R+l4L4GWTyPqvwMK6LPdtccWwFSayMAf56ZKb6vxRUeZREJMaG0TCG/Hmi5E4+QSV5pDbxV9XLkwpRMus8m5rOuinIp2IqvoF9h6q5qvz8Hy225Z3zGre1N05heRYYnVn+nY58GX3GxiVQwSspjZnKeC0dz13roDKqWpSSATf5Lm9PervmZyd1RAnRnUZ1eslYa4swBRn3l9kMKYLy6JHl2ku+odw0j3wqDDZHLNbNAeoi6oFkNyMmqahIPJ0Nn3Nhn6I6DOMVyoG48gBsp9RnfUys+dvZwz2ruUxB/PTk3hIP2nPgPpWix6sU7UGFZ6YjYvBaSLRJdd9gD1lLDyjBwwW7tkXKrUG0g5SLqPxq2dLP5I9dv6PRP8y43LDs3ZWTG+18NvlIWTwNin9EyJja6uywBS32uWC2kE2OWUmHmzhK0NwHwqMntFSCnDroARACiZ3ZfbT3Vx761vTx1898qVFYKYYARR/pOx89WjnA6ywTlYQpNVtzCQtAyk+lEbPXkV9jrPZWI/ZDGmN6bDa2SmAkGwCbYwUCPoI/k7GU=', 'base64')),
version: 'v1'
}

test('parse "torrent" from magnet metadata protocol', async t => {
Expand Down
3 changes: 2 additions & 1 deletion test/no-announce-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ const bitloveParsed = {
'piece length': 1048576,
pieces: new Uint8Array(Buffer.from('kKddzU6I0ofHrFWZwQj2A2wTxM4e9UaL3/mkRmrU5EZHeYHLZ9B5Mx+pEaZjRRKAlT7bcj5nYRlX3A/hKrrWBm4pxyPwGwkI7DDg5zdRSohVr9qOFKRef3l+tHuCstCjsspfNn4fSVk1Fcobk60Bw+4FDjXwT1wuFbmrsSMigALMpqfYj8n8mdJFg+EycEoCDS8SG/xhK3YnzZLis5rUPDW+uyiI9xQ8KWa7TV904Lh1glhWaHX0uxqfpjHuNbzXRpsej/N9ZaLLvu6twUjtaBtpnoipQPeW9RwJFcaRIcgdhQVWeL8Zi7KfyeUE7Yx/fjhjxuHGqMgkVp8cwJUEmNzrA8SrTnfa3l9UJGVZxAkVtwCk9zTO4JLEe+LTl6+/BqnppXOmOjxoPSqlAa0hKhSVIIt/+7FzzleCKRaVZSs/YjO/TqNknHeZoYSPBsrel5h1Jds3x5nkW9Avwl6swS4YxsEbTaP7THPfkwezk5/sPNXw3xecUKScbKM=', 'base64'))
},
infoBuffer: new Uint8Array(Buffer.from('ZDY6bGVuZ3RoaTE5MjExNzI5ZTQ6bmFtZTIzOmJsMDAxLWludHJvZHVjdGlvbi53ZWJtMTI6cGllY2UgbGVuZ3RoaTEwNDg1NzZlNjpwaWVjZXMzODA6kKddzU6I0ofHrFWZwQj2A2wTxM4e9UaL3/mkRmrU5EZHeYHLZ9B5Mx+pEaZjRRKAlT7bcj5nYRlX3A/hKrrWBm4pxyPwGwkI7DDg5zdRSohVr9qOFKRef3l+tHuCstCjsspfNn4fSVk1Fcobk60Bw+4FDjXwT1wuFbmrsSMigALMpqfYj8n8mdJFg+EycEoCDS8SG/xhK3YnzZLis5rUPDW+uyiI9xQ8KWa7TV904Lh1glhWaHX0uxqfpjHuNbzXRpsej/N9ZaLLvu6twUjtaBtpnoipQPeW9RwJFcaRIcgdhQVWeL8Zi7KfyeUE7Yx/fjhjxuHGqMgkVp8cwJUEmNzrA8SrTnfa3l9UJGVZxAkVtwCk9zTO4JLEe+LTl6+/BqnppXOmOjxoPSqlAa0hKhSVIIt/+7FzzleCKRaVZSs/YjO/TqNknHeZoYSPBsrel5h1Jds3x5nkW9Avwl6swS4YxsEbTaP7THPfkwezk5/sPNXw3xecUKScbKNl', 'base64'))
infoBuffer: new Uint8Array(Buffer.from('ZDY6bGVuZ3RoaTE5MjExNzI5ZTQ6bmFtZTIzOmJsMDAxLWludHJvZHVjdGlvbi53ZWJtMTI6cGllY2UgbGVuZ3RoaTEwNDg1NzZlNjpwaWVjZXMzODA6kKddzU6I0ofHrFWZwQj2A2wTxM4e9UaL3/mkRmrU5EZHeYHLZ9B5Mx+pEaZjRRKAlT7bcj5nYRlX3A/hKrrWBm4pxyPwGwkI7DDg5zdRSohVr9qOFKRef3l+tHuCstCjsspfNn4fSVk1Fcobk60Bw+4FDjXwT1wuFbmrsSMigALMpqfYj8n8mdJFg+EycEoCDS8SG/xhK3YnzZLis5rUPDW+uyiI9xQ8KWa7TV904Lh1glhWaHX0uxqfpjHuNbzXRpsej/N9ZaLLvu6twUjtaBtpnoipQPeW9RwJFcaRIcgdhQVWeL8Zi7KfyeUE7Yx/fjhjxuHGqMgkVp8cwJUEmNzrA8SrTnfa3l9UJGVZxAkVtwCk9zTO4JLEe+LTl6+/BqnppXOmOjxoPSqlAa0hKhSVIIt/+7FzzleCKRaVZSs/YjO/TqNknHeZoYSPBsrel5h1Jds3x5nkW9Avwl6swS4YxsEbTaP7THPfkwezk5/sPNXw3xecUKScbKNl', 'base64')),
version: 'v1'
}

test('parse torrent with no announce-list', async t => {
Expand Down
1 change: 1 addition & 0 deletions test/node/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { remote } from '../../index.js'
import test from 'tape'

fixtures.leaves.parsedTorrent.infoHashBuffer = new Uint8Array(fixtures.leaves.parsedTorrent.infoHashBuffer)
fixtures.leaves.parsedTorrent.version = 'v1'

test('http url to a torrent file, string', t => {
t.plan(3)
Expand Down
Binary file added test/torrents/bittorrent-v2-hybrid-test.torrent
Binary file not shown.
Binary file added test/torrents/bittorrent-v2-test.torrent
Binary file not shown.