-
-
Notifications
You must be signed in to change notification settings - Fork 85
Feat: BitTorrent v2 Support [BEP 52] #193
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 11 commits
3289039
44fa7b5
c4353fc
dbc9501
20374a9
f34b051
5577ce6
301c07b
f147872
1ca7bec
559f9f9
13c70e7
a7771c3
2ad638f
d9204a7
434ad73
dfb54c8
38f7f43
7903819
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = [] | ||
|
|
||
|
|
@@ -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) | ||
| }) | ||
|
|
@@ -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')) | ||
| } | ||
| } | ||
|
|
@@ -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) { | ||
|
leoherzog marked this conversation as resolved.
Outdated
|
||
| ensure(torrent.info['file tree'], 'info[\'file tree\']') | ||
|
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 = { | ||
|
|
@@ -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) | ||
| } | ||
|
leoherzog marked this conversation as resolved.
Outdated
|
||
|
|
||
| if (torrent.info.private !== undefined) result.private = !!torrent.info.private | ||
|
|
||
|
|
@@ -176,7 +218,29 @@ 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 = [] | ||
| function processFileTree (tree, currentPath = []) { | ||
|
leoherzog marked this conversation as resolved.
Outdated
|
||
| for (const [name, entry] of Object.entries(tree)) { | ||
| const fullPath = [...currentPath, name] | ||
| if (entry.length !== undefined) { | ||
|
leoherzog marked this conversation as resolved.
Outdated
|
||
| files.push({ | ||
| 'path.utf-8': fullPath, | ||
| length: entry.length | ||
| }) | ||
| } else { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i am not sure if this package handles BEP-47 file attributes, but they are pretty important for hybrid torrents, specifically for padfiles. maybe we could just spread the entry object here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this would be my main requested change^
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Something like this? 2ad638f There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep, exactly |
||
| 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) | ||
| sum += file.length | ||
|
|
@@ -194,7 +258,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 = [] | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would not do this: this makes it ambiguous whether the torrent is improperly created (it actually has an empty pieces string) or just a v2-only torrent. imo
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call. d9204a7 |
||
|
|
||
| return result | ||
| } | ||
|
|
||
| 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()) | ||
|
leoherzog marked this conversation as resolved.
|
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not the same assert equal in all of the cases?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cleaned up testing in 38f7f43 |
||
|
|
||
| // 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() | ||
| }) | ||
|
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.ok(v2Parsed.infoHashV2, 'v2 torrent should have v2 hash') | ||
| t.equal(v2Parsed.infoHashV2.length, 64, 'v2 hash should be 64 chars') | ||
|
leoherzog marked this conversation as resolved.
Outdated
|
||
|
|
||
| // 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') | ||
|
leoherzog marked this conversation as resolved.
Outdated
|
||
|
|
||
|
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() | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.