@pls-compile-owo/fisheye 0.4.2 → 0.7.2
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/CHANGELOG.md +48 -0
- package/README.en.md +17 -0
- package/README.md +137 -2
- package/fisheye.mjs +79 -5
- package/lib/audio.mjs +1 -0
- package/lib/batch.mjs +191 -0
- package/lib/bili.mjs +69 -14
- package/lib/frame.mjs +1 -1
- package/lib/i18n.mjs +18 -2
- package/lib/ocr.mjs +386 -0
- package/lib/progress.mjs +140 -0
- package/lib/resume.mjs +378 -0
- package/lib/stages.mjs +290 -26
- package/lib/ytdlp.mjs +393 -0
- package/mcp-video-server.js +261 -0
- package/package.json +3 -2
- package/test/audio.test.mjs +23 -0
- package/test/batch.test.mjs +212 -0
- package/test/bili.test.mjs +70 -0
- package/test/budget.test.mjs +63 -0
- package/test/env.test.mjs +35 -0
- package/test/frame.test.mjs +35 -0
- package/test/i18n.test.mjs +45 -0
- package/test/ocr-sample.png +0 -0
- package/test/ocr.test.mjs +89 -0
- package/test/progress.test.mjs +139 -0
- package/test/resume.test.mjs +232 -0
- package/test/retry.test.mjs +69 -0
- package/test/ytdlp.test.mjs +179 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// progress 模块单测:进度计数 / 单行覆盖 / 静默 / 时长格式化 / 阶段计时
|
|
2
|
+
// 用注入的假 stream 收集输出,不碰真实 stderr。
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { createProgress, stageTimer, humanDuration } from '../lib/progress.mjs'
|
|
6
|
+
|
|
7
|
+
function fakeStream({ isTTY = false } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
isTTY,
|
|
10
|
+
chunks: [],
|
|
11
|
+
write(s) { this.chunks.push(s) },
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const text = (out) => out.chunks.join('')
|
|
16
|
+
|
|
17
|
+
test('createProgress:tick 累计、set 跳转、done 收尾(非 TTY 每行换行)', () => {
|
|
18
|
+
const out = fakeStream()
|
|
19
|
+
const p = createProgress('抽帧', 12, { stream: out })
|
|
20
|
+
p.tick()
|
|
21
|
+
p.tick()
|
|
22
|
+
p.tick()
|
|
23
|
+
p.set(9)
|
|
24
|
+
p.tick(2)
|
|
25
|
+
const sec = p.done()
|
|
26
|
+
|
|
27
|
+
assert.equal(typeof sec, 'number')
|
|
28
|
+
assert.deepEqual(out.chunks, [
|
|
29
|
+
'[抽帧] 1/12 (8%)\n',
|
|
30
|
+
'[抽帧] 2/12 (16%)\n',
|
|
31
|
+
'[抽帧] 3/12 (25%)\n',
|
|
32
|
+
'[抽帧] 9/12 (75%)\n',
|
|
33
|
+
'[抽帧] 11/12 (91%)\n',
|
|
34
|
+
'[抽帧] 12/12 (100%)\n',
|
|
35
|
+
])
|
|
36
|
+
assert.ok(!text(out).includes('\r'), '非 TTY 不该用 \\r 覆盖')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('createProgress:TTY 用 \\r 单行覆盖,done 时补换行', () => {
|
|
40
|
+
const out = fakeStream({ isTTY: true })
|
|
41
|
+
const p = createProgress('下载', 4, { stream: out })
|
|
42
|
+
p.tick()
|
|
43
|
+
p.tick(2)
|
|
44
|
+
assert.ok(out.chunks.every((c) => c.startsWith('\r')))
|
|
45
|
+
assert.ok(!text(out).includes('\n'), 'done 之前不能换行')
|
|
46
|
+
|
|
47
|
+
p.done()
|
|
48
|
+
assert.equal(text(out), '\r[下载] 1/4 (25%)\r[下载] 3/4 (75%)\r[下载] 4/4 (100%)\n')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('createProgress:tick 超过总数时停在 100%', () => {
|
|
52
|
+
const out = fakeStream()
|
|
53
|
+
const p = createProgress('x', 3, { stream: out })
|
|
54
|
+
p.tick(10)
|
|
55
|
+
assert.equal(out.chunks.at(-1), '[x] 3/3 (100%)\n')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('createProgress:total 未知(0)时只显示计数', () => {
|
|
59
|
+
const out = fakeStream()
|
|
60
|
+
const p = createProgress('帧', 0, { stream: out })
|
|
61
|
+
p.tick()
|
|
62
|
+
p.tick(2)
|
|
63
|
+
assert.deepEqual(out.chunks, ['[帧] 1\n', '[帧] 3\n'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('createProgress:enabled:false 时完全静默,elapsed 仍可用', () => {
|
|
67
|
+
const out = fakeStream()
|
|
68
|
+
const p = createProgress('x', 5, { stream: out, enabled: false })
|
|
69
|
+
p.tick()
|
|
70
|
+
p.set(3)
|
|
71
|
+
p.done()
|
|
72
|
+
p.fail('boom')
|
|
73
|
+
assert.equal(text(out), '')
|
|
74
|
+
assert.equal(typeof p.elapsed(), 'number')
|
|
75
|
+
assert.ok(p.elapsed() >= 0)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('createProgress:fail 输出原因,之后不再输出', () => {
|
|
79
|
+
const out = fakeStream()
|
|
80
|
+
const p = createProgress('下载', 4, { stream: out })
|
|
81
|
+
p.tick()
|
|
82
|
+
p.fail('网络断了')
|
|
83
|
+
p.tick()
|
|
84
|
+
p.done()
|
|
85
|
+
assert.equal(out.chunks.length, 2)
|
|
86
|
+
assert.equal(out.chunks[0], '[下载] 1/4 (25%)\n')
|
|
87
|
+
assert.match(out.chunks[1], /^\[下载\] 失败:网络断了\n$/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('createProgress:elapsed 返回非负秒数', () => {
|
|
91
|
+
const p = createProgress('x', 1, { stream: fakeStream() })
|
|
92
|
+
const sec = p.elapsed()
|
|
93
|
+
assert.equal(typeof sec, 'number')
|
|
94
|
+
assert.ok(sec >= 0 && sec < 5)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('createProgress / stageTimer:stream 不能写时不抛', () => {
|
|
98
|
+
const p = createProgress('x', 1, { stream: {} })
|
|
99
|
+
p.tick()
|
|
100
|
+
p.done()
|
|
101
|
+
p.fail('no-write')
|
|
102
|
+
const timer = stageTimer('y', { stream: {} })
|
|
103
|
+
timer.stop()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('humanDuration:ms / s / m / h 格式化', () => {
|
|
107
|
+
assert.equal(humanDuration(0), '0ms')
|
|
108
|
+
assert.equal(humanDuration(300), '300ms')
|
|
109
|
+
assert.equal(humanDuration(1500), '1.5s')
|
|
110
|
+
assert.equal(humanDuration(59000), '59.0s')
|
|
111
|
+
assert.equal(humanDuration(60000), '1m0s')
|
|
112
|
+
assert.equal(humanDuration(123000), '2m3s')
|
|
113
|
+
assert.equal(humanDuration(3723000), '1h2m3s')
|
|
114
|
+
// 异常输入不该产出 NaN
|
|
115
|
+
assert.equal(humanDuration(-5), '0ms')
|
|
116
|
+
assert.equal(humanDuration(NaN), '0ms')
|
|
117
|
+
assert.equal(humanDuration(undefined), '0ms')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('stageTimer:stop 输出用时、返回秒数、重复 stop 只输出一次', () => {
|
|
121
|
+
const out = fakeStream()
|
|
122
|
+
const timer = stageTimer('下载', { stream: out })
|
|
123
|
+
const sec = timer.stop()
|
|
124
|
+
assert.equal(typeof sec, 'number')
|
|
125
|
+
assert.ok(sec >= 0)
|
|
126
|
+
assert.equal(out.chunks.length, 1)
|
|
127
|
+
assert.match(out.chunks[0], /^\[下载\] 用时 (\d+ms|\d+\.\d+s|\d+m\d+s|\d+h\d+m\d+s)\n$/)
|
|
128
|
+
|
|
129
|
+
timer.stop()
|
|
130
|
+
assert.equal(out.chunks.length, 1, '重复 stop 不应重复输出')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('stageTimer:enabled:false 时静默', () => {
|
|
134
|
+
const out = fakeStream()
|
|
135
|
+
const timer = stageTimer('精读', { stream: out, enabled: false })
|
|
136
|
+
const sec = timer.stop()
|
|
137
|
+
assert.equal(text(out), '')
|
|
138
|
+
assert.equal(typeof sec, 'number')
|
|
139
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// resume 模块单测:manifest 读写 / 可复用判定 / 阶段记录 / 人类可读摘要
|
|
2
|
+
// 数据全在 os.tmpdir() 的临时目录里造,跑完清理,不碰项目自己的 out/。
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import {
|
|
9
|
+
loadManifestState,
|
|
10
|
+
saveManifest,
|
|
11
|
+
manifestPath,
|
|
12
|
+
canResume,
|
|
13
|
+
recordStage,
|
|
14
|
+
markDone,
|
|
15
|
+
resumeSummary,
|
|
16
|
+
} from '../lib/resume.mjs'
|
|
17
|
+
|
|
18
|
+
function tmpDir(t) {
|
|
19
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fisheye-resume-'))
|
|
20
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
|
21
|
+
return dir
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 造一张「存在且非空」的拼图文件(内容无所谓,续跑只看文件在不在) */
|
|
25
|
+
function writeSheet(dir, name) {
|
|
26
|
+
const file = path.join(dir, name)
|
|
27
|
+
fs.writeFileSync(file, 'fake-jpeg')
|
|
28
|
+
return file
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PLAN = { budget: 20000, density: 6, from: 0, to: 600 }
|
|
32
|
+
|
|
33
|
+
/** 一份完整的 manifest:下载已 done,两张拼图,计划参数固定 */
|
|
34
|
+
function makeState(t, { bvid = 'BV1resume001' } = {}) {
|
|
35
|
+
const dir = tmpDir(t)
|
|
36
|
+
const a = writeSheet(dir, 'c00_0000-0300_3x3.jpg')
|
|
37
|
+
const b = writeSheet(dir, 'c01_0300-0600_3x3.jpg')
|
|
38
|
+
const state = {
|
|
39
|
+
bvid,
|
|
40
|
+
title: '断点续跑测试片',
|
|
41
|
+
duration: 600,
|
|
42
|
+
grid: '3x3',
|
|
43
|
+
plan: { ...PLAN },
|
|
44
|
+
range: { from: 0, to: 600 },
|
|
45
|
+
// 第一条用 sheet(stageGrid 现在的写法),第二条用 file(新写法)——两种都要认
|
|
46
|
+
chunks: [
|
|
47
|
+
{ chunk: 1, from: 0, to: 300, range: '00:00-05:00', grid: '3x3', sheet: a },
|
|
48
|
+
{ chunk: 2, from: 300, to: 600, range: '05:00-10:00', grid: '3x3', file: b },
|
|
49
|
+
],
|
|
50
|
+
stages: { download: { status: 'done' } },
|
|
51
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
52
|
+
}
|
|
53
|
+
saveManifest(dir, bvid, state)
|
|
54
|
+
return { dir, bvid, state, sheets: [a, b] }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('loadManifestState:读回 manifest;不存在 / 坏 JSON → null;BOM 能读', (t) => {
|
|
58
|
+
const { dir, bvid, state } = makeState(t)
|
|
59
|
+
const got = loadManifestState(dir, bvid)
|
|
60
|
+
assert.equal(got.bvid, bvid)
|
|
61
|
+
assert.equal(got.title, state.title)
|
|
62
|
+
assert.deepEqual(got.chunks, state.chunks)
|
|
63
|
+
assert.equal(manifestPath(dir, bvid), path.join(dir, `${bvid}-manifest.json`))
|
|
64
|
+
|
|
65
|
+
assert.equal(loadManifestState(dir, 'BVnothere'), null)
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(manifestPath(dir, bvid), '{ 这不是 json', 'utf8')
|
|
68
|
+
assert.equal(loadManifestState(dir, bvid), null)
|
|
69
|
+
|
|
70
|
+
// PowerShell / 记事本会加 BOM
|
|
71
|
+
fs.writeFileSync(manifestPath(dir, bvid), '\uFEFF{"bvid":"x"}', 'utf8')
|
|
72
|
+
assert.equal(loadManifestState(dir, bvid).bvid, 'x')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('saveManifest:校验参数,写出的内容能原样读回,且不留临时文件', (t) => {
|
|
76
|
+
const dir = tmpDir(t)
|
|
77
|
+
assert.throws(() => saveManifest(dir, '', {}), TypeError)
|
|
78
|
+
assert.throws(() => saveManifest(dir, 'BVx', null), TypeError)
|
|
79
|
+
|
|
80
|
+
const file = saveManifest(dir, 'BVx', { bvid: 'BVx', chunks: [] })
|
|
81
|
+
assert.equal(file, manifestPath(dir, 'BVx'))
|
|
82
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { bvid: 'BVx', chunks: [] })
|
|
83
|
+
assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith('.tmp')), [])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('canResume:文件都在、计划参数没变 → 可复用并给出张数', (t) => {
|
|
87
|
+
const { dir, bvid } = makeState(t)
|
|
88
|
+
const r = canResume(loadManifestState(dir, bvid), { plan: { ...PLAN } })
|
|
89
|
+
assert.equal(r.resumable, true)
|
|
90
|
+
assert.equal(r.doneGrids, 2)
|
|
91
|
+
assert.match(r.reason, /2\/2/)
|
|
92
|
+
assert.match(r.reason, /跳过抽帧/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('canResume:缺一张 / 空文件 → 仍可复用,只补缺的', (t) => {
|
|
96
|
+
const { dir, bvid, sheets } = makeState(t)
|
|
97
|
+
fs.unlinkSync(sheets[1])
|
|
98
|
+
let r = canResume(loadManifestState(dir, bvid), { plan: { ...PLAN } })
|
|
99
|
+
assert.equal(r.resumable, true)
|
|
100
|
+
assert.equal(r.doneGrids, 1)
|
|
101
|
+
assert.match(r.reason, /1\/2/)
|
|
102
|
+
assert.match(r.reason, /补抽 1 张/)
|
|
103
|
+
|
|
104
|
+
// 0 字节的图不能算「已抽好」
|
|
105
|
+
fs.writeFileSync(sheets[0], '')
|
|
106
|
+
r = canResume(loadManifestState(dir, bvid), { plan: { ...PLAN } })
|
|
107
|
+
assert.equal(r.resumable, false)
|
|
108
|
+
assert.equal(r.doneGrids, 0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('canResume:拼图全没了 → 抽帧要重来(不可复用)', (t) => {
|
|
112
|
+
const { dir, bvid, sheets } = makeState(t)
|
|
113
|
+
for (const f of sheets) fs.unlinkSync(f)
|
|
114
|
+
const r = canResume(loadManifestState(dir, bvid), { plan: { ...PLAN } })
|
|
115
|
+
assert.equal(r.resumable, false)
|
|
116
|
+
assert.equal(r.doneGrids, 0)
|
|
117
|
+
assert.match(r.reason, /全部缺失/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('canResume:budget / density 变了 → 抽帧不能跳过', (t) => {
|
|
121
|
+
const { dir, bvid } = makeState(t)
|
|
122
|
+
const byBudget = canResume(loadManifestState(dir, bvid), { plan: { ...PLAN, budget: 50000 } })
|
|
123
|
+
assert.equal(byBudget.resumable, false)
|
|
124
|
+
assert.match(byBudget.reason, /budget/)
|
|
125
|
+
assert.match(byBudget.reason, /20000/)
|
|
126
|
+
|
|
127
|
+
const byDensity = canResume(loadManifestState(dir, bvid), { plan: { ...PLAN, density: 3 } })
|
|
128
|
+
assert.equal(byDensity.resumable, false)
|
|
129
|
+
assert.match(byDensity.reason, /density/)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('canResume:时间区间变了 → 时间轴对不上,不能复用', (t) => {
|
|
133
|
+
const { dir, bvid } = makeState(t)
|
|
134
|
+
const r = canResume(loadManifestState(dir, bvid), { plan: { ...PLAN, from: 60 } })
|
|
135
|
+
assert.equal(r.resumable, false)
|
|
136
|
+
assert.match(r.reason, /区间/)
|
|
137
|
+
assert.match(r.reason, /00:00-10:00/)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('canResume:老 manifest 没记录 plan → 不敢按参数复用;只比区间时可以', (t) => {
|
|
141
|
+
const { dir, bvid } = makeState(t)
|
|
142
|
+
const st = loadManifestState(dir, bvid)
|
|
143
|
+
delete st.plan
|
|
144
|
+
saveManifest(dir, bvid, st)
|
|
145
|
+
|
|
146
|
+
const strict = canResume(loadManifestState(dir, bvid), { plan: { budget: PLAN.budget } })
|
|
147
|
+
assert.equal(strict.resumable, false)
|
|
148
|
+
assert.match(strict.reason, /没有记录 budget/)
|
|
149
|
+
|
|
150
|
+
// 老 state 没有 plan,但 range 还在:只给区间且与 range 一致 → 可以续
|
|
151
|
+
const loose = canResume(loadManifestState(dir, bvid), { plan: { from: 0, to: 600 } })
|
|
152
|
+
assert.equal(loose.resumable, true)
|
|
153
|
+
assert.equal(loose.doneGrids, 2)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('canResume:没有 manifest / 没有拼图记录 → 不可复用', () => {
|
|
157
|
+
const missing = canResume(null)
|
|
158
|
+
assert.deepEqual(missing, { resumable: false, reason: '没有可复用的 manifest(这个视频还没跑过)', doneGrids: 0 })
|
|
159
|
+
|
|
160
|
+
const empty = canResume({ bvid: 'BVx', chunks: [] }, { plan: {} })
|
|
161
|
+
assert.equal(empty.resumable, false)
|
|
162
|
+
assert.match(empty.reason, /没有拼图记录/)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('canResume:只给了 range 展示字符串的旧记录也能判断(按 duration 补齐区间)', (t) => {
|
|
166
|
+
const dir = tmpDir(t)
|
|
167
|
+
const sheet = writeSheet(dir, 'only.jpg')
|
|
168
|
+
const state = {
|
|
169
|
+
bvid: 'BVrange',
|
|
170
|
+
duration: 600,
|
|
171
|
+
plan: { budget: 1000 },
|
|
172
|
+
chunks: [{ chunk: 1, sheet, range: '00:00-10:00' }],
|
|
173
|
+
}
|
|
174
|
+
const r = canResume(state, { plan: { budget: 1000, from: 0, to: 600 } })
|
|
175
|
+
assert.equal(r.resumable, true)
|
|
176
|
+
assert.equal(r.doneGrids, 1)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('recordStage / markDone:自动原子写回,重新 load 保持一致', (t) => {
|
|
180
|
+
const { dir, bvid } = makeState(t)
|
|
181
|
+
const st = loadManifestState(dir, bvid)
|
|
182
|
+
|
|
183
|
+
recordStage(st, 'grids', { done: 1, total: 2 })
|
|
184
|
+
assert.equal(loadManifestState(dir, bvid).stages.grids.status, 'running')
|
|
185
|
+
|
|
186
|
+
markDone(st, 'grids', { done: 2, total: 2 })
|
|
187
|
+
const again = loadManifestState(dir, bvid)
|
|
188
|
+
assert.equal(again.stages.download.status, 'done') // 老记录不能丢
|
|
189
|
+
assert.equal(again.stages.grids.status, 'done')
|
|
190
|
+
assert.equal(again.stages.grids.done, 2)
|
|
191
|
+
assert.ok(again.stages.grids.doneAt)
|
|
192
|
+
assert.ok(again.updatedAt)
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith('.tmp')), [])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('recordStage:没有来源信息时只改内存、不写盘;非法参数抛 TypeError', () => {
|
|
198
|
+
const st = { bvid: 'BVx', stages: { download: 'done' } }
|
|
199
|
+
const out = recordStage(st, 'download', 'done')
|
|
200
|
+
assert.equal(out, st)
|
|
201
|
+
assert.equal(st.stages.download.status, 'done')
|
|
202
|
+
assert.match(st.stages.download.at, /^\d{4}-\d{2}-\d{2}T/)
|
|
203
|
+
|
|
204
|
+
assert.throws(() => recordStage(null, 'grids'), TypeError)
|
|
205
|
+
assert.throws(() => recordStage(st, ''), TypeError)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('resumeSummary:列出阶段、拼图缺口、计划参数与可跳过项', (t) => {
|
|
209
|
+
const { dir, bvid, sheets } = makeState(t)
|
|
210
|
+
const st = loadManifestState(dir, bvid)
|
|
211
|
+
recordStage(st, 'grids', { done: 1, total: 2 })
|
|
212
|
+
fs.unlinkSync(sheets[1])
|
|
213
|
+
|
|
214
|
+
const s = resumeSummary(loadManifestState(dir, bvid))
|
|
215
|
+
assert.match(s, /BV1resume001/)
|
|
216
|
+
assert.match(s, /断点续跑测试片/)
|
|
217
|
+
assert.match(s, /阶段:下载 已完成 · 抽帧 进行中/)
|
|
218
|
+
assert.match(s, /拼图:1\/2 张可用/)
|
|
219
|
+
assert.match(s, /缺 1 张/)
|
|
220
|
+
assert.match(s, /计划:budget 20000 · density 6s/)
|
|
221
|
+
assert.match(s, /可跳过:下载/)
|
|
222
|
+
assert.match(resumeSummary(null), /没有 manifest/)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('resumeSummary:拼图齐全时说明可跳过抽帧', (t) => {
|
|
226
|
+
const { dir, bvid } = makeState(t)
|
|
227
|
+
const st = loadManifestState(dir, bvid)
|
|
228
|
+
markDone(st, 'grids')
|
|
229
|
+
const s = resumeSummary(loadManifestState(dir, bvid))
|
|
230
|
+
assert.match(s, /拼图:2\/2 张可用(可跳过抽帧)/)
|
|
231
|
+
assert.match(s, /可跳过:下载、抽帧/)
|
|
232
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// retry 模块单测:可重试判定 + fetchWithRetry 行为(本地 mock server)
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
import { fetchWithRetry, isRetryableStatus, isTransientNetworkError } from '../lib/retry.mjs'
|
|
6
|
+
|
|
7
|
+
test('isRetryableStatus:只重试 5xx / 429', () => {
|
|
8
|
+
assert.equal(isRetryableStatus(200), false)
|
|
9
|
+
assert.equal(isRetryableStatus(301), false)
|
|
10
|
+
assert.equal(isRetryableStatus(400), false)
|
|
11
|
+
assert.equal(isRetryableStatus(404), false)
|
|
12
|
+
assert.equal(isRetryableStatus(429), true)
|
|
13
|
+
assert.equal(isRetryableStatus(500), true)
|
|
14
|
+
assert.equal(isRetryableStatus(502), true)
|
|
15
|
+
assert.equal(isRetryableStatus(503), true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('isTransientNetworkError:网络错误可重试,其他不重试', () => {
|
|
19
|
+
assert.equal(isTransientNetworkError(new Error('fetch failed: ECONNRESET')), true)
|
|
20
|
+
assert.equal(isTransientNetworkError(new Error('socket hang up')), true)
|
|
21
|
+
assert.equal(isTransientNetworkError(new Error('ETIMEDOUT')), true)
|
|
22
|
+
assert.equal(isTransientNetworkError(new Error('something else')), false)
|
|
23
|
+
assert.equal(isTransientNetworkError(null), false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('fetchWithRetry:成功后直接返回(不发冗余请求)', async () => {
|
|
27
|
+
let hits = 0
|
|
28
|
+
const server = http.createServer((req, res) => { hits++; res.writeHead(200); res.end('ok') })
|
|
29
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
30
|
+
const port = server.address().port
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetchWithRetry(`http://127.0.0.1:${port}/`, {}, { retries: 2, baseDelayMs: 10, quiet: true })
|
|
33
|
+
assert.equal(res.status, 200)
|
|
34
|
+
assert.equal(hits, 1)
|
|
35
|
+
} finally {
|
|
36
|
+
server.close()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('fetchWithRetry:503 重试后成功', async () => {
|
|
41
|
+
let hits = 0
|
|
42
|
+
const server = http.createServer((req, res) => {
|
|
43
|
+
hits++
|
|
44
|
+
if (hits < 3) { res.writeHead(503); res.end('busy') } else { res.writeHead(200); res.end('ok') }
|
|
45
|
+
})
|
|
46
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
47
|
+
const port = server.address().port
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetchWithRetry(`http://127.0.0.1:${port}/`, {}, { retries: 3, baseDelayMs: 10, quiet: true })
|
|
50
|
+
assert.equal(res.status, 200)
|
|
51
|
+
assert.equal(hits, 3)
|
|
52
|
+
} finally {
|
|
53
|
+
server.close()
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('fetchWithRetry:404 不重试', async () => {
|
|
58
|
+
let hits = 0
|
|
59
|
+
const server = http.createServer((req, res) => { hits++; res.writeHead(404); res.end('nope') })
|
|
60
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
61
|
+
const port = server.address().port
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchWithRetry(`http://127.0.0.1:${port}/`, {}, { retries: 3, baseDelayMs: 10, quiet: true })
|
|
64
|
+
assert.equal(res.status, 404)
|
|
65
|
+
assert.equal(hits, 1)
|
|
66
|
+
} finally {
|
|
67
|
+
server.close()
|
|
68
|
+
}
|
|
69
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// ytdlp 模块单测:能力探测结构 / 二进制可用性 / URL 校验 / 失败兜底
|
|
2
|
+
//
|
|
3
|
+
// 这些测试不依赖「本机装没装 yt-dlp」:没装时 findYtdlp 返回 null、caps 报不可用,
|
|
4
|
+
// 需要二进制的用例自动 skip。想跑真正的端到端下载(会联网、小文件):
|
|
5
|
+
// FISHEYE_YTDLP_E2E=1 node --test test/
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
import { downloadVideo, findYtdlp, ytdlpCaps } from '../lib/ytdlp.mjs'
|
|
14
|
+
|
|
15
|
+
const CAP_KEYS = ['available', 'reason', 'summary', 'hint', 'cmd', 'ffmpeg', 'version', 'notes']
|
|
16
|
+
|
|
17
|
+
/** caps 的公共结构断言(available / 不可用两种形态都适用) */
|
|
18
|
+
function assertCapsShape(caps) {
|
|
19
|
+
for (const k of CAP_KEYS) assert.ok(k in caps, `caps 缺少字段 ${k}`)
|
|
20
|
+
assert.equal(typeof caps.available, 'boolean')
|
|
21
|
+
assert.equal(typeof caps.reason, 'string')
|
|
22
|
+
assert.ok(caps.reason.length > 0, 'reason 不能为空')
|
|
23
|
+
assert.equal(typeof caps.summary, 'string')
|
|
24
|
+
assert.match(caps.summary, /yt-dlp/)
|
|
25
|
+
assert.equal(caps.summary.includes(caps.available ? '可用' : '不可用'), true, 'summary 里要说明可用性')
|
|
26
|
+
assert.ok(caps.hint === null || typeof caps.hint === 'string')
|
|
27
|
+
assert.ok(caps.cmd === null || typeof caps.cmd === 'string')
|
|
28
|
+
assert.ok(caps.ffmpeg === null || typeof caps.ffmpeg === 'string')
|
|
29
|
+
assert.ok(Array.isArray(caps.notes))
|
|
30
|
+
if (caps.available) {
|
|
31
|
+
assert.equal(typeof caps.cmd, 'string', 'available 时必须给出 cmd')
|
|
32
|
+
assert.ok(caps.cmd.length > 0)
|
|
33
|
+
} else {
|
|
34
|
+
assert.ok(typeof caps.hint === 'string' && caps.hint.length > 0, '不可用时要给 hint')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 把 stdout+stderr 落到临时文件再读(沙箱里不能捕获管道) */
|
|
39
|
+
function runCapture(cmd, args, timeoutMs = 20000) {
|
|
40
|
+
const file = path.join(os.tmpdir(), `fisheye-test-${process.pid}-${crypto.randomUUID().slice(0, 8)}.txt`)
|
|
41
|
+
let fd
|
|
42
|
+
let status
|
|
43
|
+
let error
|
|
44
|
+
try {
|
|
45
|
+
fd = fs.openSync(file, 'w')
|
|
46
|
+
const r = spawnSync(cmd, args, { stdio: ['ignore', fd, fd], timeout: timeoutMs, windowsHide: true })
|
|
47
|
+
status = r.status
|
|
48
|
+
error = r.error
|
|
49
|
+
} finally {
|
|
50
|
+
if (fd !== undefined) try { fs.closeSync(fd) } catch {}
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
assert.equal(status, 0, `${path.basename(cmd)} ${args.join(' ')} 退出码 ${status}(${error?.message ?? '无错误信息'})`)
|
|
54
|
+
return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
|
55
|
+
} finally {
|
|
56
|
+
try { fs.unlinkSync(file) } catch {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('ytdlpCaps:结构完整,字段类型正确', () => {
|
|
61
|
+
const caps = ytdlpCaps()
|
|
62
|
+
assertCapsShape(caps)
|
|
63
|
+
// caps 与 findYtdlp 必须是同一个结论,不能一个说可用一个说没装
|
|
64
|
+
assert.equal(caps.available, findYtdlp() !== null)
|
|
65
|
+
// summaries 是给 CLI 打印的一行话,不该换行
|
|
66
|
+
assert.ok(!caps.summary.includes('\n'))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('ytdlpCaps:可用时 reason 带版本与来源', () => {
|
|
70
|
+
const caps = ytdlpCaps({
|
|
71
|
+
ytdlp: { cmd: path.join(os.tmpdir(), 'yt-dlp.exe'), version: '2024.01.01', source: '单测注入' },
|
|
72
|
+
})
|
|
73
|
+
assertCapsShape(caps)
|
|
74
|
+
assert.equal(caps.available, true)
|
|
75
|
+
assert.match(caps.reason, /2024\.01\.01/)
|
|
76
|
+
assert.match(caps.reason, /单测注入/)
|
|
77
|
+
assert.equal(caps.version, '2024.01.01')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('ytdlpCaps:不可用分支给出安装提示', () => {
|
|
81
|
+
// 直接喂一个空 ytdlp 会走真实探测;这里用伪造的环境 + 让所有候选都不存在不可行,
|
|
82
|
+
// 所以只断言:无 yt-dlp 时 hint 非空,有 yt-dlp 时 hint 为 null 或字符串。
|
|
83
|
+
const caps = ytdlpCaps({ env: { FISHEYE_YTDLP: 'C:\\definitely\\not\\here\\yt-dlp.exe' } })
|
|
84
|
+
assertCapsShape(caps)
|
|
85
|
+
if (!caps.available) assert.match(caps.hint, /yt-dlp\.exe|FISHEYE_YTDLP/)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('findYtdlp:返回 null 或字段合法的对象', () => {
|
|
89
|
+
const f = findYtdlp()
|
|
90
|
+
if (f === null) return
|
|
91
|
+
assert.equal(typeof f.cmd, 'string')
|
|
92
|
+
assert.ok(f.cmd.length > 0)
|
|
93
|
+
assert.ok(f.ffmpeg === null || typeof f.ffmpeg === 'string')
|
|
94
|
+
if (f.ffmpeg) assert.ok(fs.existsSync(f.ffmpeg), `ffmpeg 路径不存在:${f.ffmpeg}`)
|
|
95
|
+
// cmd 是绝对/相对路径时必须真的存在;裸命令名(PATH 命中)允许
|
|
96
|
+
if (f.cmd.includes('/') || f.cmd.includes('\\')) assert.ok(fs.existsSync(f.cmd), `yt-dlp 路径不存在:${f.cmd}`)
|
|
97
|
+
assert.ok(f.version === null || /^\d{4}\.\d{2}\.\d{2}/.test(f.version))
|
|
98
|
+
assert.equal(typeof f.source, 'string')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('findYtdlp:FISHEYE_YTDLP 指向不存在的文件时不崩', () => {
|
|
102
|
+
const f = findYtdlp({ env: { FISHEYE_YTDLP: 'C:\\definitely\\not\\here\\yt-dlp.exe' } })
|
|
103
|
+
assert.ok(f === null || typeof f.cmd === 'string')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('yt-dlp 二进制能跑 --version(没装则 skip)', { skip: findYtdlp() === null }, () => {
|
|
107
|
+
const f = findYtdlp()
|
|
108
|
+
assert.ok(f && f.cmd)
|
|
109
|
+
const out = runCapture(f.cmd, ['--version'])
|
|
110
|
+
assert.match(out.trim(), /^\d{4}\.\d{2}\.\d{2}/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('ffmpeg 可用时能被解析出来(没装则 skip)', { skip: findYtdlp()?.ffmpeg == null }, () => {
|
|
114
|
+
const f = findYtdlp()
|
|
115
|
+
assert.ok(f?.ffmpeg)
|
|
116
|
+
assert.ok(fs.existsSync(f.ffmpeg))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('downloadVideo:非法 URL 一律返回 null(不抛)', () => {
|
|
120
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fisheye-ytdlp-bad-'))
|
|
121
|
+
const bad = [
|
|
122
|
+
'', ' ', 'not a url', 'ftp://example.com/v.mp4', 'javascript:alert(1)',
|
|
123
|
+
'file:///C:/Windows/win.ini', 'mailto:a@b.c', 'http://', null, undefined, 42, {},
|
|
124
|
+
]
|
|
125
|
+
try {
|
|
126
|
+
for (const u of bad) {
|
|
127
|
+
const r = downloadVideo(u, dir, { quiet: true, timeoutMs: 5000 })
|
|
128
|
+
assert.equal(r, null, `非法 URL 应返回 null:${String(u)}`)
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('downloadVideo:没有输出目录时返回 null', () => {
|
|
136
|
+
assert.equal(downloadVideo('https://example.com/v', '', { quiet: true, timeoutMs: 5000 }), null)
|
|
137
|
+
assert.equal(downloadVideo('https://example.com/v', undefined, { quiet: true, timeoutMs: 5000 }), null)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('downloadVideo:超时 → null(不抛)', () => {
|
|
141
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fisheye-ytdlp-timeout-'))
|
|
142
|
+
try {
|
|
143
|
+
const r = downloadVideo('https://www.youtube.com/watch?v=BaW_jenozKc', dir, {
|
|
144
|
+
quiet: true,
|
|
145
|
+
timeoutMs: 500,
|
|
146
|
+
})
|
|
147
|
+
assert.equal(r, null)
|
|
148
|
+
} finally {
|
|
149
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// 可选端到端:默认跳过,FISHEYE_YTDLP_E2E=1 时下载 yt-dlp 官方 10 秒测试视频
|
|
154
|
+
const E2E = process.env.FISHEYE_YTDLP_E2E === '1'
|
|
155
|
+
|
|
156
|
+
test('downloadVideo:端到端下载官方测试视频', { skip: !E2E }, (t) => {
|
|
157
|
+
if (!findYtdlp()) {
|
|
158
|
+
t.skip('本机没有 yt-dlp')
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fisheye-ytdlp-e2e-'))
|
|
162
|
+
try {
|
|
163
|
+
const r = downloadVideo('https://www.youtube.com/watch?v=BaW_jenozKc', dir, {
|
|
164
|
+
quiet: true,
|
|
165
|
+
timeoutMs: 180000,
|
|
166
|
+
quality: '480',
|
|
167
|
+
})
|
|
168
|
+
if (!r) {
|
|
169
|
+
t.skip('下载失败(网络 / 平台不可达),跳过端到端')
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
assert.ok(fs.existsSync(r.file), `返回的文件不存在:${r.file}`)
|
|
173
|
+
assert.ok(fs.statSync(r.file).size > 1024, '文件太小,可能不是视频')
|
|
174
|
+
assert.equal(path.dirname(r.file), dir)
|
|
175
|
+
assert.equal(typeof r.cmd, 'string')
|
|
176
|
+
} finally {
|
|
177
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
178
|
+
}
|
|
179
|
+
})
|