@ctrl/torrent-file 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -11
- package/dist/src/bencode/decode.js +68 -72
- package/dist/src/bencode/encode.d.ts +0 -1
- package/dist/src/bencode/encode.js +57 -33
- package/dist/src/torrentFile.d.ts +42 -1
- package/dist/src/torrentFile.js +217 -30
- package/package.json +29 -49
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
# torrent-file [](https://www.npmjs.com/package/@ctrl/torrent-file)
|
|
1
|
+
# torrent-file [](https://www.npmjs.com/package/@ctrl/torrent-file)
|
|
2
2
|
|
|
3
3
|
> Parse a torrent file and read encoded data.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Supports BitTorrent v1 ([BEP-3](http://www.bittorrent.org/beps/bep_0003.html)), v2 ([BEP-52](http://www.bittorrent.org/beps/bep_0052.html)), and hybrid torrent files.
|
|
6
|
+
|
|
7
|
+
This project is based on [parse-torrent](https://www.npmjs.com/package/parse-torrent) and [node-bencode](https://github.com/themasch/node-bencode) to parse the data of a torrent file. This library implements its own [bencode](http://www.bittorrent.org/beps/bep_0003.html) encoder and decoder that does not use `Buffer` making it easier to use in browser or non node environments.
|
|
8
|
+
|
|
9
|
+
demo: https://torrent-file.pages.dev
|
|
6
10
|
|
|
7
11
|
### Install
|
|
8
12
|
|
|
@@ -14,7 +18,7 @@ npm install @ctrl/torrent-file
|
|
|
14
18
|
|
|
15
19
|
##### info
|
|
16
20
|
|
|
17
|
-
The content of the metainfo file.
|
|
21
|
+
The content of the metainfo file. Includes a `version` field (`'v1'`, `'v2'`, or `'hybrid'`).
|
|
18
22
|
|
|
19
23
|
```ts
|
|
20
24
|
import fs from 'fs';
|
|
@@ -27,7 +31,7 @@ console.log({ torrentInfo });
|
|
|
27
31
|
|
|
28
32
|
##### files
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
Data about the files described in the torrent file, includes hashes of the pieces. For v2/hybrid torrents, files include `piecesRoot` and the result includes `pieceLayers`. `pieces` is undefined for v2-only torrents.
|
|
31
35
|
|
|
32
36
|
```ts
|
|
33
37
|
import fs from 'fs';
|
|
@@ -40,20 +44,78 @@ console.log({ torrentFiles });
|
|
|
40
44
|
|
|
41
45
|
##### hash
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
SHA-1 of torrent file info. This hash is commonly used by torrent clients as the ID of the torrent.
|
|
44
48
|
|
|
45
49
|
```ts
|
|
46
50
|
import fs from 'fs';
|
|
47
51
|
|
|
48
52
|
import { hash } from '@ctrl/torrent-file';
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
const torrentHash = hash(fs.readFileSync('myfile'));
|
|
55
|
+
console.log({ torrentHash });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
##### hashes
|
|
59
|
+
|
|
60
|
+
Returns both v1 (SHA-1) and v2 (SHA-256) info hashes along with the detected torrent version. `infoHashV2` is only present for v2 and hybrid torrents.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import fs from 'fs';
|
|
64
|
+
|
|
65
|
+
import { hashes } from '@ctrl/torrent-file';
|
|
66
|
+
|
|
67
|
+
const h = hashes(fs.readFileSync('myfile'));
|
|
68
|
+
console.log(h.version); // 'v1', 'v2', or 'hybrid'
|
|
69
|
+
console.log(h.infoHash); // SHA-1 (always present)
|
|
70
|
+
console.log(h.infoHashV2); // SHA-256 (v2/hybrid only)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Encode
|
|
74
|
+
|
|
75
|
+
Convert a parsed torrent object back into a `.torrent` file buffer.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import fs from 'fs';
|
|
79
|
+
|
|
80
|
+
import { toTorrentFile } from '@ctrl/torrent-file';
|
|
81
|
+
|
|
82
|
+
// Minimal example: create a .torrent buffer from fields
|
|
83
|
+
const buf = toTorrentFile({
|
|
84
|
+
info: {
|
|
85
|
+
// Required info fields
|
|
86
|
+
'piece length': 16384,
|
|
87
|
+
pieces: new Uint8Array(/* 20-byte SHA1 hashes concatenated */),
|
|
88
|
+
name: 'example.txt',
|
|
89
|
+
length: 12345,
|
|
90
|
+
},
|
|
91
|
+
// Optional fields
|
|
92
|
+
announce: ['udp://tracker.publicbt.com:80/announce'],
|
|
93
|
+
urlList: ['https://example.com/example.txt'],
|
|
94
|
+
private: false,
|
|
95
|
+
created: new Date(),
|
|
96
|
+
createdBy: 'my-app/1.0.0',
|
|
97
|
+
comment: 'Generated by @ctrl/torrent-file',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
fs.writeFileSync('example.torrent', buf);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Demo
|
|
104
|
+
|
|
105
|
+
Run a local demo UI to drop a `.torrent` file and view parsed output:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pnpm install
|
|
109
|
+
pnpm demo:watch
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
To build the demo for static hosting:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pnpm demo:build
|
|
54
116
|
```
|
|
55
117
|
|
|
56
118
|
### See Also
|
|
57
119
|
|
|
58
|
-
[parse-torrent](https://www.npmjs.com/package/parse-torrent) -
|
|
59
|
-
[node-bencode](https://github.com/themasch/node-bencode) - bencoder built into this project heavily based off this
|
|
120
|
+
[parse-torrent](https://www.npmjs.com/package/parse-torrent) - torrent parsing based very heavily off this package
|
|
121
|
+
[node-bencode](https://github.com/themasch/node-bencode) - bencoder built into this project heavily based off this package
|
|
@@ -1,64 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
1
|
+
// Byte constants
|
|
2
|
+
const COLON = 0x3a; // ':'
|
|
3
|
+
const CHAR_d = 0x64; // 'd'
|
|
4
|
+
const CHAR_e = 0x65; // 'e'
|
|
5
|
+
const CHAR_i = 0x69; // 'i'
|
|
6
|
+
const CHAR_l = 0x6c; // 'l'
|
|
7
|
+
const CHAR_0 = 0x30; // '0'
|
|
8
|
+
const CHAR_MINUS = 0x2d; // '-'
|
|
9
|
+
const te = new TextEncoder();
|
|
3
10
|
class Decoder {
|
|
4
11
|
idx = 0;
|
|
5
12
|
buf;
|
|
6
13
|
constructor(buf) {
|
|
7
14
|
this.buf = buf;
|
|
8
15
|
}
|
|
9
|
-
readByte() {
|
|
10
|
-
if (this.idx >= this.buf.length) {
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
return String.fromCharCode(this.buf[this.idx++]);
|
|
14
|
-
}
|
|
15
|
-
readBytes(length) {
|
|
16
|
-
if (this.idx + length > this.buf.length) {
|
|
17
|
-
throw new Error(`could not read ${length} bytes, insufficient content`);
|
|
18
|
-
}
|
|
19
|
-
const result = this.buf.slice(this.idx, this.idx + length);
|
|
20
|
-
this.idx += length;
|
|
21
|
-
return result;
|
|
22
|
-
}
|
|
23
|
-
readUntil(char) {
|
|
24
|
-
const targetIdx = this.buf.indexOf(char.charCodeAt(0), this.idx);
|
|
25
|
-
if (targetIdx === -1) {
|
|
26
|
-
throw new Error(`could not find terminated char: ${char}`);
|
|
27
|
-
}
|
|
28
|
-
const result = this.buf.slice(this.idx, targetIdx);
|
|
29
|
-
this.idx = targetIdx;
|
|
30
|
-
return result;
|
|
31
|
-
}
|
|
32
|
-
readNumber() {
|
|
33
|
-
const buf = this.readUntil(':');
|
|
34
|
-
return parseInt(uint8ArrayToString(buf), 10);
|
|
35
|
-
}
|
|
36
|
-
peekByte() {
|
|
37
|
-
if (this.idx >= this.buf.length) {
|
|
38
|
-
return '';
|
|
39
|
-
}
|
|
40
|
-
const result = this.readByte();
|
|
41
|
-
if (result === null) {
|
|
42
|
-
return '';
|
|
43
|
-
}
|
|
44
|
-
this.idx--;
|
|
45
|
-
return result;
|
|
46
|
-
}
|
|
47
|
-
assertByte(expected) {
|
|
48
|
-
const b = this.readByte();
|
|
49
|
-
if (b !== expected) {
|
|
50
|
-
throw new Error(`expecte ${expected}, got ${b}`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
16
|
next() {
|
|
54
|
-
|
|
55
|
-
|
|
17
|
+
const byte = this.buf[this.idx];
|
|
18
|
+
switch (byte) {
|
|
19
|
+
case CHAR_d: {
|
|
56
20
|
return this.nextDictionary();
|
|
57
21
|
}
|
|
58
|
-
case
|
|
22
|
+
case CHAR_l: {
|
|
59
23
|
return this.nextList();
|
|
60
24
|
}
|
|
61
|
-
case
|
|
25
|
+
case CHAR_i: {
|
|
62
26
|
return this.nextNumber();
|
|
63
27
|
}
|
|
64
28
|
default: {
|
|
@@ -67,52 +31,84 @@ class Decoder {
|
|
|
67
31
|
}
|
|
68
32
|
}
|
|
69
33
|
nextBufOrString() {
|
|
70
|
-
const length = this.
|
|
71
|
-
this.
|
|
72
|
-
|
|
34
|
+
const length = this.readLength();
|
|
35
|
+
const result = this.buf.subarray(this.idx, this.idx + length);
|
|
36
|
+
this.idx += length;
|
|
37
|
+
return result;
|
|
73
38
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
39
|
+
// Read a length prefix including the trailing colon: "123:"
|
|
40
|
+
readLength() {
|
|
41
|
+
let n = 0;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const byte = this.buf[this.idx++];
|
|
44
|
+
if (byte === COLON) {
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
n = n * 10 + (byte - CHAR_0);
|
|
48
|
+
}
|
|
78
49
|
}
|
|
79
50
|
nextNumber() {
|
|
80
|
-
this.
|
|
81
|
-
|
|
82
|
-
this.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
51
|
+
this.idx++; // skip 'i'
|
|
52
|
+
let negative = false;
|
|
53
|
+
if (this.buf[this.idx] === CHAR_MINUS) {
|
|
54
|
+
negative = true;
|
|
55
|
+
this.idx++;
|
|
56
|
+
}
|
|
57
|
+
let n = 0;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const byte = this.buf[this.idx++];
|
|
60
|
+
if (byte === CHAR_e) {
|
|
61
|
+
return negative ? -n : n;
|
|
62
|
+
}
|
|
63
|
+
n = n * 10 + (byte - CHAR_0);
|
|
86
64
|
}
|
|
87
|
-
return result;
|
|
88
65
|
}
|
|
89
66
|
nextList() {
|
|
90
|
-
this.
|
|
67
|
+
this.idx++; // skip 'l'
|
|
91
68
|
const result = [];
|
|
92
|
-
while (this.
|
|
69
|
+
while (this.buf[this.idx] !== CHAR_e) {
|
|
93
70
|
result.push(this.next());
|
|
94
71
|
}
|
|
95
|
-
this.
|
|
72
|
+
this.idx++; // skip 'e'
|
|
96
73
|
return result;
|
|
97
74
|
}
|
|
75
|
+
// Latin1 (1 byte → 1 code point) is lossless for arbitrary binary keys.
|
|
76
|
+
// BEP-52 piece layers uses raw SHA-256 hashes as dict keys, which
|
|
77
|
+
// would be corrupted by UTF-8 decoding.
|
|
78
|
+
nextKeyLatin1() {
|
|
79
|
+
const length = this.readLength();
|
|
80
|
+
const start = this.idx;
|
|
81
|
+
this.idx += length;
|
|
82
|
+
let key = '';
|
|
83
|
+
for (let i = start; i < this.idx; i++) {
|
|
84
|
+
key += String.fromCharCode(this.buf[i]);
|
|
85
|
+
}
|
|
86
|
+
return key;
|
|
87
|
+
}
|
|
98
88
|
nextDictionary() {
|
|
99
|
-
this.
|
|
89
|
+
this.idx++; // skip 'd'
|
|
100
90
|
const result = {};
|
|
101
|
-
while (this.
|
|
102
|
-
result[this.
|
|
91
|
+
while (this.buf[this.idx] !== CHAR_e) {
|
|
92
|
+
result[this.nextKeyLatin1()] = this.next();
|
|
103
93
|
}
|
|
104
|
-
this.
|
|
94
|
+
this.idx++; // skip 'e'
|
|
105
95
|
return result;
|
|
106
96
|
}
|
|
107
97
|
}
|
|
108
98
|
export const decode = (payload) => {
|
|
109
99
|
let buf;
|
|
110
100
|
if (typeof payload === 'string') {
|
|
111
|
-
buf =
|
|
101
|
+
buf = te.encode(payload);
|
|
112
102
|
}
|
|
113
103
|
else if (payload instanceof ArrayBuffer) {
|
|
114
104
|
buf = new Uint8Array(payload);
|
|
115
105
|
}
|
|
106
|
+
else if (payload instanceof Uint8Array) {
|
|
107
|
+
buf =
|
|
108
|
+
payload.constructor === Uint8Array
|
|
109
|
+
? payload
|
|
110
|
+
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
111
|
+
}
|
|
116
112
|
else if ('buffer' in payload) {
|
|
117
113
|
buf = new Uint8Array(payload.buffer);
|
|
118
114
|
}
|
|
@@ -1,52 +1,77 @@
|
|
|
1
|
-
import { concatUint8Arrays, stringToUint8Array } from 'uint8array-extras';
|
|
2
1
|
const te = new TextEncoder();
|
|
2
|
+
const COLON = 0x3a;
|
|
3
|
+
const BYTE_d = new Uint8Array([0x64]);
|
|
4
|
+
const BYTE_e = new Uint8Array([0x65]);
|
|
5
|
+
const BYTE_l = new Uint8Array([0x6c]);
|
|
6
|
+
function concat(arrays) {
|
|
7
|
+
let totalLength = 0;
|
|
8
|
+
for (const arr of arrays) {
|
|
9
|
+
totalLength += arr.byteLength;
|
|
10
|
+
}
|
|
11
|
+
const result = new Uint8Array(totalLength);
|
|
12
|
+
let offset = 0;
|
|
13
|
+
for (const arr of arrays) {
|
|
14
|
+
result.set(arr, offset);
|
|
15
|
+
offset += arr.byteLength;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
3
19
|
const encodeString = (str) => {
|
|
4
|
-
const lengthBytes = new TextEncoder().encode(str.length.toString());
|
|
5
20
|
const content = te.encode(str);
|
|
6
|
-
const
|
|
7
|
-
result.
|
|
8
|
-
|
|
9
|
-
result.
|
|
21
|
+
const lengthStr = content.byteLength.toString();
|
|
22
|
+
const result = new Uint8Array(lengthStr.length + 1 + content.byteLength);
|
|
23
|
+
te.encodeInto(lengthStr, result);
|
|
24
|
+
result[lengthStr.length] = COLON;
|
|
25
|
+
result.set(content, lengthStr.length + 1);
|
|
10
26
|
return result;
|
|
11
27
|
};
|
|
12
28
|
const encodeBuf = (buf) => {
|
|
13
|
-
const
|
|
14
|
-
const result = new Uint8Array(
|
|
15
|
-
|
|
16
|
-
result.
|
|
17
|
-
result.set(buf,
|
|
29
|
+
const lengthStr = buf.byteLength.toString();
|
|
30
|
+
const result = new Uint8Array(lengthStr.length + 1 + buf.byteLength);
|
|
31
|
+
te.encodeInto(lengthStr, result);
|
|
32
|
+
result[lengthStr.length] = COLON;
|
|
33
|
+
result.set(buf, lengthStr.length + 1);
|
|
18
34
|
return result;
|
|
19
35
|
};
|
|
20
36
|
const encodeNumber = (num) => {
|
|
21
|
-
// NOTE: only support integers
|
|
22
37
|
const int = Math.floor(num);
|
|
23
38
|
if (int !== num) {
|
|
24
39
|
throw new Error(`bencode only support integers, got ${num}`);
|
|
25
40
|
}
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
41
|
+
return te.encode(`i${int}e`);
|
|
42
|
+
};
|
|
43
|
+
// Inverse of Decoder.nextKeyLatin1 — see decode.ts for rationale.
|
|
44
|
+
const encodeKeyLatin1 = (key) => {
|
|
45
|
+
const lengthStr = key.length.toString();
|
|
46
|
+
const result = new Uint8Array(lengthStr.length + 1 + key.length);
|
|
47
|
+
te.encodeInto(lengthStr, result);
|
|
48
|
+
result[lengthStr.length] = COLON;
|
|
49
|
+
const offset = lengthStr.length + 1;
|
|
50
|
+
for (let i = 0; i < key.length; i++) {
|
|
51
|
+
result[offset + i] = key.charCodeAt(i) & 0xff; // eslint-disable-line no-bitwise
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
31
54
|
};
|
|
32
55
|
const encodeDictionary = (obj) => {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
56
|
+
const keys = Object.keys(obj).sort();
|
|
57
|
+
const parts = new Array(keys.length * 2 + 2); // eslint-disable-line unicorn/no-new-array
|
|
58
|
+
parts[0] = BYTE_d;
|
|
59
|
+
let i = 1;
|
|
60
|
+
for (const key of keys) {
|
|
61
|
+
parts[i++] = encodeKeyLatin1(key);
|
|
62
|
+
parts[i++] = encode(obj[key]);
|
|
63
|
+
}
|
|
64
|
+
parts[i] = BYTE_e;
|
|
65
|
+
return concat(parts);
|
|
43
66
|
};
|
|
44
67
|
const encodeArray = (arr) => {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
const parts = new Array(arr.length + 2); // eslint-disable-line unicorn/no-new-array
|
|
69
|
+
parts[0] = BYTE_l;
|
|
70
|
+
for (let i = 0; i < arr.length; i++) {
|
|
71
|
+
parts[i + 1] = encode(arr[i]);
|
|
72
|
+
}
|
|
73
|
+
parts[arr.length + 1] = BYTE_e;
|
|
74
|
+
return concat(parts);
|
|
50
75
|
};
|
|
51
76
|
export const encode = (data) => {
|
|
52
77
|
if (Array.isArray(data)) {
|
|
@@ -70,4 +95,3 @@ export const encode = (data) => {
|
|
|
70
95
|
}
|
|
71
96
|
}
|
|
72
97
|
};
|
|
73
|
-
export default encode;
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
export declare const sha1: (input: Uint8Array) => string;
|
|
2
|
+
export declare const sha256: (input: Uint8Array) => string;
|
|
3
|
+
export type TorrentVersion = 'v1' | 'v2' | 'hybrid';
|
|
2
4
|
/**
|
|
3
5
|
* sha1 of torrent file info. This hash is commonly used by torrent clients as the ID of the torrent.
|
|
4
6
|
*/
|
|
5
7
|
export declare function hash(file: Uint8Array): string;
|
|
8
|
+
/**
|
|
9
|
+
* sha256 of torrent file info dict. Used as the info hash for v2/hybrid torrents.
|
|
10
|
+
*/
|
|
11
|
+
export declare function hashV2(file: Uint8Array): string;
|
|
12
|
+
/**
|
|
13
|
+
* Returns both v1 and v2 info hashes along with the detected torrent version.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hashes(file: Uint8Array): {
|
|
16
|
+
infoHash: string;
|
|
17
|
+
infoHashV2?: string;
|
|
18
|
+
version: TorrentVersion;
|
|
19
|
+
};
|
|
6
20
|
export interface TorrentFileData {
|
|
7
21
|
length: number;
|
|
8
22
|
files: Array<{
|
|
@@ -16,13 +30,25 @@ export interface TorrentFileData {
|
|
|
16
30
|
*/
|
|
17
31
|
length: number;
|
|
18
32
|
offset: number;
|
|
33
|
+
/**
|
|
34
|
+
* hex-encoded SHA-256 pieces root for this file (v2/hybrid only)
|
|
35
|
+
*/
|
|
36
|
+
piecesRoot?: string;
|
|
19
37
|
}>;
|
|
20
38
|
/**
|
|
21
39
|
* number of bytes in each piece
|
|
22
40
|
*/
|
|
23
41
|
pieceLength: number;
|
|
24
42
|
lastPieceLength: number;
|
|
25
|
-
|
|
43
|
+
/**
|
|
44
|
+
* hex-encoded SHA-1 piece hashes (v1/hybrid). Undefined for v2-only torrents.
|
|
45
|
+
*/
|
|
46
|
+
pieces?: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Maps hex-encoded pieces root to array of hex-encoded SHA-256 piece hashes (v2/hybrid only).
|
|
49
|
+
*/
|
|
50
|
+
pieceLayers?: Record<string, string[]>;
|
|
51
|
+
version: TorrentVersion;
|
|
26
52
|
}
|
|
27
53
|
/**
|
|
28
54
|
* data about the files the torrent contains
|
|
@@ -51,8 +77,23 @@ export interface TorrentInfo {
|
|
|
51
77
|
* weburls to download torrent files
|
|
52
78
|
*/
|
|
53
79
|
urlList: string[];
|
|
80
|
+
version: TorrentVersion;
|
|
54
81
|
}
|
|
55
82
|
/**
|
|
56
83
|
* torrent file info
|
|
57
84
|
*/
|
|
58
85
|
export declare function info(file: Uint8Array): TorrentInfo;
|
|
86
|
+
export interface TorrentFileEncodeInput {
|
|
87
|
+
info: any;
|
|
88
|
+
announce?: string[];
|
|
89
|
+
urlList?: string[];
|
|
90
|
+
private?: boolean;
|
|
91
|
+
created?: Date;
|
|
92
|
+
createdBy?: string;
|
|
93
|
+
comment?: string;
|
|
94
|
+
pieceLayers?: Record<string, Uint8Array>;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Convert a parsed torrent object back into a .torrent file buffer.
|
|
98
|
+
*/
|
|
99
|
+
export declare function toTorrentFile(parsed: TorrentFileEncodeInput): Uint8Array;
|
package/dist/src/torrentFile.js
CHANGED
|
@@ -1,20 +1,91 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import { isUint8Array, uint8ArrayToHex, uint8ArrayToString } from 'uint8array-extras';
|
|
2
|
+
import { sep } from 'node:path';
|
|
4
3
|
import { decode, encode } from './bencode/index.js';
|
|
5
|
-
|
|
4
|
+
const td = new TextDecoder();
|
|
5
|
+
const hexLookup = new Array(256); // eslint-disable-line unicorn/no-new-array
|
|
6
|
+
for (let i = 0; i < 256; i++) {
|
|
7
|
+
hexLookup[i] = (i < 16 ? '0' : '') + i.toString(16);
|
|
8
|
+
}
|
|
9
|
+
function toHex(buf) {
|
|
10
|
+
let hex = '';
|
|
11
|
+
// eslint-disable-next-line typescript-eslint/prefer-for-of
|
|
12
|
+
for (let i = 0; i < buf.length; i++) {
|
|
13
|
+
hex += hexLookup[buf[i]];
|
|
14
|
+
}
|
|
15
|
+
return hex;
|
|
16
|
+
}
|
|
6
17
|
const toString = (value) => {
|
|
7
18
|
if (value instanceof Uint8Array) {
|
|
8
|
-
return
|
|
19
|
+
return td.decode(value);
|
|
9
20
|
}
|
|
10
21
|
return value.toString();
|
|
11
22
|
};
|
|
12
23
|
export const sha1 = (input) => {
|
|
13
24
|
const hash = createHash('sha1');
|
|
14
|
-
// Update the hash object with the data
|
|
15
25
|
hash.update(input);
|
|
16
26
|
return hash.digest('hex');
|
|
17
27
|
};
|
|
28
|
+
export const sha256 = (input) => {
|
|
29
|
+
const hash = createHash('sha256');
|
|
30
|
+
hash.update(input);
|
|
31
|
+
return hash.digest('hex');
|
|
32
|
+
};
|
|
33
|
+
function detectVersion(infoDict) {
|
|
34
|
+
const hasV1 = !!(infoDict.pieces || infoDict.files || typeof infoDict.length === 'number');
|
|
35
|
+
const hasV2 = !!infoDict['file tree'];
|
|
36
|
+
let version;
|
|
37
|
+
if (hasV1 && hasV2) {
|
|
38
|
+
version = 'hybrid';
|
|
39
|
+
}
|
|
40
|
+
else if (hasV2) {
|
|
41
|
+
version = 'v2';
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
version = 'v1';
|
|
45
|
+
}
|
|
46
|
+
return { version, hasV1, hasV2 };
|
|
47
|
+
}
|
|
48
|
+
// BEP-52 file tree: directories are nested dicts, files are identified
|
|
49
|
+
// by an empty-string '' key whose value contains { length, 'pieces root' }.
|
|
50
|
+
function flattenFileTree(tree, currentPath) {
|
|
51
|
+
const result = [];
|
|
52
|
+
for (const key of Object.keys(tree)) {
|
|
53
|
+
const node = tree[key];
|
|
54
|
+
if (key === '') {
|
|
55
|
+
// This is a file entry
|
|
56
|
+
result.push({
|
|
57
|
+
length: node.length,
|
|
58
|
+
path: currentPath,
|
|
59
|
+
'pieces root': node['pieces root'],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// This is a directory, recurse
|
|
64
|
+
result.push(...flattenFileTree(node, [...currentPath, key]));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
function splitPieces(buf, chunkSize) {
|
|
70
|
+
const hex = toHex(buf);
|
|
71
|
+
const hexChunkSize = chunkSize * 2;
|
|
72
|
+
const count = Math.ceil(hex.length / hexChunkSize);
|
|
73
|
+
const pieces = new Array(count); // eslint-disable-line unicorn/no-new-array
|
|
74
|
+
for (let i = 0; i < count; i++) {
|
|
75
|
+
pieces[i] = hex.slice(i * hexChunkSize, (i + 1) * hexChunkSize);
|
|
76
|
+
}
|
|
77
|
+
return pieces;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Convert a latin1-encoded string key to hex.
|
|
81
|
+
*/
|
|
82
|
+
function latin1KeyToHex(key) {
|
|
83
|
+
let hex = '';
|
|
84
|
+
for (let i = 0; i < key.length; i++) {
|
|
85
|
+
hex += hexLookup[key.charCodeAt(i) & 0xff]; // eslint-disable-line no-bitwise
|
|
86
|
+
}
|
|
87
|
+
return hex;
|
|
88
|
+
}
|
|
18
89
|
/**
|
|
19
90
|
* sha1 of torrent file info. This hash is commonly used by torrent clients as the ID of the torrent.
|
|
20
91
|
*/
|
|
@@ -22,55 +93,131 @@ export function hash(file) {
|
|
|
22
93
|
const torrent = decode(file);
|
|
23
94
|
return sha1(encode(torrent.info));
|
|
24
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* sha256 of torrent file info dict. Used as the info hash for v2/hybrid torrents.
|
|
98
|
+
*/
|
|
99
|
+
export function hashV2(file) {
|
|
100
|
+
const torrent = decode(file);
|
|
101
|
+
return sha256(encode(torrent.info));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Returns both v1 and v2 info hashes along with the detected torrent version.
|
|
105
|
+
*/
|
|
106
|
+
export function hashes(file) {
|
|
107
|
+
const torrent = decode(file);
|
|
108
|
+
const { version, hasV2 } = detectVersion(torrent.info);
|
|
109
|
+
const encodedInfo = encode(torrent.info);
|
|
110
|
+
const result = {
|
|
111
|
+
infoHash: sha1(encodedInfo),
|
|
112
|
+
version,
|
|
113
|
+
};
|
|
114
|
+
if (hasV2) {
|
|
115
|
+
result.infoHashV2 = sha256(encodedInfo);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
25
119
|
/**
|
|
26
120
|
* data about the files the torrent contains
|
|
27
121
|
*/
|
|
28
122
|
export function files(file) {
|
|
29
123
|
const torrent = decode(file);
|
|
124
|
+
const { version, hasV1, hasV2 } = detectVersion(torrent.info);
|
|
30
125
|
const result = {
|
|
31
126
|
files: [],
|
|
32
127
|
length: 0,
|
|
33
128
|
lastPieceLength: 0,
|
|
34
129
|
pieceLength: torrent.info['piece length'],
|
|
35
|
-
|
|
130
|
+
version,
|
|
36
131
|
};
|
|
37
|
-
const files = torrent.info.files || [torrent.info];
|
|
38
132
|
const name = toString(torrent.info['name.utf-8'] || torrent.info.name);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
133
|
+
if (hasV2 && !hasV1) {
|
|
134
|
+
// v2-only: file tree is the only source of file info
|
|
135
|
+
const flatFiles = flattenFileTree(torrent.info['file tree'], []);
|
|
136
|
+
let offset = 0;
|
|
137
|
+
result.files = flatFiles.map(f => {
|
|
138
|
+
const parts = [name, ...f.path];
|
|
139
|
+
const entry = {
|
|
140
|
+
path: parts.join(sep),
|
|
141
|
+
name: parts[parts.length - 1],
|
|
142
|
+
length: f.length,
|
|
143
|
+
offset,
|
|
144
|
+
piecesRoot: f['pieces root'] ? toHex(f['pieces root']) : undefined,
|
|
145
|
+
};
|
|
146
|
+
offset += f.length;
|
|
147
|
+
return entry;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// v1 or hybrid: use traditional file list
|
|
152
|
+
const fileList = torrent.info.files || [torrent.info];
|
|
153
|
+
let offset = 0;
|
|
154
|
+
result.files = fileList.map((f) => {
|
|
155
|
+
const parts = [name, ...(f['path.utf-8'] || f.path || [])].map(p => toString(p));
|
|
156
|
+
const entry = {
|
|
157
|
+
path: parts.join(sep),
|
|
158
|
+
name: parts[parts.length - 1],
|
|
159
|
+
length: f.length,
|
|
160
|
+
offset,
|
|
161
|
+
};
|
|
162
|
+
offset += f.length;
|
|
163
|
+
return entry;
|
|
164
|
+
});
|
|
165
|
+
// For hybrid: attach piecesRoot from the file tree, matched by path.
|
|
166
|
+
// Can't match by index because v1 may contain padding files that v2 doesn't.
|
|
167
|
+
if (hasV2 && torrent.info['file tree']) {
|
|
168
|
+
const flatFiles = flattenFileTree(torrent.info['file tree'], []);
|
|
169
|
+
const v2ByPath = new Map();
|
|
170
|
+
for (const ff of flatFiles) {
|
|
171
|
+
if (ff['pieces root']) {
|
|
172
|
+
v2ByPath.set(ff.path.join(sep), ff['pieces root']);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const prefix = name + sep;
|
|
176
|
+
for (const file of result.files) {
|
|
177
|
+
// v1 paths include the torrent name prefix; v2 file tree paths don't
|
|
178
|
+
const relativePath = file.path.startsWith(prefix)
|
|
179
|
+
? file.path.slice(prefix.length)
|
|
180
|
+
: file.path;
|
|
181
|
+
const root = v2ByPath.get(relativePath);
|
|
182
|
+
if (root) {
|
|
183
|
+
file.piecesRoot = toHex(root);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
result.pieces = splitPieces(torrent.info.pieces, 20);
|
|
188
|
+
}
|
|
189
|
+
result.length = result.files.reduce(sumLength, 0);
|
|
49
190
|
const lastFile = result.files[result.files.length - 1];
|
|
50
191
|
result.lastPieceLength =
|
|
51
192
|
(lastFile && (lastFile.offset + lastFile.length) % result.pieceLength) || result.pieceLength;
|
|
52
|
-
|
|
193
|
+
// Parse piece layers (v2/hybrid)
|
|
194
|
+
if (hasV2 && torrent['piece layers']) {
|
|
195
|
+
const pieceLayers = {};
|
|
196
|
+
for (const key of Object.keys(torrent['piece layers'])) {
|
|
197
|
+
const hexKey = latin1KeyToHex(key);
|
|
198
|
+
const value = torrent['piece layers'][key];
|
|
199
|
+
if (value instanceof Uint8Array) {
|
|
200
|
+
pieceLayers[hexKey] = splitPieces(value, 32);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
result.pieceLayers = pieceLayers;
|
|
204
|
+
}
|
|
53
205
|
return result;
|
|
54
206
|
}
|
|
55
207
|
function sumLength(sum, file) {
|
|
56
208
|
return sum + file.length;
|
|
57
209
|
}
|
|
58
|
-
function splitPieces(buf) {
|
|
59
|
-
const pieces = [];
|
|
60
|
-
for (let i = 0; i < buf.length; i += 20) {
|
|
61
|
-
pieces.push(uint8ArrayToHex(buf.slice(i, i + 20)));
|
|
62
|
-
}
|
|
63
|
-
return pieces;
|
|
64
|
-
}
|
|
65
210
|
/**
|
|
66
211
|
* torrent file info
|
|
67
212
|
*/
|
|
68
213
|
export function info(file) {
|
|
69
214
|
const torrent = decode(file);
|
|
215
|
+
const { version } = detectVersion(torrent.info);
|
|
70
216
|
const result = {
|
|
71
217
|
name: toString(torrent.info['name.utf-8'] || torrent.info.name),
|
|
72
218
|
announce: [],
|
|
73
219
|
urlList: [],
|
|
220
|
+
version,
|
|
74
221
|
};
|
|
75
222
|
if (torrent.info.private !== undefined) {
|
|
76
223
|
result.private = Boolean(torrent.info.private);
|
|
@@ -97,17 +244,57 @@ export function info(file) {
|
|
|
97
244
|
else if (torrent.announce) {
|
|
98
245
|
result.announce.push(toString(torrent.announce));
|
|
99
246
|
}
|
|
100
|
-
if (result.announce.length) {
|
|
101
|
-
result.announce =
|
|
247
|
+
if (result.announce.length > 0) {
|
|
248
|
+
result.announce = [...new Set(result.announce)];
|
|
102
249
|
}
|
|
103
250
|
// web seeds
|
|
104
|
-
if (
|
|
251
|
+
if (torrent['url-list'] instanceof Uint8Array) {
|
|
105
252
|
// some clients set url-list to empty string
|
|
106
253
|
torrent['url-list'] = torrent['url-list'].length > 0 ? [torrent['url-list']] : [];
|
|
107
254
|
}
|
|
108
255
|
result.urlList = (torrent['url-list'] || []).map((url) => toString(url));
|
|
109
|
-
if (result.urlList.length) {
|
|
110
|
-
result.urlList =
|
|
256
|
+
if (result.urlList.length > 0) {
|
|
257
|
+
result.urlList = [...new Set(result.urlList)];
|
|
111
258
|
}
|
|
112
259
|
return result;
|
|
113
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Convert a parsed torrent object back into a .torrent file buffer.
|
|
263
|
+
*/
|
|
264
|
+
export function toTorrentFile(parsed) {
|
|
265
|
+
const torrent = {
|
|
266
|
+
info: parsed.info,
|
|
267
|
+
};
|
|
268
|
+
// announce list (BEP-12)
|
|
269
|
+
const announce = parsed.announce || [];
|
|
270
|
+
if (announce.length > 0) {
|
|
271
|
+
torrent['announce-list'] = announce.map(url => {
|
|
272
|
+
if (!torrent.announce) {
|
|
273
|
+
torrent.announce = url;
|
|
274
|
+
}
|
|
275
|
+
return [url];
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// piece layers (BEP-52)
|
|
279
|
+
if (parsed.pieceLayers && Object.keys(parsed.pieceLayers).length > 0) {
|
|
280
|
+
torrent['piece layers'] = parsed.pieceLayers;
|
|
281
|
+
}
|
|
282
|
+
// url-list (BEP-19 / web seeds)
|
|
283
|
+
if (parsed.urlList && parsed.urlList.length > 0) {
|
|
284
|
+
torrent['url-list'] = [...parsed.urlList];
|
|
285
|
+
}
|
|
286
|
+
// Private flag lives inside info dict
|
|
287
|
+
if (parsed.private !== undefined) {
|
|
288
|
+
torrent.info = { ...torrent.info, private: Number(parsed.private) };
|
|
289
|
+
}
|
|
290
|
+
if (parsed.created) {
|
|
291
|
+
torrent['creation date'] = Math.floor(parsed.created.getTime() / 1000);
|
|
292
|
+
}
|
|
293
|
+
if (parsed.createdBy) {
|
|
294
|
+
torrent['created by'] = parsed.createdBy;
|
|
295
|
+
}
|
|
296
|
+
if (parsed.comment) {
|
|
297
|
+
torrent.comment = parsed.comment;
|
|
298
|
+
}
|
|
299
|
+
return encode(torrent);
|
|
300
|
+
}
|
package/package.json
CHANGED
|
@@ -1,69 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ctrl/torrent-file",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "Parse a torrent file (name, hash, files, pieces)",
|
|
5
|
-
"
|
|
5
|
+
"keywords": [],
|
|
6
|
+
"homepage": "https://torrent-file.pages.dev",
|
|
6
7
|
"license": "MIT",
|
|
8
|
+
"author": "Scott Cooper <scttcper@gmail.com>",
|
|
7
9
|
"repository": {
|
|
8
10
|
"type": "git",
|
|
9
11
|
"url": "git+https://github.com/scttcper/torrent-file.git"
|
|
10
12
|
},
|
|
11
|
-
"type": "module",
|
|
12
|
-
"exports": "./dist/src/index.js",
|
|
13
|
-
"types": "./dist/src/index.d.ts",
|
|
14
13
|
"files": [
|
|
15
14
|
"dist/src"
|
|
16
15
|
],
|
|
16
|
+
"type": "module",
|
|
17
17
|
"sideEffects": false,
|
|
18
|
-
"
|
|
18
|
+
"types": "./dist/src/index.d.ts",
|
|
19
|
+
"exports": "./dist/src/index.js",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"provenance": true
|
|
23
|
+
},
|
|
19
24
|
"scripts": {
|
|
20
|
-
"lint": "oxlint . &&
|
|
21
|
-
"lint:fix": "oxlint . --fix &&
|
|
22
|
-
"prepare": "
|
|
25
|
+
"lint": "oxlint . && oxfmt --check",
|
|
26
|
+
"lint:fix": "oxlint . --fix && oxfmt",
|
|
27
|
+
"prepare": "pnpm run build",
|
|
23
28
|
"build": "tsc",
|
|
24
29
|
"test": "vitest run",
|
|
25
30
|
"test:watch": "vitest",
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"uint8array-extras": "^1.5.0"
|
|
31
|
+
"bench": "vitest bench ./bench/index.bench.ts",
|
|
32
|
+
"demo:watch": "pnpm -C demo dev",
|
|
33
|
+
"demo:build": "pnpm -C demo build"
|
|
30
34
|
},
|
|
35
|
+
"dependencies": {},
|
|
31
36
|
"devDependencies": {
|
|
32
|
-
"@ctrl/oxlint-config": "1.
|
|
33
|
-
"@sindresorhus/tsconfig": "8.0
|
|
34
|
-
"@
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"parse-torrent": "11.0.18",
|
|
39
|
-
"prettier": "3.6.2",
|
|
37
|
+
"@ctrl/oxlint-config": "1.4.0",
|
|
38
|
+
"@sindresorhus/tsconfig": "8.1.0",
|
|
39
|
+
"@types/node": "25.2.3",
|
|
40
|
+
"oxfmt": "0.33.0",
|
|
41
|
+
"oxlint": "1.48.0",
|
|
42
|
+
"parse-torrent": "11.0.19",
|
|
40
43
|
"typescript": "5.9.3",
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
"prettier": {
|
|
44
|
-
"singleQuote": true,
|
|
45
|
-
"trailingComma": "all",
|
|
46
|
-
"arrowParens": "avoid",
|
|
47
|
-
"semi": true,
|
|
48
|
-
"printWidth": 100,
|
|
49
|
-
"plugins": [
|
|
50
|
-
"@trivago/prettier-plugin-sort-imports"
|
|
51
|
-
],
|
|
52
|
-
"importOrder": [
|
|
53
|
-
"^node:.*$",
|
|
54
|
-
"<THIRD_PARTY_MODULES>",
|
|
55
|
-
"^(@ctrl)(/.*|$)",
|
|
56
|
-
"^\\.\\./(?!.*\\.css$)",
|
|
57
|
-
"^\\./(?!.*\\.css$)(?=.*/)",
|
|
58
|
-
"^\\./(?!.*\\.css$)(?!.*/)"
|
|
59
|
-
],
|
|
60
|
-
"importOrderSeparation": true,
|
|
61
|
-
"importOrderSortSpecifiers": false
|
|
62
|
-
},
|
|
63
|
-
"packageManager": "pnpm@10.18.0",
|
|
64
|
-
"publishConfig": {
|
|
65
|
-
"access": "public",
|
|
66
|
-
"provenance": true
|
|
44
|
+
"uint8array-extras": "^1.5.0",
|
|
45
|
+
"vitest": "4.0.18"
|
|
67
46
|
},
|
|
68
47
|
"release": {
|
|
69
48
|
"branches": [
|
|
@@ -71,6 +50,7 @@
|
|
|
71
50
|
]
|
|
72
51
|
},
|
|
73
52
|
"engines": {
|
|
74
|
-
"node": "
|
|
75
|
-
}
|
|
53
|
+
"node": ">20"
|
|
54
|
+
},
|
|
55
|
+
"packageManager": "pnpm@10.29.3"
|
|
76
56
|
}
|