@orderg-x/dsh-video-preview 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  装完**重启 DSH**(客户端代码在 DSH 启动时打包,不重启看不到播放器)。DSH「插件」页里显示为「视频播放」。
40
40
 
41
- **更新**:在「插件」页把它卸载,再按上面添加一次。新版发布后大约一天按包名才装得到(DSH 装插件用的 pnpm 默认不装刚发布的版本),急的话填发布包地址。
41
+ **更新**:DSH 0.2.2 起,在「插件」页点开它,点「更新到 …」;更老的 DSH 先卸载,再按上面添加一次。新版发布后大约一天才装得到(DSH 装插件用的 pnpm 默认不装刚发布的版本);急的话卸载后填发布包地址,不过这样装的以后不能点更新。
42
42
 
43
43
  > **从 0.3.x 升级**:0.4.0 起包名改成 `@orderg-x/dsh-video-preview`(npm 上的 `dsh-video-preview` 是别人的包)。装上新版后,到「插件」页把旧的「dsh-video-preview」移除,再重启 DSH。旧版还在时新版不会启动,旧版照常能用;以前对话里的视频链接都还能播。
44
44
 
package/lib/client.js CHANGED
@@ -191,6 +191,46 @@ window.__ModuleLoader__.load({
191
191
  try { return new URL(url, location.href).searchParams.get('p') || '' } catch (_) { return '' }
192
192
  }
193
193
 
194
+ // DSH 桌面版的页面在 dsh-app:// 下,媒体请求经它转发;Chromium 认定这种非 http 的来源不能跳转,
195
+ // 进度条一拖就弹回去。桌面版下先问服务端要一个直连本机的地址,拿不到就照旧走转发。
196
+ const VIA_SHELL = (() => { try { return !/^https?:$/.test(new URL(location.href).protocol) } catch (_) { return false } })()
197
+ const directs = new Map()
198
+
199
+ function directUrl(url) {
200
+ const p = pathOfUrl(url)
201
+ if (!VIA_SHELL || !p || !/\/dsh-video-preview\/(media|preview)\?/.test(url)) return Promise.resolve('')
202
+ if (!directs.has(p)) {
203
+ directs.set(p, fetch('/dsh-video-preview/direct?p=' + encodeURIComponent(p), { credentials: 'same-origin', cache: 'no-store' })
204
+ .then((r) => (r.ok ? r.json() : null))
205
+ .then((j) => (j && typeof j.src === 'string' ? j.src : ''))
206
+ .catch(() => ''))
207
+ }
208
+ return directs.get(p)
209
+ }
210
+
211
+ /**
212
+ * 播放器的 src:桌面版换成直连地址,拿到之前先不给 src(免得先按转发地址加载一遍)。
213
+ * 返回 [src, fallback]:直连地址出错时调 fallback() 退回原地址,退过了就返回 false。
214
+ */
215
+ function usePlayable(url) {
216
+ const [s, setS] = react.useState({ url, src: VIA_SHELL ? '' : url, direct: false })
217
+ react.useEffect(() => {
218
+ if (!VIA_SHELL) return
219
+ let alive = true
220
+ setS({ url, src: '', direct: false })
221
+ directUrl(url).then((d) => { if (alive) setS({ url, src: d || url, direct: !!d }) })
222
+ return () => { alive = false }
223
+ }, [url])
224
+ const src = s.url === url ? s.src : (VIA_SHELL ? '' : url)
225
+ const fallback = () => {
226
+ if (s.url !== url || !s.direct) return false
227
+ directs.delete(pathOfUrl(url))
228
+ setS({ url, src: url, direct: false })
229
+ return true
230
+ }
231
+ return [src, fallback]
232
+ }
233
+
194
234
  /** shell 单引号:文件名里的 $(...)、反引号、双引号都原样当字面,单引号写成 '\'' */
195
235
  function shq(s) {
196
236
  return "'" + String(s).replace(/'/g, "'\\''") + "'"
@@ -262,6 +302,7 @@ window.__ModuleLoader__.load({
262
302
  const ref = react.useRef(null)
263
303
  const [reason, onError, setReason] = useMediaError(media.url)
264
304
  const meta = useProbe(media.url, setReason)
305
+ const [src, fallback] = usePlayable(media.url)
265
306
  const [state, setState] = react.useState({ active: false, previewing: false, duration: 0, at: 0 })
266
307
  const set = (patch) => setState((s) => ({ ...s, ...patch }))
267
308
  if (reason) return h(ErrorCard, { reason, media, t })
@@ -310,7 +351,7 @@ window.__ModuleLoader__.load({
310
351
  ref,
311
352
  className: 'dvp-media',
312
353
  // #t=0.1:没开播时也画出第一帧当封面,不是一块黑
313
- src: media.url + '#t=0.1',
354
+ src: src ? src + '#t=0.1' : undefined,
314
355
  controls: state.active,
315
356
  autoPlay: !!media.autoplay,
316
357
  loop: !!media.loop,
@@ -320,7 +361,7 @@ window.__ModuleLoader__.load({
320
361
  onDurationChange: (e) => set({ duration: e.currentTarget.duration || 0 }),
321
362
  onTimeUpdate: (e) => { if (state.previewing) set({ at: e.currentTarget.currentTime || 0 }) },
322
363
  onPlay: (e) => { if (!e.currentTarget.muted) { solo(e.currentTarget); if (!state.active) set({ active: true, previewing: false }) } },
323
- onError,
364
+ onError: () => { if (!fallback()) onError() },
324
365
  }),
325
366
  h('span', { className: 'dvp-play' }, icon(PLAY, 18)),
326
367
  h('div', { className: 'dvp-top' },
@@ -341,6 +382,7 @@ window.__ModuleLoader__.load({
341
382
  const trackRef = react.useRef(null)
342
383
  const [reason, onError, setReason] = useMediaError(media.url)
343
384
  const meta = useProbe(media.url, setReason)
385
+ const [src, fallback] = usePlayable(media.url)
344
386
  const [state, setState] = react.useState({ playing: false, duration: 0, at: 0, drag: false })
345
387
  const set = (patch) => setState((s) => ({ ...s, ...patch }))
346
388
  if (reason) return h(ErrorCard, { reason, media, t })
@@ -370,7 +412,7 @@ window.__ModuleLoader__.load({
370
412
  h('audio', {
371
413
  ref,
372
414
  className: 'dvp-media',
373
- src: media.url,
415
+ src: src || undefined,
374
416
  preload: 'metadata',
375
417
  autoPlay: !!media.autoplay,
376
418
  loop: !!media.loop,
@@ -380,7 +422,7 @@ window.__ModuleLoader__.load({
380
422
  onPlay: (e) => { solo(e.currentTarget); set({ playing: true }) },
381
423
  onPause: () => set({ playing: false }),
382
424
  onEnded: () => set({ playing: false, at: 0 }),
383
- onError,
425
+ onError: () => { if (!fallback()) onError() },
384
426
  }),
385
427
  h('button', {
386
428
  className: 'dvp-btn', type: 'button', onClick: toggle,
@@ -566,6 +608,8 @@ window.__ModuleLoader__.load({
566
608
  const p = hostPath || (isAbsPath(raw) ? raw : '')
567
609
  const src = p ? '/dsh-video-preview/preview?p=' + encodeURIComponent(p) : ''
568
610
  const [reason, onError] = useMediaError(src)
611
+ const [playSrc, fallback] = usePlayable(src)
612
+ const onMediaError = () => { if (!fallback()) onError() }
569
613
  if (!p) {
570
614
  // 相对路径还在等宿主解析 / 宿主说文件不在 / 压根没有路径
571
615
  const status = res ? res.status : ''
@@ -579,9 +623,9 @@ window.__ModuleLoader__.load({
579
623
  }
580
624
  const player = audio
581
625
  ? h('div', { style: { flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', padding: '0 16px' } },
582
- h('audio', { src, controls: true, preload: 'metadata', onError, className: 'dvp-media', style: { width: '100%' } }))
626
+ h('audio', { src: playSrc || undefined, controls: true, preload: 'metadata', onError: onMediaError, className: 'dvp-media', style: { width: '100%' } }))
583
627
  : h('video', {
584
- src, controls: true, playsInline: true, preload: 'metadata', onError, className: 'dvp-media',
628
+ src: playSrc || undefined, controls: true, playsInline: true, preload: 'metadata', onError: onMediaError, className: 'dvp-media',
585
629
  onPlay: (e) => solo(e.currentTarget),
586
630
  style: { flex: 1, width: '100%', minHeight: 0, objectFit: 'contain', background: '#000' },
587
631
  })
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * 照官方 present 工具的机制 append deliverables/presented 事件
9
9
  * 路由 /dsh-video-preview/media 签名 token 鉴权,HTTP Range 流式(能拖进度条)
10
10
  * /dsh-video-preview/preview 宿主登录态鉴权(DSH 自己的 connection 服务验 cookie),供侧边栏/turnTail 用
11
+ * /dsh-video-preview/direct 桌面版专用:登录后换一个直连本机的短期地址(见 direct 处理函数)
11
12
  * 自动 这一轮新生成的媒体(录屏、转码、TTS…),模型不调工具也自动呈现(见 auto-present.js)
12
13
  * 客户端 turnTail 槽 → 播放器渲染在最终输出正下方(Claude/Codex 同款位置)
13
14
  * documentPreviews 渲染器 → 侧边栏预览面板变播放器
@@ -18,7 +19,8 @@
18
19
  * 把门(webServer 本身不鉴权,浏览器里任何网页都摸得到 127.0.0.1):
19
20
  * - 两条路由都只给媒体文件(按扩展名,软链接按真实文件算),别的文件一律不给
20
21
  * - Host 必须是本机名或 DSH 自己的地址(防 DNS 重绑定);跨站请求、Origin 不是自己的都拒
21
- * - preview 没有 token,靠 DSH 的登录 cookie;拿不到 connection 服务时只收本机连接
22
+ * (direct 换来的直连地址例外:桌面版的请求一律跨站,只查 Host,靠登录后才拿得到、七天过期的 token)
23
+ * - preview、direct 没有 token,靠 DSH 的登录 cookie;拿不到 connection 服务时只收本机连接
22
24
  */
23
25
 
24
26
  import fs from 'node:fs'
@@ -56,6 +58,9 @@ export function oldCopyLoaded(ctx) {
56
58
 
57
59
  const ROUTE = '/dsh-video-preview/media'
58
60
  const PREVIEW_ROUTE = '/dsh-video-preview/preview'
61
+ const DIRECT_ROUTE = '/dsh-video-preview/direct'
62
+ /** 直连地址的有效期(秒):只活在打开着的页面里,不写进会话历史 */
63
+ const DIRECT_TTL = 7 * 24 * 3600
59
64
 
60
65
  const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
61
66
  const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
@@ -86,9 +91,25 @@ function tokenFor(file) {
86
91
  }
87
92
 
88
93
  function tokenOk(file, t) {
89
- const want = Buffer.from(tokenFor(file))
90
- const got = Buffer.from(String(t || ''))
91
- return got.length === want.length && crypto.timingSafeEqual(got, want)
94
+ return sameToken(tokenFor(file), t)
95
+ }
96
+
97
+ function sameToken(want, got) {
98
+ const a = Buffer.from(want)
99
+ const b = Buffer.from(String(got || ''))
100
+ return a.length === b.length && crypto.timingSafeEqual(a, b)
101
+ }
102
+
103
+ /** 直连 token:和到期时间绑在一起,跟会话历史里的 t 不是一回事 */
104
+ function directTokenFor(file, exp) {
105
+ return crypto.createHmac('sha256', SECRET).update(`direct\n${exp}\n${file}`).digest('hex').slice(0, 32)
106
+ }
107
+
108
+ function directOk(file, d, e) {
109
+ const exp = Number(e)
110
+ const now = Math.floor(Date.now() / 1000)
111
+ if (!Number.isInteger(exp) || exp < now || exp > now + DIRECT_TTL + 60) return false
112
+ return sameToken(directTokenFor(file, exp), d)
92
113
  }
93
114
 
94
115
  // DSH 的播放器是 Chromium 内核,这些能直接播(mkv / 3gp 要看里面的编码,播不了前端会说清楚)
@@ -152,10 +173,16 @@ function ownHost(ctx) {
152
173
  try { return new URL(ctx.webServer.getOrigin()).host } catch { return '' }
153
174
  }
154
175
 
176
+ /** Host 必须是本机名或 DSH 自己的地址(防 DNS 重绑定) */
177
+ function hostOk(req, own) {
178
+ const host = String(req.headers.host || '')
179
+ return LOOPBACK_HOST.test(host) || !!(own && host === own)
180
+ }
181
+
155
182
  /** Host / Sec-Fetch-Site / Origin 三道,照 DSH 自家路由的规矩 */
156
183
  function fenceOk(req, own) {
157
184
  const host = String(req.headers.host || '')
158
- if (!LOOPBACK_HOST.test(host) && !(own && host === own)) return false
185
+ if (!hostOk(req, own)) return false
159
186
  if (req.headers['sec-fetch-site'] === 'cross-site') return false
160
187
  const origin = req.headers.origin
161
188
  if (origin === undefined) return true
@@ -214,32 +241,67 @@ async function mediaFile(file, res) {
214
241
  // ── 两条路由 ────────────────────────────────────────────────────────────────
215
242
 
216
243
  function makeHandlers(ctx) {
217
- /** 带签名的地址:token 对得上才给(工具输出里的链接、工具块里的小播放器走这条) */
244
+ /**
245
+ * 带签名的地址:token 对得上才给(工具输出里的链接、工具块里的小播放器走这条)。
246
+ * d + e 是 direct 换来的直连地址:桌面版页面在 dsh-app:// 下,这种请求一律是跨站,
247
+ * 所以只查 Host 不查跨站;token 只有登录过的页面拿得到,七天就过期。
248
+ */
218
249
  async function media(req, res) {
219
250
  const url = new URL(req.url, 'http://127.0.0.1')
220
251
  const file = url.searchParams.get('p') || ''
221
- if (!fenceOk(req, ownHost(ctx)) || !tokenOk(file, url.searchParams.get('t'))) return reply(res, 403, 'forbidden')
252
+ const d = url.searchParams.get('d')
253
+ const ok = d === null
254
+ ? fenceOk(req, ownHost(ctx)) && tokenOk(file, url.searchParams.get('t'))
255
+ : hostOk(req, ownHost(ctx)) && directOk(file, d, url.searchParams.get('e'))
256
+ if (!ok) return reply(res, 403, 'forbidden')
222
257
  const f = await mediaFile(file, res)
223
258
  if (f) stream(req, res, f)
224
259
  }
225
260
 
226
- /** 没 token 的地址(turnTail、侧边栏只知道路径):要 DSH 的登录态 */
227
- async function preview(req, res) {
228
- const url = new URL(req.url, 'http://127.0.0.1')
229
- const file = url.searchParams.get('p') || ''
261
+ /** 要 DSH 的登录态;拿不到 connection 服务时只收本机、同站的连接。不过关就回好错误,返回 false */
262
+ function signedIn(req, res) {
230
263
  const conn = connectionOf(ctx)
231
264
  if (conn) {
232
265
  const rejected = conn.requestRejection(req)
233
- if (rejected) return reply(res, rejected, rejected === 401 ? 'unauthorized' : 'forbidden')
266
+ if (rejected) { reply(res, rejected, rejected === 401 ? 'unauthorized' : 'forbidden'); return false }
234
267
  } else if (!LOOPBACK.has(req.socket.remoteAddress) || !fenceOk(req, ownHost(ctx))) {
235
- return reply(res, 403, 'forbidden')
268
+ reply(res, 403, 'forbidden')
269
+ return false
236
270
  }
271
+ return true
272
+ }
273
+
274
+ /** 没 token 的地址(turnTail、侧边栏只知道路径):要 DSH 的登录态 */
275
+ async function preview(req, res) {
276
+ const url = new URL(req.url, 'http://127.0.0.1')
277
+ const file = url.searchParams.get('p') || ''
278
+ if (!signedIn(req, res)) return
237
279
  const f = await mediaFile(file, res)
238
280
  if (f) stream(req, res, f)
239
281
  }
240
282
 
283
+ /**
284
+ * 桌面版专用:给一个直连本机的地址。
285
+ * 桌面版的页面在 dsh-app:// 下,媒体请求经它转发后,Chromium 认定这种非 http 的来源不能跳转
286
+ * (seekable 只有 0),进度条一拖就弹回去。直接走 http://127.0.0.1 就正常。
287
+ * 要登录态(和 preview 一样),只给媒体文件;宿主不在本机地址上就不给,前端照旧走转发。
288
+ */
289
+ async function direct(req, res) {
290
+ const url = new URL(req.url, 'http://127.0.0.1')
291
+ const file = url.searchParams.get('p') || ''
292
+ if (!signedIn(req, res)) return
293
+ if (!file || !path.isAbsolute(file) || !kindOf(file)) return reply(res, 403, 'forbidden')
294
+ let origin = ''
295
+ try { origin = new URL(ctx.webServer.getOrigin()).origin } catch { /* 还没开始监听 */ }
296
+ if (!/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(origin)) return reply(res, 404, 'no direct origin')
297
+ const exp = Math.floor(Date.now() / 1000) + DIRECT_TTL
298
+ const src = `${origin}${ROUTE}?d=${directTokenFor(file, exp)}&e=${exp}&p=${encodeURIComponent(file)}`
299
+ res.writeHead(200, { ...MARK, 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
300
+ res.end(JSON.stringify({ src }))
301
+ }
302
+
241
303
  // 宿主会 await 处理函数、接住抛出的错(读盘的错 mediaFile 自己已经接住)
242
- return { media, preview }
304
+ return { media, preview, direct }
243
305
  }
244
306
 
245
307
  /** 只认单段 Range;格式不对或多段就当没带(回整个文件),起点越界才 416 */
@@ -331,6 +393,11 @@ export function apply(ctx) {
331
393
  path: PREVIEW_ROUTE,
332
394
  handler: handlers.preview,
333
395
  }), 'dsh-video-preview: preview route')
396
+ ctx.effect(() => ctx.webServer.register({
397
+ kind: 'exact',
398
+ path: DIRECT_ROUTE,
399
+ handler: handlers.direct,
400
+ }), 'dsh-video-preview: direct route')
334
401
 
335
402
  const tool = defineTool({
336
403
  name: 'play_video',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orderg-x/dsh-video-preview",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1 test/*.mjs"