@orderg-x/dsh-video-preview 0.5.3 → 0.5.4
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/lib/client.js +50 -6
- package/lib/index.js +81 -14
- package/package.json +1 -1
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:
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (!
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
227
|
-
|
|
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)
|
|
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
|
-
|
|
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',
|