dsh-dl-server 0.3.1 → 0.4.1
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 +34 -7
- package/README.zh.md +34 -7
- package/lib/headers.js +138 -0
- package/lib/queue.js +894 -876
- package/lib/tools.js +16 -1
- package/lib/transport.js +67 -12
- package/lib/types.js +1 -0
- package/package.json +2 -2
package/lib/tools.js
CHANGED
|
@@ -80,6 +80,16 @@ export function registerTools({ ctx, queue, config }) {
|
|
|
80
80
|
type: 'string',
|
|
81
81
|
description: 'Optional expected SHA-256. The job fails if the finished file does not match.',
|
|
82
82
|
},
|
|
83
|
+
headers: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
description:
|
|
86
|
+
'Optional HTTP request headers sent with every attempt, including mirror probes and ' +
|
|
87
|
+
'resumes. Use this for CDNs that refuse a bare request — Bilibili media needs ' +
|
|
88
|
+
'{"Referer": "https://www.bilibili.com"}, some hosts want a Cookie or User-Agent. ' +
|
|
89
|
+
'Range, Host, Content-Length and similar transport headers cannot be set. ' +
|
|
90
|
+
'Values are stored locally but never echoed back: job output lists header names only.',
|
|
91
|
+
additionalProperties: { type: 'string' },
|
|
92
|
+
},
|
|
83
93
|
},
|
|
84
94
|
required: ['urls', 'dest'],
|
|
85
95
|
additionalProperties: false,
|
|
@@ -87,7 +97,12 @@ export function registerTools({ ctx, queue, config }) {
|
|
|
87
97
|
async execute(args, exec) {
|
|
88
98
|
try {
|
|
89
99
|
const job = await queue.enqueue(
|
|
90
|
-
{
|
|
100
|
+
{
|
|
101
|
+
urls: args.urls,
|
|
102
|
+
destPath: args.dest,
|
|
103
|
+
expectSha256: args.sha256,
|
|
104
|
+
headers: args.headers,
|
|
105
|
+
},
|
|
91
106
|
withCaller(exec),
|
|
92
107
|
)
|
|
93
108
|
return {
|
package/lib/transport.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Readable } from 'node:stream'
|
|
|
7
7
|
import { pipeline } from 'node:stream/promises'
|
|
8
8
|
import { randomBytes } from 'node:crypto'
|
|
9
9
|
import { sameResource } from './mirrors.js'
|
|
10
|
+
import { toAria2Headers } from './headers.js'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Transport layer. Two channels: an aria2c JSON-RPC daemon when one is
|
|
@@ -23,6 +24,8 @@ import { sameResource } from './mirrors.js'
|
|
|
23
24
|
* @property {number} [speedLimitKbps] 0 or absent means unlimited
|
|
24
25
|
* @property {string} [proxy] e.g. http://127.0.0.1:65532
|
|
25
26
|
* @property {number} [bytesDone] resume hint for the direct channel
|
|
27
|
+
* @property {Record<string, string>} [headers] custom HTTP headers sent with
|
|
28
|
+
* every attempt (probe, aria2, direct)
|
|
26
29
|
* @property {(url: string, ok: boolean) => void} [onUrlResult]
|
|
27
30
|
* @property {(done: number, total: number) => void} [onProgress]
|
|
28
31
|
* @property {AbortSignal} [signal]
|
|
@@ -57,6 +60,30 @@ export function planTransfer(sizeBytes, cap = 16) {
|
|
|
57
60
|
const IS_WIN = process.platform === 'win32'
|
|
58
61
|
const ARIA2_BIN = IS_WIN ? 'aria2c.exe' : 'aria2c'
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The command that installs aria2 on this platform.
|
|
65
|
+
*
|
|
66
|
+
* Missing aria2 is the single most common fresh-install problem: the queue keeps
|
|
67
|
+
* working through the single-stream fallback, so nothing fails loudly and the
|
|
68
|
+
* user just concludes that DLS is slow. Every surface that mentions the fallback
|
|
69
|
+
* quotes this string, so the advice cannot drift from the detection code.
|
|
70
|
+
*
|
|
71
|
+
* @param {NodeJS.Platform} [platform]
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function aria2InstallHint(platform = process.platform) {
|
|
75
|
+
switch (platform) {
|
|
76
|
+
case 'win32':
|
|
77
|
+
return 'winget install aria2.aria2 (或从 https://github.com/aria2/aria2/releases 下载)'
|
|
78
|
+
case 'darwin':
|
|
79
|
+
return 'brew install aria2'
|
|
80
|
+
case 'linux':
|
|
81
|
+
return 'sudo apt install aria2 (Fedora: sudo dnf install aria2,Arch: sudo pacman -S aria2)'
|
|
82
|
+
default:
|
|
83
|
+
return '安装 aria2 并把 aria2c 放进 PATH'
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
/** Absolute locations worth checking beyond PATH. */
|
|
61
88
|
function extraCandidates() {
|
|
62
89
|
if (IS_WIN) {
|
|
@@ -193,17 +220,36 @@ export class Transport {
|
|
|
193
220
|
this.config = config
|
|
194
221
|
this.log = log
|
|
195
222
|
this.mode = 'direct'
|
|
223
|
+
this.aria2Missing = false
|
|
196
224
|
this.aria2 = undefined
|
|
197
225
|
this.rpc = undefined
|
|
198
226
|
/** @type {Map<string, { verified: string[], rejected: { url: string, reason: string }[] }>} */
|
|
199
227
|
this.mirrorReports = new Map()
|
|
200
228
|
}
|
|
201
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Channel snapshot for `download_status`. When aria2 is missing the view
|
|
232
|
+
* carries the install hint so an agent reading a slow transfer can tell the
|
|
233
|
+
* user what to fix instead of guessing.
|
|
234
|
+
*/
|
|
235
|
+
modeInfo() {
|
|
236
|
+
if (this.aria2Missing) {
|
|
237
|
+
return { mode: 'direct', aria2Missing: true, hint: aria2InstallHint() }
|
|
238
|
+
}
|
|
239
|
+
return { mode: this.mode }
|
|
240
|
+
}
|
|
241
|
+
|
|
202
242
|
/** Detect aria2 and start the daemon if possible. Never throws. */
|
|
203
243
|
async init() {
|
|
204
244
|
const binary = await findAria2(this.config.aria2Path)
|
|
205
245
|
if (!binary) {
|
|
206
|
-
this.
|
|
246
|
+
this.aria2Missing = true
|
|
247
|
+
// warn, not info: this is the difference between 136 MB/s and 13 MB/s and
|
|
248
|
+
// the fallback is silent otherwise.
|
|
249
|
+
this.log(
|
|
250
|
+
'warn',
|
|
251
|
+
`aria2c 未找到,回退到单流模式(速度约为并发的 1/10)。安装:${aria2InstallHint()}`,
|
|
252
|
+
)
|
|
207
253
|
return
|
|
208
254
|
}
|
|
209
255
|
|
|
@@ -250,11 +296,12 @@ export class Transport {
|
|
|
250
296
|
|
|
251
297
|
/** @param {TransferOptions} options @returns {Promise<TransferResult>} */
|
|
252
298
|
async transfer(options) {
|
|
253
|
-
const
|
|
299
|
+
const headers = options.headers
|
|
300
|
+
const origin = await this.#probeHead(options.urls[0], options.signal, headers)
|
|
254
301
|
const plan = planTransfer(origin.length, options.connectionsCap ?? options.connections ?? this.config.connectionsPerJob ?? 16)
|
|
255
302
|
const connections = options.connections ?? plan.connections
|
|
256
303
|
|
|
257
|
-
const urls = await this.#verifiedUrls(options.urls, origin, options.signal)
|
|
304
|
+
const urls = await this.#verifiedUrls(options.urls, origin, options.signal, headers)
|
|
258
305
|
const useAria2 = this.mode === 'aria2' && this.rpc && !options.preferDirect && !plan.direct
|
|
259
306
|
|
|
260
307
|
if (useAria2) {
|
|
@@ -278,8 +325,9 @@ export class Transport {
|
|
|
278
325
|
* @param {string[]} urls
|
|
279
326
|
* @param {{ length: number, etag?: string }} origin
|
|
280
327
|
* @param {AbortSignal} [signal]
|
|
328
|
+
* @param {Record<string, string>} [headers]
|
|
281
329
|
*/
|
|
282
|
-
async #verifiedUrls(urls, origin, signal) {
|
|
330
|
+
async #verifiedUrls(urls, origin, signal, headers) {
|
|
283
331
|
if (urls.length <= 1) return urls
|
|
284
332
|
|
|
285
333
|
const accepted = [urls[0]]
|
|
@@ -287,7 +335,7 @@ export class Transport {
|
|
|
287
335
|
const rejected = []
|
|
288
336
|
|
|
289
337
|
for (const mirror of urls.slice(1)) {
|
|
290
|
-
const head = await this.#probeHead(mirror, signal)
|
|
338
|
+
const head = await this.#probeHead(mirror, signal, headers)
|
|
291
339
|
const verdict = sameResource(origin, head)
|
|
292
340
|
if (verdict.ok) {
|
|
293
341
|
accepted.push(mirror)
|
|
@@ -310,9 +358,10 @@ export class Transport {
|
|
|
310
358
|
* planner stays conservative.
|
|
311
359
|
* @param {string} url
|
|
312
360
|
* @param {AbortSignal} [signal]
|
|
361
|
+
* @param {Record<string, string>} [headers] needed for CDNs that 403 a bare HEAD
|
|
313
362
|
* @returns {Promise<{ length: number, etag?: string }>}
|
|
314
363
|
*/
|
|
315
|
-
async #probeHead(url, signal) {
|
|
364
|
+
async #probeHead(url, signal, headers) {
|
|
316
365
|
if (!url) return { length: -1 }
|
|
317
366
|
const controller = new AbortController()
|
|
318
367
|
const timer = setTimeout(() => controller.abort(), 5000)
|
|
@@ -321,7 +370,7 @@ export class Transport {
|
|
|
321
370
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
322
371
|
|
|
323
372
|
try {
|
|
324
|
-
const response = await fetch(url, { method: 'HEAD', signal: controller.signal })
|
|
373
|
+
const response = await fetch(url, { method: 'HEAD', headers, signal: controller.signal })
|
|
325
374
|
if (!response.ok) return { length: -1 }
|
|
326
375
|
const length = Number(response.headers.get('content-length') ?? 0)
|
|
327
376
|
const etag = response.headers.get('etag') ?? undefined
|
|
@@ -335,7 +384,7 @@ export class Transport {
|
|
|
335
384
|
}
|
|
336
385
|
|
|
337
386
|
/** @param {TransferOptions} options */
|
|
338
|
-
async #transferAria2({ urls, destPath, connections, speedLimitKbps, onProgress, signal }) {
|
|
387
|
+
async #transferAria2({ urls, destPath, connections, speedLimitKbps, headers, onProgress, signal }) {
|
|
339
388
|
const rpc = this.rpc
|
|
340
389
|
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
341
390
|
|
|
@@ -351,6 +400,10 @@ export class Transport {
|
|
|
351
400
|
}
|
|
352
401
|
const limit = speedLimitKbps ?? this.config.speedLimitKbps ?? 0
|
|
353
402
|
if (limit > 0) options['max-download-limit'] = `${limit}K`
|
|
403
|
+
// aria2 wants one "Name: Value" string per `--header`; it applies them to
|
|
404
|
+
// every URI of the download, which is what a mirror group needs.
|
|
405
|
+
const headerList = toAria2Headers(headers)
|
|
406
|
+
if (headerList) options.header = headerList
|
|
354
407
|
|
|
355
408
|
const gid = await rpc.call('aria2.addUri', [urls, options])
|
|
356
409
|
|
|
@@ -385,7 +438,7 @@ export class Transport {
|
|
|
385
438
|
}
|
|
386
439
|
|
|
387
440
|
/** @param {TransferOptions} options */
|
|
388
|
-
async #transferDirect({ urls, destPath, bytesDone = 0, onProgress, onUrlResult, signal }) {
|
|
441
|
+
async #transferDirect({ urls, destPath, bytesDone = 0, headers, onProgress, onUrlResult, signal }) {
|
|
389
442
|
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
390
443
|
const partPath = `${destPath}.part`
|
|
391
444
|
let lastError
|
|
@@ -399,10 +452,12 @@ export class Transport {
|
|
|
399
452
|
offset = bytesDone
|
|
400
453
|
}
|
|
401
454
|
|
|
402
|
-
|
|
403
|
-
|
|
455
|
+
// Custom headers first, then `range` — resume owns that one, and
|
|
456
|
+
// `normalizeHeaders` already rejects a caller-supplied Range.
|
|
457
|
+
const requestHeaders = { ...headers }
|
|
458
|
+
if (offset > 0) requestHeaders.range = `bytes=${offset}-`
|
|
404
459
|
|
|
405
|
-
const response = await fetch(url, { headers, signal })
|
|
460
|
+
const response = await fetch(url, { headers: requestHeaders, signal })
|
|
406
461
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
407
462
|
|
|
408
463
|
const ranged = response.status === 206
|
package/lib/types.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* @property {string} [origin] subagent name, display and audit only
|
|
13
13
|
* @property {string[]} urls
|
|
14
14
|
* @property {string} destPath
|
|
15
|
+
* @property {Record<string, string>} [headers] custom HTTP headers, sent on every attempt
|
|
15
16
|
* @property {JobState} state
|
|
16
17
|
* @property {PauseReason} [pauseReason]
|
|
17
18
|
* @property {number} createdAt
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dl-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "DLS — DSH Download Server: a global, session-aware, persistent download queue for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "node --test",
|
|
45
|
-
"check": "node --check lib/index.js && node --check lib/domain.js && node --check lib/types.js"
|
|
45
|
+
"check": "node --check lib/index.js && node --check lib/domain.js && node --check lib/types.js && node --check lib/headers.js"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"zod": "^4.4.3"
|