@ctrl/torrent-file 4.3.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 CHANGED
@@ -1,8 +1,12 @@
1
- # torrent-file [![npm](https://badgen.net/npm/v/@ctrl/torrent-file)](https://www.npmjs.com/package/@ctrl/torrent-file) [![coverage](https://badgen.net/codecov/c/github/scttcper/torrent-file)](https://codecov.io/gh/scttcper/torrent-file) [![bundlesize](https://badgen.net/bundlephobia/min/@ctrl/torrent-file)](https://bundlephobia.com/result?p=@ctrl/torrent-file)
1
+ # torrent-file [![npm](https://badgen.net/npm/v/@ctrl/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
- 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`.
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
- data about the files described in the torrent file, includes hashes of the pieces
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
- sha1 of torrent file info. This hash is commenly used by torrent clients as the ID of the torrent. It is async and sha1 encoding is handled by [crypto-hash](https://github.com/sindresorhus/crypto-hash)
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
- (async () => {
51
- const torrentHash = await hash(fs.readFileSync('myfile'));
52
- console.log({ torrentHash });
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) - "@ctrl/torrent-file" torrent parsing based very heavily off this project
59
- [node-bencode](https://github.com/themasch/node-bencode) - bencoder built into this project heavily based off this project
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,65 +1,28 @@
1
- import { stringToUint8Array, uint8ArrayToString } from 'uint8array-extras';
2
- import { isValidUTF8 } from './utils.js';
3
- const td = new TextDecoder();
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();
4
10
  class Decoder {
5
11
  idx = 0;
6
12
  buf;
7
13
  constructor(buf) {
8
14
  this.buf = buf;
9
15
  }
10
- readByte() {
11
- if (this.idx >= this.buf.length) {
12
- return null;
13
- }
14
- return String.fromCharCode(this.buf[this.idx++]);
15
- }
16
- readBytes(length) {
17
- if (this.idx + length > this.buf.length) {
18
- throw new Error(`could not read ${length} bytes, insufficient content`);
19
- }
20
- const result = this.buf.slice(this.idx, this.idx + length);
21
- this.idx += length;
22
- return result;
23
- }
24
- readUntil(char) {
25
- const targetIdx = this.buf.indexOf(char.charCodeAt(0), this.idx);
26
- if (targetIdx === -1) {
27
- throw new Error(`could not find terminated char: ${char}`);
28
- }
29
- const result = this.buf.slice(this.idx, targetIdx);
30
- this.idx = targetIdx;
31
- return result;
32
- }
33
- readNumber() {
34
- const buf = this.readUntil(':');
35
- return parseInt(uint8ArrayToString(buf), 10);
36
- }
37
- peekByte() {
38
- if (this.idx >= this.buf.length) {
39
- return '';
40
- }
41
- const result = this.readByte();
42
- if (result === null) {
43
- return '';
44
- }
45
- this.idx--;
46
- return result;
47
- }
48
- assertByte(expected) {
49
- const b = this.readByte();
50
- if (b !== expected) {
51
- throw new Error(`expecte ${expected}, got ${b}`);
52
- }
53
- }
54
16
  next() {
55
- switch (this.peekByte()) {
56
- case 'd': {
17
+ const byte = this.buf[this.idx];
18
+ switch (byte) {
19
+ case CHAR_d: {
57
20
  return this.nextDictionary();
58
21
  }
59
- case 'l': {
22
+ case CHAR_l: {
60
23
  return this.nextList();
61
24
  }
62
- case 'i': {
25
+ case CHAR_i: {
63
26
  return this.nextNumber();
64
27
  }
65
28
  default: {
@@ -68,53 +31,84 @@ class Decoder {
68
31
  }
69
32
  }
70
33
  nextBufOrString() {
71
- const length = this.readNumber();
72
- this.assertByte(':');
73
- const buf = this.readBytes(length);
74
- return isValidUTF8(buf) ? td.decode(buf) : buf;
34
+ const length = this.readLength();
35
+ const result = this.buf.subarray(this.idx, this.idx + length);
36
+ this.idx += length;
37
+ return result;
75
38
  }
76
- nextString() {
77
- const length = this.readNumber();
78
- this.assertByte(':');
79
- return td.decode(this.readBytes(length));
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
+ }
80
49
  }
81
50
  nextNumber() {
82
- this.assertByte('i');
83
- const content = td.decode(this.readUntil('e'));
84
- this.assertByte('e');
85
- const result = Number(content);
86
- if (isNaN(result)) {
87
- throw new Error(`not a number: ${content}`);
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);
88
64
  }
89
- return result;
90
65
  }
91
66
  nextList() {
92
- this.assertByte('l');
67
+ this.idx++; // skip 'l'
93
68
  const result = [];
94
- while (this.peekByte() !== 'e') {
69
+ while (this.buf[this.idx] !== CHAR_e) {
95
70
  result.push(this.next());
96
71
  }
97
- this.assertByte('e');
72
+ this.idx++; // skip 'e'
98
73
  return result;
99
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
+ }
100
88
  nextDictionary() {
101
- this.assertByte('d');
89
+ this.idx++; // skip 'd'
102
90
  const result = {};
103
- while (this.peekByte() !== 'e') {
104
- result[this.nextString()] = this.next();
91
+ while (this.buf[this.idx] !== CHAR_e) {
92
+ result[this.nextKeyLatin1()] = this.next();
105
93
  }
106
- this.assertByte('e');
94
+ this.idx++; // skip 'e'
107
95
  return result;
108
96
  }
109
97
  }
110
98
  export const decode = (payload) => {
111
99
  let buf;
112
100
  if (typeof payload === 'string') {
113
- buf = stringToUint8Array(payload);
101
+ buf = te.encode(payload);
114
102
  }
115
103
  else if (payload instanceof ArrayBuffer) {
116
104
  buf = new Uint8Array(payload);
117
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
+ }
118
112
  else if ('buffer' in payload) {
119
113
  buf = new Uint8Array(payload.buffer);
120
114
  }
@@ -2,4 +2,3 @@ export type bencodeValue = string | Uint8Array | number | {
2
2
  [key: string]: bencodeValue;
3
3
  } | bencodeValue[];
4
4
  export declare const encode: (data: bencodeValue | bencodeValue[]) => Uint8Array;
5
- export default encode;
@@ -1,53 +1,77 @@
1
- import { concatUint8Arrays, stringToUint8Array } from 'uint8array-extras';
2
- import { cmpRawString } from './utils.js';
3
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
+ }
4
19
  const encodeString = (str) => {
5
- const lengthBytes = new TextEncoder().encode(str.length.toString());
6
20
  const content = te.encode(str);
7
- const result = new Uint8Array(lengthBytes.byteLength + 1 + content.byteLength);
8
- result.set(lengthBytes);
9
- result.set(te.encode(':'), lengthBytes.byteLength);
10
- result.set(content, lengthBytes.byteLength + 1);
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);
11
26
  return result;
12
27
  };
13
28
  const encodeBuf = (buf) => {
14
- const lengthBytes = new TextEncoder().encode(buf.byteLength.toString());
15
- const result = new Uint8Array(lengthBytes.byteLength + 1 + buf.byteLength);
16
- result.set(lengthBytes);
17
- result.set(te.encode(':'), lengthBytes.byteLength);
18
- result.set(buf, lengthBytes.byteLength + 1);
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);
19
34
  return result;
20
35
  };
21
36
  const encodeNumber = (num) => {
22
- // NOTE: only support integers
23
37
  const int = Math.floor(num);
24
38
  if (int !== num) {
25
39
  throw new Error(`bencode only support integers, got ${num}`);
26
40
  }
27
- return concatUint8Arrays([
28
- stringToUint8Array('i'),
29
- stringToUint8Array(int.toString()),
30
- stringToUint8Array('e'),
31
- ]);
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;
32
54
  };
33
55
  const encodeDictionary = (obj) => {
34
- const results = [];
35
- Object.keys(obj)
36
- .sort(cmpRawString)
37
- .forEach(key => {
38
- results.push(encodeString(key));
39
- results.push(new Uint8Array(encode(obj[key])));
40
- });
41
- const d = stringToUint8Array('d');
42
- const e = stringToUint8Array('e');
43
- return concatUint8Arrays([d, ...results, e]);
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);
44
66
  };
45
67
  const encodeArray = (arr) => {
46
- const prefixSuffix = te.encode('le'); // Combined prefix and suffix
47
- const encodedElements = arr.map(encode); // Encode each element
48
- // Concatenate the encoded elements directly into a Uint8Array
49
- const result = concatUint8Arrays([prefixSuffix, ...encodedElements.flat()]);
50
- return result;
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);
51
75
  };
52
76
  export const encode = (data) => {
53
77
  if (Array.isArray(data)) {
@@ -71,4 +95,3 @@ export const encode = (data) => {
71
95
  }
72
96
  }
73
97
  };
74
- export default encode;
@@ -1,3 +1 @@
1
1
  export declare const cmpRawString: (str1: string, str2: string) => number;
2
- export declare const typedArraysAreEqual: <T extends Uint8Array>(a: T, b: T) => boolean;
3
- export declare const isValidUTF8: (buf: Uint8Array) => boolean;
@@ -18,73 +18,3 @@ export const cmpRawString = (str1, str2) => {
18
18
  }
19
19
  return v1.length < v2.length ? -1 : 1;
20
20
  };
21
- export const typedArraysAreEqual = (a, b) => {
22
- if (a.byteLength !== b.byteLength) {
23
- return false;
24
- }
25
- return a.every((val, i) => val === b[i]);
26
- };
27
- // Copied from https://github.com/hcodes/isutf8/blob/master/src/index.ts
28
- /*
29
- https://tools.ietf.org/html/rfc3629
30
- UTF8-char = UTF8-1 / UTF8-2 / UTF8-3 / UTF8-4
31
- UTF8-1 = %x00-7F
32
- UTF8-2 = %xC2-DF UTF8-tail
33
- UTF8-3 = %xE0 %xA0-BF UTF8-tail
34
- %xE1-EC 2( UTF8-tail )
35
- %xED %x80-9F UTF8-tail
36
- %xEE-EF 2( UTF8-tail )
37
- UTF8-4 = %xF0 %x90-BF 2( UTF8-tail )
38
- %xF1-F3 3( UTF8-tail )
39
- %xF4 %x80-8F 2( UTF8-tail )
40
- UTF8-tail = %x80-BF
41
- */
42
- export const isValidUTF8 = (buf) => {
43
- let i = 0;
44
- const len = buf.length;
45
- while (i < len) {
46
- // UTF8-1 = %x00-7F
47
- if (buf[i] <= 0x7f) {
48
- i++;
49
- continue;
50
- }
51
- // UTF8-2 = %xC2-DF UTF8-tail
52
- if (buf[i] >= 0xc2 && buf[i] <= 0xdf) {
53
- // if(buf[i + 1] >= 0x80 && buf[i + 1] <= 0xBF) {
54
- if (buf[i + 1] >> 6 === 2) {
55
- i += 2;
56
- continue;
57
- }
58
- return false;
59
- }
60
- // UTF8-3 = %xE0 %xA0-BF UTF8-tail
61
- // UTF8-3 = %xED %x80-9F UTF8-tail
62
- if (((buf[i] === 0xe0 && buf[i + 1] >= 0xa0 && buf[i + 1] <= 0xbf) ||
63
- (buf[i] === 0xed && buf[i + 1] >= 0x80 && buf[i + 1] <= 0x9f)) &&
64
- buf[i + 2] >> 6 === 2) {
65
- i += 3;
66
- continue;
67
- }
68
- // UTF8-3 = %xE1-EC 2( UTF8-tail )
69
- // UTF8-3 = %xEE-EF 2( UTF8-tail )
70
- if (((buf[i] >= 0xe1 && buf[i] <= 0xec) || (buf[i] >= 0xee && buf[i] <= 0xef)) &&
71
- buf[i + 1] >> 6 === 2 &&
72
- buf[i + 2] >> 6 === 2) {
73
- i += 3;
74
- continue;
75
- }
76
- // UTF8-4 = %xF0 %x90-BF 2( UTF8-tail )
77
- // %xF1-F3 3( UTF8-tail )
78
- // %xF4 %x80-8F 2( UTF8-tail )
79
- if (((buf[i] === 0xf0 && buf[i + 1] >= 0x90 && buf[i + 1] <= 0xbf) ||
80
- (buf[i] >= 0xf1 && buf[i] <= 0xf3 && buf[i + 1] >> 6 === 2) ||
81
- (buf[i] === 0xf4 && buf[i + 1] >= 0x80 && buf[i + 1] <= 0x8f)) &&
82
- buf[i + 2] >> 6 === 2 &&
83
- buf[i + 3] >> 6 === 2) {
84
- i += 4;
85
- continue;
86
- }
87
- return false;
88
- }
89
- return true;
90
- };
@@ -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
- * sha1 of torrent file info. This hash is commenly used by torrent clients as the ID of the torrent.
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
- pieces: string[];
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;
@@ -1,69 +1,223 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { join, sep } from 'node:path';
3
- import { isUint8Array, uint8ArrayToHex, uint8ArrayToString } from 'uint8array-extras';
2
+ import { sep } from 'node:path';
4
3
  import { decode, encode } from './bencode/index.js';
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
+ }
17
+ const toString = (value) => {
18
+ if (value instanceof Uint8Array) {
19
+ return td.decode(value);
20
+ }
21
+ return value.toString();
22
+ };
5
23
  export const sha1 = (input) => {
6
24
  const hash = createHash('sha1');
7
- // Update the hash object with the data
8
25
  hash.update(input);
9
26
  return hash.digest('hex');
10
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
+ }
11
89
  /**
12
- * sha1 of torrent file info. This hash is commenly used by torrent clients as the ID of the torrent.
90
+ * sha1 of torrent file info. This hash is commonly used by torrent clients as the ID of the torrent.
13
91
  */
14
92
  export function hash(file) {
15
93
  const torrent = decode(file);
16
94
  return sha1(encode(torrent.info));
17
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
+ }
18
119
  /**
19
120
  * data about the files the torrent contains
20
121
  */
21
122
  export function files(file) {
22
123
  const torrent = decode(file);
124
+ const { version, hasV1, hasV2 } = detectVersion(torrent.info);
23
125
  const result = {
24
126
  files: [],
25
127
  length: 0,
26
128
  lastPieceLength: 0,
27
129
  pieceLength: torrent.info['piece length'],
28
- pieces: [],
130
+ version,
29
131
  };
30
- const files = torrent.info.files || [torrent.info];
31
- const name = (torrent.info['name.utf-8'] || torrent.info.name).toString();
32
- result.files = files.map((file, i) => {
33
- const parts = [name, ...(file['path.utf-8'] || file.path || [])].map(p => p.toString());
34
- return {
35
- path: join(sep, ...parts).slice(1),
36
- name: parts[parts.length - 1],
37
- length: file.length,
38
- offset: files.slice(0, i).reduce(sumLength, 0),
39
- };
40
- });
41
- result.length = files.reduce(sumLength, 0);
132
+ const name = toString(torrent.info['name.utf-8'] || torrent.info.name);
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);
42
190
  const lastFile = result.files[result.files.length - 1];
43
191
  result.lastPieceLength =
44
192
  (lastFile && (lastFile.offset + lastFile.length) % result.pieceLength) || result.pieceLength;
45
- result.pieces = splitPieces(torrent.info.pieces);
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
+ }
46
205
  return result;
47
206
  }
48
207
  function sumLength(sum, file) {
49
208
  return sum + file.length;
50
209
  }
51
- function splitPieces(buf) {
52
- const pieces = [];
53
- for (let i = 0; i < buf.length; i += 20) {
54
- pieces.push(uint8ArrayToHex(buf.slice(i, i + 20)));
55
- }
56
- return pieces;
57
- }
58
210
  /**
59
211
  * torrent file info
60
212
  */
61
213
  export function info(file) {
62
214
  const torrent = decode(file);
215
+ const { version } = detectVersion(torrent.info);
63
216
  const result = {
64
- name: (torrent.info['name.utf-8'] || torrent.info.name).toString(),
217
+ name: toString(torrent.info['name.utf-8'] || torrent.info.name),
65
218
  announce: [],
66
219
  urlList: [],
220
+ version,
67
221
  };
68
222
  if (torrent.info.private !== undefined) {
69
223
  result.private = Boolean(torrent.info.private);
@@ -72,10 +226,10 @@ export function info(file) {
72
226
  result.created = new Date(torrent['creation date'] * 1000);
73
227
  }
74
228
  if (torrent['created by']) {
75
- result.createdBy = torrent['created by'].toString();
229
+ result.createdBy = toString(torrent['created by']);
76
230
  }
77
- if (isUint8Array(torrent.comment)) {
78
- result.comment = uint8ArrayToString(torrent.comment);
231
+ if (torrent.comment) {
232
+ result.comment = toString(torrent.comment);
79
233
  }
80
234
  // announce and announce-list will be missing if metadata fetched via ut_metadata
81
235
  if (Array.isArray(torrent['announce-list']) &&
@@ -83,24 +237,64 @@ export function info(file) {
83
237
  torrent['announce-list'].length > 0) {
84
238
  torrent['announce-list'].forEach((urls) => {
85
239
  urls.forEach((url) => {
86
- result.announce.push(url.toString());
240
+ result.announce.push(toString(url));
87
241
  });
88
242
  });
89
243
  }
90
244
  else if (torrent.announce) {
91
- result.announce.push(torrent.announce.toString());
245
+ result.announce.push(toString(torrent.announce));
92
246
  }
93
- if (result.announce.length) {
94
- result.announce = Array.from(new Set(result.announce));
247
+ if (result.announce.length > 0) {
248
+ result.announce = [...new Set(result.announce)];
95
249
  }
96
250
  // web seeds
97
- if (isUint8Array(torrent['url-list'])) {
251
+ if (torrent['url-list'] instanceof Uint8Array) {
98
252
  // some clients set url-list to empty string
99
253
  torrent['url-list'] = torrent['url-list'].length > 0 ? [torrent['url-list']] : [];
100
254
  }
101
- result.urlList = (torrent['url-list'] || []).map((url) => url.toString());
102
- if (result.urlList.length) {
103
- result.urlList = Array.from(new Set(result.urlList));
255
+ result.urlList = (torrent['url-list'] || []).map((url) => toString(url));
256
+ if (result.urlList.length > 0) {
257
+ result.urlList = [...new Set(result.urlList)];
104
258
  }
105
259
  return result;
106
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.0",
3
+ "version": "4.5.0",
4
4
  "description": "Parse a torrent file (name, hash, files, pieces)",
5
- "author": "Scott Cooper <scttcper@gmail.com>",
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
- "keywords": [],
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 . && prettier --check . --experimental-cli",
21
- "lint:fix": "oxlint . --fix && prettier --write . --log-level=error --experimental-cli",
22
- "prepare": "npm run build",
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
- "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=./junit.xml"
27
- },
28
- "dependencies": {
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.2.7",
33
- "@sindresorhus/tsconfig": "8.0.1",
34
- "@trivago/prettier-plugin-sort-imports": "5.2.2",
35
- "@types/node": "24.6.2",
36
- "@vitest/coverage-v8": "3.2.4",
37
- "oxlint": "1.19.0",
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
- "vitest": "3.2.4"
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": ">=18"
75
- }
53
+ "node": ">20"
54
+ },
55
+ "packageManager": "pnpm@10.29.3"
76
56
  }