dsh-bili-watch 0.3.0 → 0.3.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/client.js CHANGED
@@ -101,6 +101,23 @@ window.__ModuleLoader__.load({
101
101
 
102
102
  // 参数分层(2026-10-28 阶段B):一项 = 输入框 + 默认值提示 + 范围 + 一句话后果 + 恢复默认。
103
103
  // 只写 h(),不引新依赖;保存/恢复都由外面传进来的回调走 /config。
104
+ // 2026-10-28 老大要求「高级设置放开」:这 8 项原来是代码里的硬编码,现在都能在面板里调。
105
+ // 元数据跟后端 TUNING_DEFS 一一对应(键名、默认值、上下界都从 /status 的 tuningDefaults/tuningRanges 取,
106
+ // 这里只放文案),加项时后端 TUNING_DEFS 与这张表各加一行即可。
107
+ const ADV_TUNING = [
108
+ { key: 'asrMemMB', label: '转写内存门槛', unit: 'MB', def: '1536', hint: '该值用于控制本地转写的内存门槛。可用内存低于此值时跳过转写、降级至字幕或抽帧;增大后更早放弃转写,减小后内存紧张时仍强制执行可能崩溃。默认 1536 MB。' },
109
+ { key: 'lockTtlMin', label: '残留锁过期', unit: '分钟', def: '30', hint: '该值用于控制残留锁的过期时长。进程异常退出后锁文件超过此时长视为过期,新任务可接管;缩短后运行中的长转写可能被误判过期,延长后残留锁阻塞更久。默认 30 分钟。' },
110
+ { key: 'unitLimit', label: '单条内容上限', unit: '字', def: '5000', hint: '该值用于控制单条评论或弹幕的截断阈值。超出时截断并标注省略字数;增大后内容更完整但占用更多上下文,减小后省上下文但长评论截断更短。默认 5000 字。' },
111
+ { key: 'textLimit', label: '文本层默认字数', unit: '字', def: '800', hint: '该值用于控制文本层单次返回正文上限。超出时仅返回开头摘要与落盘路径;增大后对话内正文更长但占用更多上下文,减小后更早落盘。默认 800 字。' },
112
+ { key: 'toolTextCap', label: '工具层总字数', unit: '字', def: '2000', hint: '该值用于控制工具层单次返回正文总上限。增大后每次工具返回的正文更长,占用更多对话上下文;减小后更早落盘。默认 2000 字。' },
113
+ { key: 'toolHead', label: '工具层默认头部', unit: '字', def: '800', hint: '该值用于控制工具层默认返回的正文头部字数,超出部分写入落盘文件。增大后对话内暴露更多开头内容,减小后更早切换至落盘路径。默认 800 字。' },
114
+ { key: 'dmBucketSec', label: '弹幕分桶宽度', unit: '秒', def: '30', hint: '该值用于控制弹幕热区的分桶时长。增大后热区粒度更粗、覆盖更长时段;减小后定位更精确但可能分散。默认 30 秒。' },
115
+ { key: 'dmTop', label: '弹幕热区条数', unit: '桶', def: '5', hint: '该值用于控制弹幕热区输出的桶数。增大后列出更多时段但输出更长;减小后仅保留最突出时段。默认 5 桶。' },
116
+ { key: 'downloadMaxMB', label: '单文件下载上限', unit: 'MB', def: '512', hint: '该值用于控制单个媒体文件的下载体积上限。超出时下载中止,需增大后重试;增大后允许更大文件但增加磁盘与带宽占用,减小后拒绝大文件。默认 512 MB。' },
117
+ { key: 'subRateMin10', label: '字幕语速下限 ×10', unit: '(20=2.0 字/秒)', def: '20', hint: '该值用于控制字幕语速下限(×10,即 2.0 字/秒),低于此值判定为「字幕可疑」。减小后慢口播更不易误判但可能漏检诱饵,增大后慢口播更易误判。默认 2.0 字/秒。' },
118
+ { key: 'subRateMax10', label: '字幕语速上限 ×10', unit: '(85=8.5 字/秒)', def: '85', hint: '该值用于控制字幕语速上限(×10,即 8.5 字/秒),高于此值判定为「字幕可疑」。增大后快语速更不易误判但可能漏检,减小后快语速更易误判。默认 8.5 字/秒。' },
119
+ { key: 'textBrief', label: '超长文本摘要字数', unit: '字', def: '400', hint: '该值用于控制超长文本的摘要长度。正文超过「文本层默认字数」时,对话内仅返回开头这么多字,全文落盘;增大后摘要更完整但增加上下文占用,减小后摘要更短。默认 400 字。' },
120
+ ]
104
121
  function TuneRow({ label, value, unit, onChange, onSave, onRestore, def, range, hint }) {
105
122
  return h('div', { 'data-bl-row': '' },
106
123
  h('span', { 'data-bl-name': '' }, label),
@@ -127,11 +144,17 @@ window.__ModuleLoader__.load({
127
144
  const [ffTmo, setFfTmo] = React.useState('300')
128
145
  const [cmdTmo, setCmdTmo] = React.useState('180')
129
146
  const [advOpen, setAdvOpen] = React.useState(false)
147
+ // 高级设置那 8 项统一放在一个对象里,避免再加 8 个 useState
148
+ const [advVals, setAdvVals] = React.useState({})
130
149
  const [tuneMeta, setTuneMeta] = React.useState(null)
131
150
  const [qr, setQr] = React.useState(null)
132
151
  const [qrState, setQrState] = React.useState('')
133
152
  const [dlg, setDlg] = React.useState(null) // 'download' | 'clean'
134
153
  const [picked, setPicked] = React.useState([])
154
+ // BLK-03(2026-10-28):8 秒轮询原来无条件把输入框重置成服务端值,用户改完天数还没点保存
155
+ // 就被改回 7。用 ref 记「用户动过输入框」,轮询见到就跳过覆盖,保存成功后复位。
156
+ const dirtyRef = React.useRef(false)
157
+ const markDirty = () => { dirtyRef.current = true }
135
158
 
136
159
  const load = React.useCallback(async () => {
137
160
  try {
@@ -139,19 +162,22 @@ window.__ModuleLoader__.load({
139
162
  if (j.ok) {
140
163
  setSt(j.data)
141
164
  setErr('')
142
- setDays(String(j.data.cache?.days ?? 7))
143
- setMaxMb(String(j.data.cache?.maxMb ?? 2048))
144
165
  // 参数分层:初值取 /status 的 tuning / tuningDefaults / tuningRanges
145
166
  const tv = j.data.tuning || {}
146
167
  const td = j.data.tuningDefaults || {}
147
168
  const tr = j.data.tuningRanges || {}
148
169
  setTuneMeta({ defaults: td, ranges: tr })
149
- const sec = Number(tv.asrMaxSec ?? td.asrMaxSec ?? 1800)
150
- setAsrMin(String(Math.round(sec / 60)))
151
- setFrames(String(tv.maxFrames ?? td.maxFrames ?? 500))
152
- setAsrTmo(String(tv.asrTimeoutSec ?? td.asrTimeoutSec ?? 900))
153
- setFfTmo(String(tv.ffmpegTimeoutSec ?? td.ffmpegTimeoutSec ?? 300))
154
- setCmdTmo(String(tv.cmdTimeoutSec ?? td.cmdTimeoutSec ?? 180))
170
+ if (!dirtyRef.current) {
171
+ setDays(String(j.data.cache?.days ?? 7))
172
+ setMaxMb(String(j.data.cache?.maxMb ?? 2048))
173
+ const sec = Number(tv.asrMaxSec ?? td.asrMaxSec ?? 1800)
174
+ setAsrMin(String(Math.round(sec / 60)))
175
+ setFrames(String(tv.maxFrames ?? td.maxFrames ?? 500))
176
+ setAsrTmo(String(tv.asrTimeoutSec ?? td.asrTimeoutSec ?? 900))
177
+ setFfTmo(String(tv.ffmpegTimeoutSec ?? td.ffmpegTimeoutSec ?? 300))
178
+ setCmdTmo(String(tv.cmdTimeoutSec ?? td.cmdTimeoutSec ?? 180))
179
+ setAdvVals(Object.fromEntries(ADV_TUNING.map((x) => [x.key, String(tv[x.key] ?? td[x.key] ?? x.def)])))
180
+ }
155
181
  } else {
156
182
  setErr(j.why || '状态读取失败')
157
183
  }
@@ -186,7 +212,7 @@ window.__ModuleLoader__.load({
186
212
  if (j.state === 'expired' || j.state === 'error') {
187
213
  stop = true
188
214
  setQrState(j.state)
189
- setMsg(j.why || (j.state === 'expired' ? '二维码已过期,点「重新扫码」再试。' : '轮询失败'))
215
+ setMsg(j.why || (j.state === 'expired' ? '二维码已过期,请点击「重新扫码」重试。' : '轮询失败'))
190
216
  return
191
217
  }
192
218
  setQrState(j.state)
@@ -230,7 +256,7 @@ window.__ModuleLoader__.load({
230
256
  setMsg('')
231
257
  try {
232
258
  const j = await jpost('/download', { target })
233
- setMsg(j.ok ? `已开始下载(后台跑,日志 ${j.data.log})。下载较久,过一会儿点刷新看状态。` : (j.data?.why || j.why || '启动失败'))
259
+ setMsg(j.ok ? `已开始下载(后台执行,日志 ${j.data.log})。下载完成后点击刷新查看状态。` : (j.data?.why || j.why || '启动失败'))
234
260
  } catch (e) {
235
261
  setMsg(String(e?.message || e))
236
262
  } finally {
@@ -241,7 +267,8 @@ window.__ModuleLoader__.load({
241
267
  const saveCache = async () => {
242
268
  try {
243
269
  const j = await jpost('/cache/clear', { days: Number(days), maxMb: Number(maxMb) })
244
- setMsg(j.ok ? `缓存已清(${j.data.removed} 个文件 / ${fmtBytes(j.data.bytes)});之后按 ${days} 天、${maxMb} MB 自动清` : (j.why || '清缓存失败'))
270
+ setMsg(j.ok ? `缓存已清理(${j.data.removed} 个文件 / ${fmtBytes(j.data.bytes)});此后按 ${days} 天、${maxMb} MB 自动清理` : (j.why || '清理缓存失败'))
271
+ dirtyRef.current = false
245
272
  await load()
246
273
  } catch (e) {
247
274
  setMsg(String(e?.message || e))
@@ -252,6 +279,7 @@ window.__ModuleLoader__.load({
252
279
  try {
253
280
  const j = await jpost('/config', { days: Number(days), maxMb: Number(maxMb) })
254
281
  setMsg(j?.ok === false ? ('保存失败:' + (j.why || '未知错误')) : '自动清理规则已保存。')
282
+ if (j?.ok !== false) { dirtyRef.current = false; await load() }
255
283
  } catch (e) {
256
284
  // A2(2026-10-27 批次8):原来 catch{} 静默吞,保存失败用户完全无感——现在显式提示
257
285
  setMsg('保存失败:' + String(e?.message || e))
@@ -267,6 +295,7 @@ window.__ModuleLoader__.load({
267
295
  return
268
296
  }
269
297
  setMsg(okText)
298
+ dirtyRef.current = false
270
299
  await load()
271
300
  } catch (e) {
272
301
  setMsg('保存失败:' + String(e?.message || e))
@@ -305,7 +334,7 @@ window.__ModuleLoader__.load({
305
334
  await jpost('/download', { target: t })
306
335
  } catch {}
307
336
  }
308
- setMsg(`已启动 ${picked.length} 个下载(后台跑,过一会儿刷新看状态)。`)
337
+ setMsg(`已启动 ${picked.length} 个下载(后台执行,稍后点击刷新查看状态)。`)
309
338
  return
310
339
  }
311
340
  if (!picked.length) {
@@ -325,7 +354,7 @@ window.__ModuleLoader__.load({
325
354
 
326
355
  const delOne = async (target) => {
327
356
  const row = rows.find((d) => d.id === target)
328
- const label = target === 'credential' ? '登录凭证(删了要重新扫码)' : (row?.name || target)
357
+ const label = target === 'credential' ? '登录凭证(删除后需重新扫码)' : (row?.name || target)
329
358
  if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
330
359
  if (!window.confirm(`确定删除 ${label}?`)) return
331
360
  }
@@ -333,7 +362,7 @@ window.__ModuleLoader__.load({
333
362
  const j = await jpost('/delete', { targets: [target] })
334
363
  const d = j.data || {}
335
364
  const skipped = (d.skipped || []).map((s) => s.why).join('、')
336
- setMsg(d.deleted?.length ? `已删除 ${target}` : `没删:${skipped || '未知原因'}`)
365
+ setMsg(d.deleted?.length ? `已删除 ${target}` : `未删除:${skipped || '未知原因'}`)
337
366
  await load()
338
367
  } catch (e) {
339
368
  setMsg('删除失败:' + String(e?.message || e))
@@ -380,7 +409,7 @@ window.__ModuleLoader__.load({
380
409
  ),
381
410
  h('div', { 'data-bl-head': '' },
382
411
  h('span', { 'data-bl-title': '' }, '依赖与状态'),
383
- h('span', { 'data-bl-sub': '' }, '缺什么点下载,装好后这里会变绿。')),
412
+ h('span', { 'data-bl-sub': '' }, '缺失项点击行末按钮安装,完成后状态显示为已就绪。')),
384
413
  h('div', { 'data-bl-sec': '' },
385
414
  h('div', { 'data-bl-sechead': '' },
386
415
  h('span', { 'data-bl-sec-title': '' }, '运行依赖'),
@@ -388,7 +417,7 @@ window.__ModuleLoader__.load({
388
417
  h('button', { 'data-bl-btn': '', onClick: () => openDlg('clean') }, '批量清理')),
389
418
  ...deps.map((d) => h(DepRow, { key: d.id, d, busy: busy === d.id, onDownload: download, onDelete: delOne })),
390
419
  h('div', { 'data-bl-note': '' },
391
- '下载在后台跑,用插件自带的 fetch-deps 脚本;体积是预估,实际以跑完为准。')),
420
+ '下载在后台执行,使用内置 fetch-deps 脚本;体积为预估值,实际以完成后为准。')),
392
421
  dlg
393
422
  ? h('div', { 'data-bl-sec': '', 'data-bl-dlg': '' },
394
423
  h('span', { 'data-bl-sec-title': '' }, dlg === 'download' ? '批量下载(默认全勾选,可取消)' : '批量清理(默认全勾选,可取消)'),
@@ -415,7 +444,7 @@ window.__ModuleLoader__.load({
415
444
  d.removable
416
445
  ? d.ok
417
446
  ? h('span', { 'data-bl-good': '' }, '可清理')
418
- : h('span', { 'data-bl-note': '' }, '没有可删的')
447
+ : h('span', { 'data-bl-note': '' }, '无可清理项')
419
448
  : h('span', { 'data-bl-note': '' }, d.removableWhy || '不在插件目录内'),
420
449
  ),
421
450
  )),
@@ -431,10 +460,10 @@ window.__ModuleLoader__.load({
431
460
  h('div', { 'data-bl-qrside': '' },
432
461
  h('div', { 'data-bl-note': '' },
433
462
  qrState === 'scanned'
434
- ? '已扫码,请在手机 B 上点确认。'
463
+ ? '请在 B 站手机客户端中确认登录。'
435
464
  : qrState === 'loading'
436
465
  ? '正在生成二维码…'
437
- : '用手机 B 站「扫一扫」扫左边这张;扫完这里会自动变绿。'),
466
+ : '请使用 B 站手机 App 扫描二维码,确认后自动刷新登录状态。'),
438
467
  h('button', { 'data-bl-btn': '', onClick: () => { setQr(null); setQrState('') } }, '取消'))))
439
468
  : null,
440
469
  h('div', { 'data-bl-sec': '' },
@@ -467,13 +496,13 @@ window.__ModuleLoader__.load({
467
496
  h('span', { 'data-bl-name': '' }, '自动清理规则'),
468
497
  h('span', { 'data-bl-detail': '' },
469
498
  '保留 ',
470
- h('input', { 'data-bl-input': '', value: days, onChange: (e) => setDays(e.target.value), inputMode: 'numeric' }),
499
+ h('input', { 'data-bl-input': '', value: days, onChange: (e) => { markDirty(); setDays(e.target.value) }, inputMode: 'numeric' }),
471
500
  ' 天;总量超过 ',
472
- h('input', { 'data-bl-input': '', value: maxMb, onChange: (e) => setMaxMb(e.target.value), inputMode: 'numeric' }),
501
+ h('input', { 'data-bl-input': '', value: maxMb, onChange: (e) => { markDirty(); setMaxMb(e.target.value) }, inputMode: 'numeric' }),
473
502
  ' MB 时从最旧的删。'),
474
503
  h('button', { 'data-bl-btn': '', onClick: saveRule }, '保存规则')),
475
504
  h('div', { 'data-bl-note': '' },
476
- '不点也会按上面的规则自动清(每次打开面板与插件启动时检查一次)。')),
505
+ '即使不手动清理,也会按上述规则自动执行(每次打开面板与插件启动时检查一次)。')),
477
506
  // 参数分层(阶段B)常规组:不折叠。转写闸门按分钟显示,存的是秒。
478
507
  h('div', { 'data-bl-sec': '' },
479
508
  h('span', { 'data-bl-sec-title': '' }, '转写与抽帧'),
@@ -481,7 +510,7 @@ window.__ModuleLoader__.load({
481
510
  label: '转写闸门',
482
511
  value: asrMin,
483
512
  unit: '分钟',
484
- onChange: setAsrMin,
513
+ onChange: (v) => { markDirty(); setAsrMin(v) },
485
514
  onSave: saveAsrMax,
486
515
  onRestore: () => saveTune({ asrMaxSec: null }, '转写闸门已恢复默认(30 分钟)。'),
487
516
  def: '30 分钟',
@@ -492,7 +521,7 @@ window.__ModuleLoader__.load({
492
521
  label: '抽帧张数上限',
493
522
  value: frames,
494
523
  unit: '张',
495
- onChange: setFrames,
524
+ onChange: (v) => { markDirty(); setFrames(v) },
496
525
  onSave: () => saveTune({ maxFrames: Number(frames) }, '抽帧张数上限已保存。'),
497
526
  onRestore: () => saveTune({ maxFrames: null }, '抽帧张数上限已恢复默认(500 张)。'),
498
527
  def: '500 张',
@@ -501,16 +530,16 @@ window.__ModuleLoader__.load({
501
530
  }),
502
531
  h('div', { 'data-bl-row': '' },
503
532
  h('button', { 'data-bl-btn': '', onClick: () => setAdvOpen((v) => !v) },
504
- (advOpen ? '▾ ' : '▸ ') + '高级设置(不建议修改)'))),
533
+ (advOpen ? '▾ ' : '▸ ') + '高级设置(一般保持默认)'))),
505
534
  // 高级设置:折叠,默认收起。改错会让长视频静默降级,所以放在折叠区里。
506
535
  advOpen
507
536
  ? h('div', { 'data-bl-sec': '' },
508
- h('span', { 'data-bl-sec-title': '' }, '高级设置(不建议修改)'),
537
+ h('span', { 'data-bl-sec-title': '' }, '高级设置(一般保持默认)'),
509
538
  h(TuneRow, {
510
539
  label: 'ASR 超时',
511
540
  value: asrTmo,
512
541
  unit: '秒',
513
- onChange: setAsrTmo,
542
+ onChange: (v) => { markDirty(); setAsrTmo(v) },
514
543
  onSave: () => saveTune({ asrTimeoutSec: Number(asrTmo) }, 'ASR 超时已保存。'),
515
544
  onRestore: () => saveTune({ asrTimeoutSec: null }, 'ASR 超时已恢复默认(900 秒)。'),
516
545
  def: '900 秒',
@@ -521,7 +550,7 @@ window.__ModuleLoader__.load({
521
550
  label: 'ffmpeg 超时',
522
551
  value: ffTmo,
523
552
  unit: '秒',
524
- onChange: setFfTmo,
553
+ onChange: (v) => { markDirty(); setFfTmo(v) },
525
554
  onSave: () => saveTune({ ffmpegTimeoutSec: Number(ffTmo) }, 'ffmpeg 超时已保存。'),
526
555
  onRestore: () => saveTune({ ffmpegTimeoutSec: null }, 'ffmpeg 超时已恢复默认(300 秒)。'),
527
556
  def: '300 秒',
@@ -532,13 +561,29 @@ window.__ModuleLoader__.load({
532
561
  label: '命令超时',
533
562
  value: cmdTmo,
534
563
  unit: '秒',
535
- onChange: setCmdTmo,
564
+ onChange: (v) => { markDirty(); setCmdTmo(v) },
536
565
  onSave: () => saveTune({ cmdTimeoutSec: Number(cmdTmo) }, '命令超时已保存。'),
537
566
  onRestore: () => saveTune({ cmdTimeoutSec: null }, '命令超时已恢复默认(180 秒)。'),
538
567
  def: '180 秒',
539
568
  range: `${tuneMeta?.ranges?.cmdTimeoutSec?.[0] ?? 60}–${tuneMeta?.ranges?.cmdTimeoutSec?.[1] ?? 900} 秒`,
540
569
  hint: '太短会让长视频的 text 命令超时。',
541
570
  }),
571
+ // 2026-10-28 新增的 8 项:全部由 ADV_TUNING 驱动,加项只改那张表 + 后端 TUNING_DEFS
572
+ ...ADV_TUNING.map((x) => h(TuneRow, {
573
+ key: x.key,
574
+ label: x.label,
575
+ value: advVals[x.key] ?? x.def,
576
+ unit: x.unit,
577
+ onChange: (v) => { markDirty(); setAdvVals((s) => ({ ...s, [x.key]: v })) },
578
+ onSave: () => saveTune({ [x.key]: Number(advVals[x.key] ?? x.def) }, `${x.label}已保存。`),
579
+ onRestore: () => saveTune({ [x.key]: null }, `${x.label}已恢复默认(${x.def} ${x.unit})。`),
580
+ def: `${x.def} ${x.unit}`,
581
+ range: (() => {
582
+ const r = tuneMeta?.ranges?.[x.key]
583
+ return r ? `${r[0]}–${r[1]} ${x.unit}` : `默认 ${x.def} ${x.unit}`
584
+ })(),
585
+ hint: x.hint,
586
+ })),
542
587
  h('div', { 'data-bl-note': '' },
543
588
  '这几项会写进 deps/deps-config.json,改完立即生效,不用重启。「恢复默认」= 把这个键从配置里删掉。'))
544
589
  : null,
package/cordis.patch.yml CHANGED
@@ -1,6 +1,7 @@
1
1
  # dsh-bili-watch bundle patch layer
2
2
  # profile 的 dsh.profile.bundles 列出本包名即整层生效
3
+ # 2026-10-28:去掉 disabled: true。0.2.17(10-24 应急修 DNS rebinding)时误加,
4
+ # 之后 7 个版本一直带着,装完重启命令不存在、用户以为装坏了。不要再改回 true。
3
5
  - insert:
4
6
  - id: dsh-bili-watch
5
7
  name: dsh-bili-watch
6
- disabled: true
package/deps/_bili-asr.py CHANGED
@@ -90,7 +90,9 @@ def main() -> int:
90
90
  segments, info = model.transcribe(
91
91
  args.src,
92
92
  language=None if args.lang == "auto" else args.lang,
93
- vad_filter=True, # 掐掉静音段,纯画面/纯音乐的视频会得到空文本
93
+ # issue #2 P3-1(2026-10-28):原来硬编码 True。VAD 预检失败(vad_ok=False)时,
94
+ # 同一个 vad 模块会在 transcribe 里再炸一次、整段转写直接失败;所以预检失败就关掉过滤。
95
+ vad_filter=vad_ok, # 正常情况掐掉静音段,纯画面/纯音乐的视频会得到空文本
94
96
  vad_parameters={"min_silence_duration_ms": 400},
95
97
  beam_size=5,
96
98
  condition_on_previous_text=False, # 防止长视频里一段错就带着后面全错
package/index.js CHANGED
@@ -18,8 +18,10 @@ import { fileURLToPath } from 'node:url'
18
18
  // 缺了只少一个 bili_watch 工具,/bili 命令、插件面板与路由照常工作,不能因此整块加载失败。
19
19
  // 工具输出上限:超了就把全文落文件、只回摘要。
20
20
  // 由来:2026-10-26 一段 14 分钟视频的字幕 4,449 字整段塞进上下文,把会话挤爆两次。
21
- const TOOL_TEXT_CAP = 2000
22
- const TOOL_TEXT_HEAD = 800
21
+ // 2026-10-28 高级设置放开:两个上限改从 readTuning() 每次取(toolTextCap 默认 2000、toolHead 默认 800)。
22
+ // 不能写成模块级 const——index.js 是常驻进程,那样面板改了要重启才生效,和 cacheCfg 踩过的坑一样。
23
+ const toolTextCap = () => readTuning().toolTextCap
24
+ const toolTextHead = () => readTuning().toolHead
23
25
 
24
26
  // ── 进程级路由守卫(T41-P0-1):fiber 重放/多实例下同一路径只注册一次 ──
25
27
  // 参照 dsh-memory-evolve/lib/route-guard.js(MIT):claim 成功才注册;
@@ -80,10 +82,10 @@ function trimForModel(text, tag = 'out', bv = '', jsonMode = false, opts = {}) {
80
82
  // A8(2026-10-27 批次8):--full 时工具层不要再把已放大的文本切回 800——
81
83
  // full 走 20000 上限;用户给的 --head N 优先于默认 800;不传 full 仍 ≤2000。
82
84
  const full = !!opts.full
83
- const cap = full ? 20000 : TOOL_TEXT_CAP
85
+ const cap = full ? 20000 : toolTextCap()
84
86
  const head = (Number.isFinite(opts.head) && opts.head > 0)
85
87
  ? Math.floor(opts.head)
86
- : (full ? 20000 : TOOL_TEXT_HEAD)
88
+ : (full ? 20000 : toolTextHead())
87
89
  let f = ''
88
90
  try {
89
91
  const base = String(tag || 'out').replace(/[^\w.-]/g, '') || 'out'
@@ -140,6 +142,9 @@ const DEPS = join(HERE, 'deps')
140
142
  // 解析失败不写 stdout/stderr(会污染 --format json 与 PowerShell 管道),
141
143
  // 而是记进 cfgWarning,由 /status 的 warnings 带给面板;ENOENT(没配过)不算异常。
142
144
  let cfgWarning = null
145
+ // issue #2 P3-3(2026-10-28):bili_watch 工具没注册成功时原来只 console.warn,桌面端看不到控制台,
146
+ // 用户会以为「工具坏了」。改成同时记一条,由 /status 的 warnings 带到面板。
147
+ let toolsWarning = null
143
148
  function depsCfg() {
144
149
  try { return JSON.parse(readFileSync(join(DEPS, 'deps-config.json'), 'utf8')) } catch (e) {
145
150
  if (e?.code !== 'ENOENT') {
@@ -159,6 +164,23 @@ const TUNING_DEFS = {
159
164
  asrTimeoutSec: { def: 900, min: 300, max: 3600 },
160
165
  ffmpegTimeoutSec: { def: 300, min: 60, max: 1800 },
161
166
  cmdTimeoutSec: { def: 180, min: 60, max: 900 },
167
+ // 2026-10-28 老大要求「高级设置放开,能调的都给调」:下面 8 项原来是硬编码。
168
+ // 全部整数(tuneOne 会 Math.floor,别写小数);键集必须与 tools/look.mjs:2878 那份完全一致,
169
+ // verify.mjs 的 C17 会逐键比对,改一处漏一处直接红。
170
+ asrMemMB: { def: 1536, min: 512, max: 16384 }, // 跑本地转写要多少可用内存(MB),不够就跳
171
+ lockTtlMin: { def: 30, min: 5, max: 240 }, // 残留锁多久算过期(分钟)
172
+ unitLimit: { def: 5000, min: 1000, max: 20000 }, // 单个内容单元(字幕段/评论)字符上限
173
+ textLimit: { def: 800, min: 200, max: 5000 }, // 文本层默认给多少字
174
+ toolTextCap: { def: 2000, min: 500, max: 12000 },// 工具层单次回给模型的总字数(调大会挤占对话上下文)
175
+ toolHead: { def: 800, min: 200, max: 5000 }, // 工具层默认 head 字数
176
+ dmBucketSec: { def: 30, min: 5, max: 120 }, // 弹幕热区按多少秒一桶
177
+ dmTop: { def: 5, min: 1, max: 20 }, // 弹幕热区取前几桶
178
+ // 2026-10-28 第二批 3 项:都是「撞上了只能报 bug、自己没法自救」的。
179
+ // 语速用 ×10 整数存(20 = 2.0 字/秒),因为 tuneOne 会 Math.floor,不能写小数。
180
+ downloadMaxMB: { def: 512, min: 64, max: 4096 }, // 单个视频/音频下载上限(4K 素材会撞)
181
+ subRateMin10: { def: 20, min: 5, max: 60 }, // 字幕语速下限×10,低于判「可疑」
182
+ subRateMax10: { def: 85, min: 30, max: 200 }, // 字幕语速上限×10,高于判「可疑」
183
+ textBrief: { def: 400, min: 100, max: 3000 }, // 超长文本只给开头多少字(其余落盘)
162
184
  }
163
185
  const TUNING_KEYS = Object.keys(TUNING_DEFS)
164
186
  const tuningDefaults = () => Object.fromEntries(TUNING_KEYS.map((k) => [k, TUNING_DEFS[k].def]))
@@ -201,7 +223,7 @@ function writeTuning(body) {
201
223
  writeFileSync(p, JSON.stringify(cur, null, 2), 'utf8')
202
224
  return { ok: true, data: cur }
203
225
  } catch (e) {
204
- return { ok: false, error: String(e?.message || e) }
226
+ return { ok: false, why: String(e?.message || e) }
205
227
  }
206
228
  }
207
229
  function projDir() { return process.env.BILI_WATCH_PROJ || depsCfg().proj || DEPS }
@@ -213,7 +235,23 @@ function projDir() { return process.env.BILI_WATCH_PROJ || depsCfg().proj || DEP
213
235
  const SHARED_CRED = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'bili-watch', 'credential.json')
214
236
  const PANEL_CRED = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'bili-duty', 'bili_credential.json')
215
237
  const LEGACY_CRED = join(DEPS, 'credential.json')
238
+ // 2026-10-28:面板扫码写的是 bili-duty\bili_credential.small.json(小号),watch 这份不会跟着变。
239
+ // 老大的口径是「两个地方用同一份,复制一下即可」——所以这里把它做成一次自动复制,
240
+ // 不引入新的读取优先级:复制后 SHARED 的 mtime 就是现在,后面那段 mtime 仲裁照旧。
241
+ // 单向(panel small → SHARED),不反向写,避免把 watch 扫的号盖掉面板那份。
242
+ const PANEL_SMALL = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'bili-duty', 'bili_credential.small.json')
243
+ function syncPanelSmallToShared() {
244
+ try {
245
+ const panelAt = statSync(PANEL_SMALL).mtimeMs
246
+ let sharedAt = 0
247
+ try { sharedAt = statSync(SHARED_CRED).mtimeMs } catch {}
248
+ if (sharedAt && panelAt <= sharedAt) return
249
+ mkdirSync(dirname(SHARED_CRED), { recursive: true })
250
+ writeFileSync(SHARED_CRED, readFileSync(PANEL_SMALL), { encoding: 'utf8', mode: 0o600 })
251
+ } catch {}
252
+ }
216
253
  function resolveCredPath() {
254
+ syncPanelSmallToShared()
217
255
  try {
218
256
  // mtime 比较:panel 侧重登大号(更常见)后 PANEL_CRED 更新,watch 立刻跟上;
219
257
  // watch 自己扫码登了新号则 SHARED 更新,用 SHARED。同 mtime 时偏好 panel(权威源)。
@@ -244,23 +282,23 @@ const LAST_RUN = join(DEPS, 'last-run.json')
244
282
  // 自定义目录不可写时不让功能哑掉:回退到默认路径($DSH_HOME/bili-watch/cache),并把原因挂进 /status.warnings。
245
283
  // 缓存根标记:只有我们自己写过这个文件,才认「这个目录已经是缓存根」
246
284
  const CACHE_MARK = '.bili-watch-root'
247
- const OWN_CACHE_DIRS = new Set(['_logs', 'frames', 'sheets', 'sheet-tmp', 'substrip-tmp', '_misc', 'asr', 'frames-dl', 'audio-dl'])
285
+ const OWN_CACHE_DIRS = new Set(['_logs', 'frames', 'sheets', 'sheet-tmp', 'substrip-tmp', '_misc', 'asr', 'frames-dl', 'audio-dl', '_hot'])
248
286
  function hasBvLayout(dir) {
249
287
  // 判据一:我们写过的标记(新用户、二次运行都靠它)
250
288
  try { if (existsSync(join(dir, CACHE_MARK))) return true } catch {}
251
- // 判据二(N05-A1,2026-10-27 改):BV* 目录 **且** 我们必建的工作目录之一,两条同时成立才算。
252
- // 原来只看 BV*,可目标用户就是 B 站素材库——目录名全以 BV 开头,于是把用户自己的素材库
253
- // 认成缓存根,removeOwned / autoCleanCache 会成片删掉 original.mp4。
254
- // 单看 frames/asr 也不行(通用名,别人也会有),所以改成「两者同时成立」。
289
+ // 判据二(2026-10-28 W-01 改):BV* 目录 **且** 该目录里有我们自己写的 meta.json。
290
+ // N05-A1 那版用「BV* + frames/asr/_logs 之一」,实测仍会误判:用户素材库只要同时有 BV 目录和
291
+ // 一个 frames 目录,clean 就把 BV 目录整片删掉(rc=0、无警告),还顺手写下 .bili-watch-root 自我固化,
292
+ // 此后永远走删的那条路、不会自己纠正。frames/asr/_logs 是通用名,等于拿别人的东西证明自己的身份;
293
+ // meta.json 只有 writeMeta() 会写,用户素材库不会有。
255
294
  // 口径必须与 tools/look.mjs 的同名函数逐字一致,否则面板与 CLI 判据分叉。
256
- let hasBv = false
257
- let hasWork = false
258
295
  try {
259
296
  for (const e of readdirSync(dir, { withFileTypes: true })) {
260
297
  if (!e.isDirectory()) continue
261
- if (/^BV[0-9A-Za-z]{10}/i.test(e.name)) hasBv = true
262
- else if (e.name === 'frames' || e.name === 'asr' || e.name === '_logs') hasWork = true
263
- if (hasBv && hasWork) return true
298
+ if (!/^BV[0-9A-Za-z]{10}/i.test(e.name)) continue
299
+ try {
300
+ if (existsSync(join(dir, e.name, 'meta.json'))) return true
301
+ } catch {}
264
302
  }
265
303
  } catch {}
266
304
  return false
@@ -281,7 +319,8 @@ function isOwnedEntry(p) {
281
319
  // 留下一个 .lock 会让 autoCleanCache 永远 locked、而唯一逃生口 clean 用同一判据也被堵死,
282
320
  // 缓存只涨不消且没有 --force。这里与 tryLock 用完全相同的判据:持有者进程还活着且未过期才算被占用。
283
321
  // 下面三个辅助函数与 tools/look.mjs:647-662 逐字一致。
284
- const LOCK_TTL_MS = 30 * 60 * 1000
322
+ // 2026-10-28 高级设置放开:与 tools/look.mjs 同源,从 readTuning().lockTtlMin 取(默认 30 分钟)
323
+ const lockTtlMs = () => readTuning().lockTtlMin * 60 * 1000
285
324
  function pidAlive(pid) {
286
325
  if (!Number.isInteger(pid) || pid <= 0) return false
287
326
  try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' }
@@ -295,7 +334,7 @@ function readLock(f) {
295
334
  }
296
335
  function lockExpired(owner) {
297
336
  if (!owner) return true
298
- return Date.now() - (owner.at || 0) > (owner.ttlMs || LOCK_TTL_MS)
337
+ return Date.now() - (owner.at || 0) > (owner.ttlMs || lockTtlMs())
299
338
  }
300
339
  function hasAnyLock(root) {
301
340
  const walk = (d, depth) => {
@@ -316,15 +355,23 @@ function hasAnyLock(root) {
316
355
  }
317
356
  // 2026-10-28:cleanKey 会把整条链接压成目录名(如 httpsb23.tvabc123)——短链没解析成 BV 时
318
357
  // 插件自己就建了这种目录,清理时必须认它,否则「立即清除」会留一堆删不掉的垃圾(用户实测剩 3 个)。
319
- // 两条一起用:名字以 http 开头(用户素材库不会有这种名字),且里面只有我们自己的产物。
358
+ // 10-28 又实测剩两个:`search` 的落盘目录名来自查询词(如 `DeepSeekHarness`)、`hot` 落 `_hot`。
359
+ // 判据:里面**只有**我们自己的产物,且至少有一个 `*.tool.txt`。用户素材目录装的是 mp4/jpg,不会是这个形态;
360
+ // 要求「至少一个 tool.txt」是给「名字不像 URL 的目录」加门槛,空目录仍只认名字像 URL 的。
320
361
  function looksLikeOwnUrlDir(p, name) {
321
- if (!/^https?/i.test(name)) return false
322
362
  let ents
323
363
  try { ents = readdirSync(p, { withFileTypes: true }) } catch { return false }
324
- if (!ents.length) return true
325
- return ents.every((e) => e.isDirectory()
326
- ? OWN_CACHE_DIRS.has(e.name)
327
- : /^(meta\.json|.*\.tool\.txt|.*\.lock)$/i.test(e.name))
364
+ if (!ents.length) return /^https?/i.test(name)
365
+ let hasToolTxt = false
366
+ for (const e of ents) {
367
+ if (e.isDirectory()) {
368
+ if (!OWN_CACHE_DIRS.has(e.name)) return false
369
+ continue
370
+ }
371
+ if (/\.tool\.txt$/i.test(e.name)) hasToolTxt = true
372
+ else if (!/^(meta\.json|.*\.lock)$/i.test(e.name)) return false
373
+ }
374
+ return hasToolTxt || /^https?/i.test(name)
328
375
  }
329
376
  function removeOwned(dir) {
330
377
  let files = 0
@@ -753,6 +800,9 @@ function dirStat(dir) {
753
800
  let files = 0
754
801
  const walk = (d) => {
755
802
  for (const e of readdirSync(d, { withFileTypes: true })) {
803
+ // 10-28:标记文件与运行日志不算「缓存占用」——面板「当前占用」应当等于「立即清除能清掉的量」,
804
+ // 否则清了以后永远显示 52 B + 日志大小,用户以为没清干净。
805
+ if (e.name === CACHE_MARK || e.name === '_logs') continue
756
806
  const p = join(d, e.name)
757
807
  if (e.isDirectory()) walk(p)
758
808
  else {
@@ -826,9 +876,12 @@ function writeDepsCfg(patch) {
826
876
  const next = { ...cur, ...patch }
827
877
  try {
828
878
  writeFileSync(p, JSON.stringify(next, null, 2), 'utf8')
879
+ // issue #2 P2-2(2026-10-28):resolveFfmpeg 有 5 分钟缓存,改了 ffmpeg 路径后 /status 还会
880
+ // 报旧路径 5 分钟。配置一写就清缓存,下一次 /status 立刻重探。
881
+ ffmpegProbeCache = { at: 0, path: '' }
829
882
  return { ok: true, data: next }
830
883
  } catch (e) {
831
- return { ok: false, error: String(e?.message || e) }
884
+ return { ok: false, why: String(e?.message || e) }
832
885
  }
833
886
  }
834
887
 
@@ -1009,7 +1062,13 @@ function depRows() {
1009
1062
  id: 'ytdlp',
1010
1063
  name: 'yt-dlp(通用 URL)',
1011
1064
  ok: existsSync(ytdlp),
1012
- detail: existsSync(ytdlp) ? `版本 ${ytdlpVersion() || '未知'}` : '没找到,非 B 站 URL 不可用',
1065
+ // 2026-10-28:原先 detail 只写「版本 X」,与其它依赖行(detail 都是路径)不一致,
1066
+ // 面板上就变成「只有版本号、看不出装在哪」。改成路径,版本拼在括号里。
1067
+ detail: (() => {
1068
+ if (!existsSync(ytdlp)) return '没找到,非 B 站 URL 不可用'
1069
+ const v = ytdlpVersion()
1070
+ return `${maskAbs(ytdlp)}${v ? `(v${v})` : ''}`
1071
+ })(),
1013
1072
  path: maskAbs(ytdlp),
1014
1073
  removable: true,
1015
1074
  size: ytdlpSize,
@@ -1087,7 +1146,7 @@ function runDownload(target) {
1087
1146
  try {
1088
1147
  child = spawn(cmd, args, { windowsHide: true, stdio: 'ignore', detached: true })
1089
1148
  } catch (e) {
1090
- return { started: false, error: String(e?.message ?? e) }
1149
+ return { started: false, why: String(e?.message ?? e) }
1091
1150
  }
1092
1151
  // detached 子进程不会把 spawn 失败同步抛给我们,必须自己接 error,否则 ENOENT 会带崩宿主
1093
1152
  child.on('error', (e) => {
@@ -1187,9 +1246,18 @@ function sameOriginBlocked(req) {
1187
1246
  const method = String(req.method || 'GET').toUpperCase()
1188
1247
  const mutating = method !== 'GET' && method !== 'HEAD'
1189
1248
  const origin = req.headers?.origin
1249
+ const site = req.headers?.["sec-fetch-site"]
1190
1250
  // 14-6:写操作必须带 Origin 且同源。不带 Origin 的(curl 直打、旧浏览器)一律拒——
1191
1251
  // 原本 `if (origin)` 是可选的,等于裸 POST 没有任何同源校验。
1192
- if (mutating && !origin) return "跨站写请求被拒绝(缺少 Origin 头)"
1252
+ // 2026-10-28 issue #2 P1-1 修正:DSH 桌面端壳层的同源 fetch **不带 Origin**(实测复现:
1253
+ // 无 Origin 的 POST → 403「缺少 Origin 头」,面板所有写操作全废)。改成有 Origin 才严格同源,
1254
+ // 没 Origin 时只拒 sec-fetch-site 明确写着跨站的(cross-site / same-site),
1255
+ // 其余交给下面「必须 application/json」那条兜——CSRF 的简单请求向量走不到这里。
1256
+ if (mutating && !origin) {
1257
+ if (site === "cross-site" || site === "same-site") {
1258
+ return `跨站写请求被拒绝(缺少 Origin 且 sec-fetch-site=${site})`
1259
+ }
1260
+ }
1193
1261
  if (origin) {
1194
1262
  let oh = ""
1195
1263
  try { oh = new URL(origin).hostname } catch { return "非法 Origin 头" }
@@ -1201,7 +1269,7 @@ function sameOriginBlocked(req) {
1201
1269
  const ct = String(req.headers?.['content-type'] || '').split(';')[0].trim().toLowerCase()
1202
1270
  if (ct !== 'application/json') return `跨站写请求被拒绝(Content-Type 必须是 application/json,收到:${ct || '无'})`
1203
1271
  }
1204
- const site = req.headers?.["sec-fetch-site"]
1272
+ // site 在上面写操作分支已取过(2026-10-28 issue #2 P1-1),这里直接复用,不要重复声明。
1205
1273
  if (site && site !== "same-origin" && site !== "none") return `跨站请求被拒绝(sec-fetch-site=${site})`
1206
1274
  return null
1207
1275
  }
@@ -1590,7 +1658,8 @@ function apply(ctx) {
1590
1658
  },
1591
1659
  }))
1592
1660
  } else {
1593
- console.warn('[dsh-bili-watch] 未找到 @deepseek-ai/dsh-tools,bili_watch 工具未注册(/bili 命令与插件面板不受影响)')
1661
+ toolsWarning = '[dsh-bili-watch] 未找到 @deepseek-ai/dsh-tools,bili_watch 工具未注册(/bili 命令与插件面板不受影响)'
1662
+ console.warn(toolsWarning)
1594
1663
  }
1595
1664
 
1596
1665
  // ③ 设置页:读盘状态 + 清缓存(只读优先;唯一写操作是删本机抽帧缓存目录)
@@ -1604,7 +1673,7 @@ function apply(ctx) {
1604
1673
  path: '/api/bili-watch',
1605
1674
  handler: async (req, res) => {
1606
1675
  const blocked = sameOriginBlocked(req)
1607
- if (blocked) return sendJson(res, 403, { ok: false, error: blocked })
1676
+ if (blocked) return sendJson(res, 403, { ok: false, why: blocked })
1608
1677
  try {
1609
1678
  const url = new URL(req.url, 'http://127.0.0.1')
1610
1679
  if (req.method === 'GET' && url.pathname === '/api/bili-watch/status') {
@@ -1620,7 +1689,7 @@ function apply(ctx) {
1620
1689
  tuningRanges: tuningRanges(),
1621
1690
  lastRun: readLastRun(),
1622
1691
  ui: uiCfg(),
1623
- warnings: cfgWarning ? [cfgWarning] : [],
1692
+ warnings: [cfgWarning, toolsWarning].filter(Boolean),
1624
1693
  },
1625
1694
  })
1626
1695
  }
@@ -1639,7 +1708,7 @@ function apply(ctx) {
1639
1708
  // 6-17:写 deps-config.json 可能失败(文件只读等),原来直接抛错被外层吞成 500,
1640
1709
  // 面板可能显示已保存;这里显式回「保存失败」。
1641
1710
  const w = writeDepsCfg({ cache: dir })
1642
- if (!w.ok) return sendJson(res, 500, { ok: false, why: `保存失败:${w.error}` })
1711
+ if (!w.ok) return sendJson(res, 500, { ok: false, why: `保存失败:${w.why || '未知错误'}` })
1643
1712
  return sendJson(res, 200, { ok: true, data: { dir, restart: true } })
1644
1713
  } catch (e) {
1645
1714
  return sendJson(res, 500, { ok: false, why: String(e?.message || e) })
@@ -1727,7 +1796,7 @@ function apply(ctx) {
1727
1796
  let tuneErr = null
1728
1797
  if (TUNING_KEYS.some((k) => body && Object.prototype.hasOwnProperty.call(body, k))) {
1729
1798
  const w = writeTuning(body)
1730
- if (!w.ok) tuneErr = w.error
1799
+ if (!w.ok) tuneErr = w.why || '未知错误'
1731
1800
  }
1732
1801
  if (tuneErr) return sendJson(res, 500, { ok: false, why: `参数保存失败:${tuneErr}` })
1733
1802
  return sendJson(res, 200, { ok: true, data: { ...next, ...(rule || {}), tuning: readTuning() } })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bili-watch",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "description": "把 B 站视频读成文本给 DSH agent 用:元数据、评论、弹幕、字幕、本地转写、抽帧 OCR;支持 b23.tv 短链与 av 号,抽帧缓存目录可换。只读命令零依赖。",
6
6
  "type": "module",
@@ -21,7 +21,9 @@
21
21
  · -DryRun 只打印将执行的命令,不下载不写盘。
22
22
  · 国内网络用 -Mirror cn:GitHub 走 gh-proxy、PyPI 走清华、模型走 hf-mirror。
23
23
  · ffmpeg 来源是 BtbN 的 latest 构建,版本随上游变化,不锁具体版本。
24
- · pip 包锁死三版本(本机实测能跑):faster-whisper==1.2.1、av==15.1.0、
24
+ · pip 包锁死四版本(本机实测能跑):faster-whisper==1.2.1、av==15.1.0、
25
+ ctranslate2==4.8.3(10-28 issue #2 P3-2:4.8.4 在部分机器加载模型即 0xC0000005 硬崩,
26
+ 自己没有显式依赖,是 faster-whisper 拖进来的,所以必须自己钉)、
25
27
  rapidocr-onnxruntime==1.2.3。yt-dlp 不锁版本,必须跟上游走。
26
28
  · 二进制不锁版本,但装完会把 ffmpeg -version 首行与 yt-dlp --version 记进
27
29
  deps\last-run.json(便于对账;该文件同时是运行时记录,下次跑 CLI 会被覆盖)。
@@ -137,14 +139,14 @@ function Ensure-Asr {
137
139
  Write-Step 'ASR 环境(deps\.venv-asr + faster-whisper==1.2.1)'
138
140
  if ($DryRun) {
139
141
  Write-Host " [dry-run] <系统 Python> -m venv `"$venv`""
140
- Write-Host " [dry-run] `"$py`" -m pip install `"faster-whisper==1.2.1`" `"av==15.1.0`" -i $PyIndex"
142
+ Write-Host " [dry-run] `"$py`" -m pip install `"faster-whisper==1.2.1`" `"av==15.1.0`" `"ctranslate2==4.8.3`" -i $PyIndex"
141
143
  return
142
144
  }
143
145
  $sysPy = Get-SystemPython
144
146
  & $sysPy -m venv $venv
145
147
  if ($LASTEXITCODE -ne 0) { throw '创建 venv 失败' }
146
148
  & $py -m pip install --upgrade pip -i $PyIndex
147
- & $py -m pip install "faster-whisper==1.2.1" "av==15.1.0" -i $PyIndex
149
+ & $py -m pip install "faster-whisper==1.2.1" "av==15.1.0" "ctranslate2==4.8.3" -i $PyIndex
148
150
  if ($LASTEXITCODE -ne 0) {
149
151
  # 本次失败立刻清掉半成品,别留给下次 Skip 继续踩
150
152
  Remove-Item -LiteralPath $venv -Recurse -Force -ErrorAction SilentlyContinue