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
137 changes: 104 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,29 @@ 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 isV2 = torrent.info['meta version'] === 2
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 (isV2 || hasV2Structure) {
ensure(torrent.info['file tree'], 'info[\'file tree\']')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably also check for presence of piece layers here too :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great call. 5577ce6

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 +170,21 @@ async function decodeTorrentFile (torrent) {
announce: []
}

result.infoHashBuffer = await hash(result.infoBuffer)
result.infoHash = arr2hex(result.infoHashBuffer)
// Auto-detect hash generation based on torrent type
const hasFileTree = hasV2Structure

const shouldGenerateV1 = hasV1Structure
const shouldGenerateV2 = isV2 || hasFileTree

if (shouldGenerateV1) {
result.infoHashBuffer = await hash(result.infoBuffer)
result.infoHash = arr2hex(result.infoHashBuffer)
}

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

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

Expand Down Expand Up @@ -175,7 +217,29 @@ async function decodeTorrentFile (torrent) {
result.announce = Array.from(new Set(result.announce))
result.urlList = Array.from(new Set(result.urlList))

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 = []
function processFileTree (tree, currentPath = []) {
for (const [name, entry] of Object.entries(tree)) {
const fullPath = [...currentPath, name]
if (entry.length !== undefined) {
files.push({
'path.utf-8': fullPath,
length: entry.length
})
} else {
processFileTree(entry, fullPath)
}
}
}
processFileTree(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)
return {
Expand All @@ -192,7 +256,14 @@ 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)

// Simplified pieces handling - fall back to v1 logic for both
if (torrent.info.pieces) {
result.pieces = splitPieces(torrent.info.pieces)
} else {
// For v2 torrents without pieces, create empty array
result.pieces = []
}

return result
}
Expand Down
93 changes: 93 additions & 0 deletions test/bittorrent-v2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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())
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.ok(parsed.infoHashV2)

// 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()
})

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.ok(v2Parsed.infoHashV2, 'v2 torrent should have v2 hash')
t.equal(v2Parsed.infoHashV2.length, 64, 'v2 hash should be 64 chars')

// Test hybrid torrent (should auto-detect and generate both hashes)
const hybrid = await parseTorrent(hybridBuf)
t.ok(hybrid.infoHash, 'Hybrid should have v1 hash')
t.ok(hybrid.infoHashV2, 'Hybrid should have v2 hash')
t.equal(hybrid.infoHash.length, 40, 'v1 hash should be 40 chars')
t.equal(hybrid.infoHashV2.length, 64, 'v2 hash should be 64 chars')

// 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.ok(parsed.infoHashV2, 'v2 torrent should auto-generate v2 hash')

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()
})
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.