gnutella 1.0.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/CLI.md +189 -0
- package/DEVELOPER.md +193 -0
- package/LICENSE +674 -0
- package/QUICKSTART.md +133 -0
- package/README.md +74 -0
- package/bin/gnutella.ts +15 -0
- package/gnutella.json.example +18 -0
- package/package.json +72 -0
- package/src/cli.ts +692 -0
- package/src/cli_shared.ts +359 -0
- package/src/const.ts +138 -0
- package/src/gwebcache/bootstrap.ts +491 -0
- package/src/gwebcache/response.ts +391 -0
- package/src/gwebcache/shared.ts +116 -0
- package/src/gwebcache/types.ts +187 -0
- package/src/gwebcache_client.ts +13 -0
- package/src/protocol/browse_host.ts +552 -0
- package/src/protocol/client_blocking.ts +29 -0
- package/src/protocol/codec.ts +715 -0
- package/src/protocol/content_urn.ts +170 -0
- package/src/protocol/core_utils.ts +43 -0
- package/src/protocol/file_server.ts +245 -0
- package/src/protocol/ggep.ts +168 -0
- package/src/protocol/handshake.ts +199 -0
- package/src/protocol/http_download_reader.ts +112 -0
- package/src/protocol/magnet.ts +176 -0
- package/src/protocol/node.ts +416 -0
- package/src/protocol/node_handshake.ts +992 -0
- package/src/protocol/node_lifecycle.ts +210 -0
- package/src/protocol/node_protocol_runtime.ts +949 -0
- package/src/protocol/node_qrp_runtime.ts +97 -0
- package/src/protocol/node_query_routing.ts +208 -0
- package/src/protocol/node_state.ts +745 -0
- package/src/protocol/node_tls.ts +257 -0
- package/src/protocol/node_topology.ts +141 -0
- package/src/protocol/node_transfer.ts +455 -0
- package/src/protocol/node_types.ts +106 -0
- package/src/protocol/peer_state.ts +675 -0
- package/src/protocol/qrp.ts +549 -0
- package/src/protocol/query_search.ts +29 -0
- package/src/protocol/share_index.ts +131 -0
- package/src/protocol/share_library.ts +246 -0
- package/src/protocol.ts +36 -0
- package/src/shared.ts +236 -0
- package/src/types.ts +452 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CANONICAL_HEADER_NAMES,
|
|
5
|
+
INTERESTING_HANDSHAKE_HEADERS,
|
|
6
|
+
} from "../const";
|
|
7
|
+
import {
|
|
8
|
+
ipv4Subnet16,
|
|
9
|
+
isRoutableIpv4,
|
|
10
|
+
normalizeIpv4,
|
|
11
|
+
parsePeer,
|
|
12
|
+
} from "../shared";
|
|
13
|
+
import type { PeerAddr } from "../types";
|
|
14
|
+
|
|
15
|
+
export function hasToken(
|
|
16
|
+
value: string | undefined,
|
|
17
|
+
token: string,
|
|
18
|
+
): boolean {
|
|
19
|
+
if (!value) return false;
|
|
20
|
+
return value
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((x) => x.trim().toLowerCase())
|
|
23
|
+
.includes(token.toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseBoolHeader(
|
|
27
|
+
v: string | undefined,
|
|
28
|
+
): boolean | undefined {
|
|
29
|
+
if (v == null) return undefined;
|
|
30
|
+
const s = v.trim().toLowerCase();
|
|
31
|
+
if (s === "true") return true;
|
|
32
|
+
if (s === "false") return false;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parsePositiveIntHeader(
|
|
37
|
+
v: string | undefined,
|
|
38
|
+
): number | undefined {
|
|
39
|
+
if (!v) return undefined;
|
|
40
|
+
const n = Number(v.trim());
|
|
41
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parsePeerHeaderList(v: string | undefined): PeerAddr[] {
|
|
45
|
+
if (!v) return [];
|
|
46
|
+
return v
|
|
47
|
+
.split(",")
|
|
48
|
+
.map((x) => parsePeer(x.trim()))
|
|
49
|
+
.filter((x): x is PeerAddr => !!x);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseListenIpHeader(
|
|
53
|
+
v: string | undefined,
|
|
54
|
+
): PeerAddr | undefined {
|
|
55
|
+
if (!v) return undefined;
|
|
56
|
+
return parsePeer(v.trim()) || undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseRemoteIpHeader(v: string | undefined): string | undefined {
|
|
60
|
+
if (!v) return undefined;
|
|
61
|
+
return normalizeIpv4(v.split(",")[0]?.trim());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function lowerCaseHeaders(
|
|
65
|
+
headers: Record<string, string>,
|
|
66
|
+
): Record<string, string> {
|
|
67
|
+
const out: Record<string, string> = {};
|
|
68
|
+
for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mergeHeaders(
|
|
73
|
+
...parts: Array<Record<string, string> | undefined>
|
|
74
|
+
): Record<string, string> {
|
|
75
|
+
const out: Record<string, string> = {};
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
if (!part) continue;
|
|
78
|
+
for (const [k, v] of Object.entries(part)) out[k.toLowerCase()] = v;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function findHeaderEnd(raw: string): number {
|
|
84
|
+
const crlf = raw.indexOf("\r\n\r\n");
|
|
85
|
+
const lf = raw.indexOf("\n\n");
|
|
86
|
+
if (crlf !== -1 && (lf === -1 || crlf < lf)) return crlf + 4;
|
|
87
|
+
if (lf !== -1) return lf + 2;
|
|
88
|
+
return -1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function appendContinuationLine(
|
|
92
|
+
headers: Record<string, string>,
|
|
93
|
+
current: string,
|
|
94
|
+
line: string,
|
|
95
|
+
): boolean {
|
|
96
|
+
if (!/^[ \t]/.test(line) || !current) return false;
|
|
97
|
+
headers[current] = `${headers[current]} ${line.trim()}`.trim();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function recordHeaderLine(
|
|
102
|
+
headers: Record<string, string>,
|
|
103
|
+
line: string,
|
|
104
|
+
): string {
|
|
105
|
+
const idx = line.indexOf(":");
|
|
106
|
+
if (idx === -1) return "";
|
|
107
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
108
|
+
const value = line.slice(idx + 1).trim();
|
|
109
|
+
headers[key] = headers[key] ? `${headers[key]},${value}` : value;
|
|
110
|
+
return key;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseHandshakeBlock(raw: string): {
|
|
114
|
+
startLine: string;
|
|
115
|
+
headers: Record<string, string>;
|
|
116
|
+
} {
|
|
117
|
+
const end = findHeaderEnd(raw);
|
|
118
|
+
const text = end === -1 ? raw : raw.slice(0, end);
|
|
119
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
120
|
+
const startLine = lines.shift()?.trim() || "";
|
|
121
|
+
const headers: Record<string, string> = {};
|
|
122
|
+
let current = "";
|
|
123
|
+
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (!line) continue;
|
|
126
|
+
if (appendContinuationLine(headers, current, line)) continue;
|
|
127
|
+
current = recordHeaderLine(headers, line);
|
|
128
|
+
}
|
|
129
|
+
return { startLine, headers };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function canonicalHeaderName(key: string): string {
|
|
133
|
+
return CANONICAL_HEADER_NAMES[key.toLowerCase()] || key;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildHandshakeBlock(
|
|
137
|
+
startLine: string,
|
|
138
|
+
headers: Record<string, string>,
|
|
139
|
+
): Buffer {
|
|
140
|
+
const lines = [startLine];
|
|
141
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
142
|
+
lines.push(`${canonicalHeaderName(k)}: ${v}`);
|
|
143
|
+
}
|
|
144
|
+
lines.push("", "");
|
|
145
|
+
return Buffer.from(lines.join("\r\n"), "latin1");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function summarizeHandshakeHeaders(
|
|
149
|
+
headers: Record<string, string>,
|
|
150
|
+
): string {
|
|
151
|
+
const parts = INTERESTING_HANDSHAKE_HEADERS.filter(
|
|
152
|
+
(key) => !!headers[key],
|
|
153
|
+
).map((key) => `${canonicalHeaderName(key)}=${headers[key]}`);
|
|
154
|
+
return parts.length ? ` [${parts.join("; ")}]` : "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function describeHandshakeResponse(
|
|
158
|
+
startLine: string,
|
|
159
|
+
headers: Record<string, string>,
|
|
160
|
+
): string {
|
|
161
|
+
return `${startLine}${summarizeHandshakeHeaders(headers)}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function observedAdvertisedHostCandidate(
|
|
165
|
+
headers: Record<string, string>,
|
|
166
|
+
reporterHost?: string,
|
|
167
|
+
): { observedHost: string; subnet: string } | undefined {
|
|
168
|
+
const observedHost = parseRemoteIpHeader(
|
|
169
|
+
headers["remote-ip"] || headers["x-remote-ip"],
|
|
170
|
+
);
|
|
171
|
+
const reporter = normalizeIpv4(reporterHost);
|
|
172
|
+
if (!observedHost || !reporter) return undefined;
|
|
173
|
+
if (!isRoutableIpv4(observedHost) || !isRoutableIpv4(reporter))
|
|
174
|
+
return undefined;
|
|
175
|
+
const subnet = ipv4Subnet16(reporter);
|
|
176
|
+
if (!subnet) return undefined;
|
|
177
|
+
return { observedHost, subnet };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function socketCanEnd(socket: net.Socket): boolean {
|
|
181
|
+
return (
|
|
182
|
+
!socket.destroyed &&
|
|
183
|
+
!(socket as net.Socket & { writableEnded?: boolean }).writableEnded &&
|
|
184
|
+
!(socket as net.Socket & { ended?: boolean }).ended
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function parseHttpHeaders(raw: string): Record<string, string> {
|
|
189
|
+
const out: Record<string, string> = {};
|
|
190
|
+
const lines = raw.replace(/\r\n/g, "\n").split("\n");
|
|
191
|
+
for (const line of lines.slice(1)) {
|
|
192
|
+
const idx = line.indexOf(":");
|
|
193
|
+
if (idx === -1) continue;
|
|
194
|
+
out[line.slice(0, idx).trim().toLowerCase()] = line
|
|
195
|
+
.slice(idx + 1)
|
|
196
|
+
.trim();
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { errMsg } from "../shared";
|
|
2
|
+
import type { HttpDownloadState } from "./node_types";
|
|
3
|
+
|
|
4
|
+
type HttpDownloadSourceHandlers = {
|
|
5
|
+
onChunk: (chunk: Buffer) => void;
|
|
6
|
+
onEnd: () => void;
|
|
7
|
+
onError: (error: unknown) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ReadHttpDownloadSourceArgs = {
|
|
11
|
+
attach: (handlers: HttpDownloadSourceHandlers) => () => void;
|
|
12
|
+
consumeChunk: (
|
|
13
|
+
state: HttpDownloadState,
|
|
14
|
+
destPath: string,
|
|
15
|
+
requestedStart: number,
|
|
16
|
+
onWriteError: (error: Error) => void,
|
|
17
|
+
chunk: Buffer,
|
|
18
|
+
) => void;
|
|
19
|
+
destPath: string;
|
|
20
|
+
destroyOnFailure?: () => void;
|
|
21
|
+
incompleteMessage: string;
|
|
22
|
+
label: string;
|
|
23
|
+
requestedStart: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function toReadError(error: unknown): Error {
|
|
27
|
+
return error instanceof Error ? error : new Error(errMsg(error));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readHttpDownloadSource({
|
|
31
|
+
attach,
|
|
32
|
+
consumeChunk,
|
|
33
|
+
destPath,
|
|
34
|
+
destroyOnFailure,
|
|
35
|
+
incompleteMessage,
|
|
36
|
+
label,
|
|
37
|
+
requestedStart,
|
|
38
|
+
}: ReadHttpDownloadSourceArgs): Promise<unknown> {
|
|
39
|
+
return await new Promise((resolve, reject) => {
|
|
40
|
+
const state: HttpDownloadState = {
|
|
41
|
+
buf: Buffer.alloc(0),
|
|
42
|
+
headerDone: false,
|
|
43
|
+
remaining: 0,
|
|
44
|
+
ws: null,
|
|
45
|
+
finalStart: requestedStart,
|
|
46
|
+
bodyBytes: 0,
|
|
47
|
+
};
|
|
48
|
+
let done = false;
|
|
49
|
+
const onWriteError = (error: Error) => fail(error);
|
|
50
|
+
const detach = attach({
|
|
51
|
+
onChunk: (chunk) => {
|
|
52
|
+
if (done) return;
|
|
53
|
+
try {
|
|
54
|
+
consumeChunk(
|
|
55
|
+
state,
|
|
56
|
+
destPath,
|
|
57
|
+
requestedStart,
|
|
58
|
+
onWriteError,
|
|
59
|
+
chunk,
|
|
60
|
+
);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
fail(error);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (state.headerDone && state.remaining === 0) finish();
|
|
66
|
+
},
|
|
67
|
+
onEnd: () => {
|
|
68
|
+
if (!done && state.headerDone && state.remaining === 0) finish();
|
|
69
|
+
else if (!done) fail(new Error(incompleteMessage));
|
|
70
|
+
},
|
|
71
|
+
onError: (error) => fail(error),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const cleanup = () => {
|
|
75
|
+
detach();
|
|
76
|
+
state.ws?.off("error", onWriteError);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const fail = (error: unknown) => {
|
|
80
|
+
if (done) return;
|
|
81
|
+
done = true;
|
|
82
|
+
cleanup();
|
|
83
|
+
try {
|
|
84
|
+
state.ws?.destroy();
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
destroyOnFailure?.();
|
|
90
|
+
} catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
reject(toReadError(error));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const finish = () => {
|
|
97
|
+
if (done) return;
|
|
98
|
+
done = true;
|
|
99
|
+
cleanup();
|
|
100
|
+
const meta = {
|
|
101
|
+
destPath,
|
|
102
|
+
bytes: state.finalStart + state.bodyBytes,
|
|
103
|
+
label,
|
|
104
|
+
};
|
|
105
|
+
if (!state.ws) {
|
|
106
|
+
resolve(meta);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
state.ws.end(() => resolve(meta));
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { firstSha1Urn, normalizeUrnList } from "./content_urn";
|
|
2
|
+
|
|
3
|
+
type ParsedMagnet = {
|
|
4
|
+
uri: string;
|
|
5
|
+
displayName?: string;
|
|
6
|
+
search?: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
urns: string[];
|
|
9
|
+
sha1Urn?: string;
|
|
10
|
+
exactSources: string[];
|
|
11
|
+
alternateSources: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type MagnetFields = {
|
|
15
|
+
xt: string[];
|
|
16
|
+
xs: string[];
|
|
17
|
+
as: string[];
|
|
18
|
+
dn: string[];
|
|
19
|
+
kt: string[];
|
|
20
|
+
size: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type MagnetFieldKey = keyof MagnetFields;
|
|
24
|
+
|
|
25
|
+
const MAGNET_FIELD_BY_KEY: Record<string, MagnetFieldKey | undefined> = {
|
|
26
|
+
xt: "xt",
|
|
27
|
+
xs: "xs",
|
|
28
|
+
as: "as",
|
|
29
|
+
dn: "dn",
|
|
30
|
+
kt: "kt",
|
|
31
|
+
xl: "size",
|
|
32
|
+
sz: "size",
|
|
33
|
+
fs: "size",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function emptyMagnetFields(): MagnetFields {
|
|
37
|
+
return {
|
|
38
|
+
xt: [],
|
|
39
|
+
xs: [],
|
|
40
|
+
as: [],
|
|
41
|
+
dn: [],
|
|
42
|
+
kt: [],
|
|
43
|
+
size: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizedMagnetKey(key: string): string {
|
|
48
|
+
return key.toLowerCase().split(".", 1)[0] || "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positiveInteger(value: string): number | undefined {
|
|
52
|
+
if (!/^\d+$/.test(value)) return undefined;
|
|
53
|
+
const parsed = Number(value);
|
|
54
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pickFirst(values: string[]): string | undefined {
|
|
58
|
+
return values.find((value) => value.length > 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseMagnetParams(raw: string):
|
|
62
|
+
| {
|
|
63
|
+
uri: string;
|
|
64
|
+
params: URLSearchParams;
|
|
65
|
+
}
|
|
66
|
+
| undefined {
|
|
67
|
+
const uri = raw.trim();
|
|
68
|
+
if (!/^magnet:\?/i.test(uri)) return undefined;
|
|
69
|
+
return {
|
|
70
|
+
uri,
|
|
71
|
+
params: new URLSearchParams(uri.slice(uri.indexOf("?") + 1)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function appendMagnetField(
|
|
76
|
+
fields: MagnetFields,
|
|
77
|
+
rawKey: string,
|
|
78
|
+
rawValue: string,
|
|
79
|
+
): void {
|
|
80
|
+
const key = MAGNET_FIELD_BY_KEY[normalizedMagnetKey(rawKey)];
|
|
81
|
+
const value = rawValue.trim();
|
|
82
|
+
if (!value || !key) return;
|
|
83
|
+
fields[key].push(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readMagnetFields(params: URLSearchParams): MagnetFields {
|
|
87
|
+
const fields = emptyMagnetFields();
|
|
88
|
+
for (const [rawKey, rawValue] of params.entries()) {
|
|
89
|
+
appendMagnetField(fields, rawKey, rawValue);
|
|
90
|
+
}
|
|
91
|
+
return fields;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parsedMagnetSize(fields: MagnetFields): number | undefined {
|
|
95
|
+
return fields.size
|
|
96
|
+
.map((value) => positiveInteger(value))
|
|
97
|
+
.find((value) => value != null);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hasMagnetPayload(
|
|
101
|
+
fields: MagnetFields,
|
|
102
|
+
urns: string[],
|
|
103
|
+
displayName: string | undefined,
|
|
104
|
+
search: string | undefined,
|
|
105
|
+
size: number | undefined,
|
|
106
|
+
): boolean {
|
|
107
|
+
return !!(
|
|
108
|
+
urns.length ||
|
|
109
|
+
displayName ||
|
|
110
|
+
search ||
|
|
111
|
+
size != null ||
|
|
112
|
+
fields.xs.length ||
|
|
113
|
+
fields.as.length
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function preferredMagnetUrn(urns: string[]): string | undefined {
|
|
118
|
+
return (
|
|
119
|
+
urns.find((urn) => urn.toLowerCase().startsWith("urn:bitprint:")) ||
|
|
120
|
+
urns.find((urn) => urn.toLowerCase().startsWith("urn:sha1:")) ||
|
|
121
|
+
urns[0]
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function appendMagnetSize(
|
|
126
|
+
params: URLSearchParams,
|
|
127
|
+
fileSize: number | undefined,
|
|
128
|
+
): void {
|
|
129
|
+
if (fileSize == null || !Number.isFinite(fileSize) || fileSize < 0) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
params.set("xl", String(Math.floor(fileSize)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseMagnetUri(raw: string): ParsedMagnet | undefined {
|
|
136
|
+
const parsed = parseMagnetParams(raw);
|
|
137
|
+
if (!parsed) return undefined;
|
|
138
|
+
const fields = readMagnetFields(parsed.params);
|
|
139
|
+
const urns = normalizeUrnList(fields.xt);
|
|
140
|
+
const displayName = pickFirst(fields.dn);
|
|
141
|
+
const search = pickFirst(fields.kt) || displayName;
|
|
142
|
+
const size = parsedMagnetSize(fields);
|
|
143
|
+
if (!hasMagnetPayload(fields, urns, displayName, search, size))
|
|
144
|
+
return undefined;
|
|
145
|
+
const sha1Urn = firstSha1Urn(urns);
|
|
146
|
+
return {
|
|
147
|
+
uri: parsed.uri,
|
|
148
|
+
...(displayName ? { displayName } : {}),
|
|
149
|
+
...(search ? { search } : {}),
|
|
150
|
+
...(size != null ? { size } : {}),
|
|
151
|
+
urns,
|
|
152
|
+
...(sha1Urn ? { sha1Urn } : {}),
|
|
153
|
+
exactSources: fields.xs,
|
|
154
|
+
alternateSources: fields.as,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function buildMagnetUri(input: {
|
|
159
|
+
fileName?: string;
|
|
160
|
+
fileSize?: number;
|
|
161
|
+
search?: string;
|
|
162
|
+
urns?: string[];
|
|
163
|
+
sha1Urn?: string;
|
|
164
|
+
}): string {
|
|
165
|
+
const params = new URLSearchParams();
|
|
166
|
+
const urns = normalizeUrnList([
|
|
167
|
+
...(input.urns || []),
|
|
168
|
+
...(input.sha1Urn ? [input.sha1Urn] : []),
|
|
169
|
+
]);
|
|
170
|
+
const primaryUrn = preferredMagnetUrn(urns);
|
|
171
|
+
if (primaryUrn) params.set("xt", primaryUrn);
|
|
172
|
+
appendMagnetSize(params, input.fileSize);
|
|
173
|
+
if (input.fileName) params.set("dn", input.fileName);
|
|
174
|
+
else if (input.search) params.set("kt", input.search);
|
|
175
|
+
return `magnet:?${params.toString()}`;
|
|
176
|
+
}
|