dsh-vidi 0.8.9 → 0.9.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.
Files changed (3) hide show
  1. package/lib/client.js +219 -50
  2. package/lib/index.mjs +182 -1
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -358,6 +358,17 @@ const Icon = {
358
358
  rx: "2"
359
359
  })
360
360
  }),
361
+ timer: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
362
+ viewBox: "0 0 24 24",
363
+ width: "18",
364
+ height: "18",
365
+ ...P,
366
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
367
+ cx: "12",
368
+ cy: "13.5",
369
+ r: "7.5"
370
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 9.5v4l2.5 1.5M10 3h4M12 3v3" })]
371
+ }),
361
372
  live: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
362
373
  viewBox: "0 0 24 24",
363
374
  width: "16",
@@ -1062,7 +1073,7 @@ const BGS = {
1062
1073
  * position and background.
1063
1074
  */
1064
1075
  function SubtitleMenu(props) {
1065
- const { lines, onLines, options, site, name, style, onStyle, t } = props;
1076
+ const { lines, onLines, options, site, name, style, onStyle, spoken, t } = props;
1066
1077
  const main = lines[0] ?? "src";
1067
1078
  const second = lines[1] ?? "";
1068
1079
  const label = (k) => site.find((s) => s.key === k)?.label ?? name(k);
@@ -1127,6 +1138,21 @@ function SubtitleMenu(props) {
1127
1138
  className: "swap",
1128
1139
  onClick: () => onLines([second, main]),
1129
1140
  children: ["⇅ ", t("sub.swapMain")]
1141
+ }),
1142
+ spoken && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1143
+ className: "slot",
1144
+ title: t("set.spoken.tip"),
1145
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.spoken") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
1146
+ value: spoken.value,
1147
+ onChange: spoken.onChange,
1148
+ options: [{
1149
+ value: "auto",
1150
+ label: t("set.auto")
1151
+ }, ...spoken.options.map(([k, v]) => ({
1152
+ value: k,
1153
+ label: v
1154
+ }))]
1155
+ })]
1130
1156
  })
1131
1157
  ]
1132
1158
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -34897,7 +34923,7 @@ async function post$1(path, body) {
34897
34923
  return j;
34898
34924
  }
34899
34925
  function Settings(props) {
34900
- const { engine, onEngine, mode, onMode, spoken, onSpoken, langOptions, login, onLogin, browsers, providers, shared, name, onKey, disabled, open, onOpen, update, onCheckUpdate, t } = props;
34926
+ const { engine, onEngine, login, onLogin, browsers, providers, shared, name, onKey, disabled, open, onOpen, update, onCheckUpdate, t } = props;
34901
34927
  const [checking, setChecking] = (0, react.useState)(false);
34902
34928
  const [checked, setChecked] = (0, react.useState)(false);
34903
34929
  const [installing, setInstalling] = (0, react.useState)(false);
@@ -35209,43 +35235,6 @@ function Settings(props) {
35209
35235
  })
35210
35236
  ]
35211
35237
  }),
35212
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35213
- className: "srow col",
35214
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
35215
- className: "pair",
35216
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.mode") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35217
- className: "dshl-seg",
35218
- children: [
35219
- "live",
35220
- "realtime",
35221
- "polished"
35222
- ].map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35223
- className: mode === m ? "on" : "",
35224
- title: t(`mode.${m}.tip`),
35225
- onClick: () => onMode(m),
35226
- children: t(`mode.${m}`)
35227
- }, m))
35228
- })]
35229
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35230
- className: "hint",
35231
- children: t(`mode.${mode}.tip`)
35232
- })]
35233
- }),
35234
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35235
- className: "srow",
35236
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("set.spoken") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
35237
- value: spoken,
35238
- disabled,
35239
- onChange: onSpoken,
35240
- options: [{
35241
- value: "auto",
35242
- label: t("set.auto")
35243
- }, ...langOptions.map(([k, v]) => ({
35244
- value: k,
35245
- label: v
35246
- }))]
35247
- })]
35248
- }),
35249
35238
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
35250
35239
  className: "srow",
35251
35240
  title: t("set.login.tip"),
@@ -35387,6 +35376,92 @@ function useDevices(enabled) {
35387
35376
  }, [enabled, devices]);
35388
35377
  return devices;
35389
35378
  }
35379
+ /** "3 分钟前", "5 小时前", "2 天前", then the date. */
35380
+ function ago(ts, ui) {
35381
+ if (!ts) return "";
35382
+ const s = Math.max(0, (Date.now() - ts) / 1e3);
35383
+ if (s < 60) return ui === "zh" ? "刚刚" : "just now";
35384
+ const [n, unit] = s < 3600 ? [Math.round(s / 60), ui === "zh" ? " 分钟前" : " min ago"] : s < 86400 ? [Math.round(s / 3600), ui === "zh" ? " 小时前" : " h ago"] : s < 604800 ? [Math.round(s / 86400), ui === "zh" ? " 天前" : " d ago"] : [0, ""];
35385
+ if (unit) return `${n}${unit}`;
35386
+ return new Date(ts).toLocaleDateString(ui === "zh" ? "zh-CN" : "en-US", {
35387
+ month: "short",
35388
+ day: "numeric"
35389
+ });
35390
+ }
35391
+ /** Past videos under the start box: the newest few, more on request. */
35392
+ function History({ t, ui, onOpen }) {
35393
+ const [items, setItems] = (0, react.useState)([]);
35394
+ const [total, setTotal] = (0, react.useState)(0);
35395
+ const load = (offset, limit) => fetch(`/live/api/history?offset=${offset}&limit=${limit}`).then((r) => r.json()).then((j) => {
35396
+ setItems((v) => offset ? [...v, ...j.items ?? []] : j.items ?? []);
35397
+ setTotal(j.total ?? 0);
35398
+ }).catch(() => {});
35399
+ (0, react.useEffect)(() => {
35400
+ load(0, 5);
35401
+ }, []);
35402
+ if (!items.length) return null;
35403
+ const from = (v) => v.source === "file" ? t("src.file") : v.source === "device" ? t("src.camera") : v.source === "push" ? t("src.push") : siteName(v.url ?? "") || (() => {
35404
+ try {
35405
+ return new URL(v.url ?? "").hostname.replace(/^www\./, "");
35406
+ } catch {
35407
+ return t("src.link");
35408
+ }
35409
+ })();
35410
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
35411
+ className: "dshl-history",
35412
+ children: [
35413
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35414
+ className: "hh",
35415
+ children: t("hist.title")
35416
+ }),
35417
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
35418
+ className: "hl",
35419
+ children: items.map((v) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
35420
+ className: "hi",
35421
+ onClick: () => onOpen(v),
35422
+ children: [
35423
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
35424
+ className: "th",
35425
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
35426
+ src: `/live/api/thumb?jobId=${encodeURIComponent(v.id)}`,
35427
+ alt: "",
35428
+ onError: (e) => {
35429
+ e.currentTarget.style.visibility = "hidden";
35430
+ }
35431
+ }), v.duration ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35432
+ className: "dur",
35433
+ children: CLOCK(v.duration)
35434
+ }) : null]
35435
+ }),
35436
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
35437
+ className: "tx",
35438
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35439
+ className: "tt",
35440
+ children: v.title || (v.source === "push" ? from(v) : t("hist.untitled"))
35441
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35442
+ className: "mm",
35443
+ children: [
35444
+ v.title || v.source !== "push" ? from(v) : "",
35445
+ v.live ? t("hist.live") : "",
35446
+ ago(v.startedAt, ui)
35447
+ ].filter(Boolean).join(" · ")
35448
+ })]
35449
+ }),
35450
+ v.running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
35451
+ className: "chip",
35452
+ children: t("hist.running")
35453
+ })
35454
+ ]
35455
+ }, v.id))
35456
+ }),
35457
+ items.length < total && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
35458
+ className: "dshl-link more",
35459
+ onClick: () => void load(items.length, 10),
35460
+ children: t("hist.more").replace("{n}", String(total - items.length))
35461
+ })
35462
+ ]
35463
+ });
35464
+ }
35390
35465
  /** The push stream key: copy it, or change it (typed, or a new random one). */
35391
35466
  function KeyRow({ value, t, onChanged }) {
35392
35467
  const [edit, setEdit] = (0, react.useState)(null);
@@ -36134,6 +36209,22 @@ function LiveView(props) {
36134
36209
  return "camera";
36135
36210
  }
36136
36211
  };
36212
+ const openPast = async (v) => {
36213
+ setError("");
36214
+ try {
36215
+ if (v.conversationId !== sessionId && props.onOpenVideo && await props.onOpenVideo(v.conversationId, v.id)) return;
36216
+ const j = await post("reopen", {
36217
+ jobId: v.id,
36218
+ conversationId: sessionId
36219
+ });
36220
+ setCues(j.cues ?? []);
36221
+ setTerms(j.terms ?? []);
36222
+ setJob(j);
36223
+ setArmed(null);
36224
+ } catch (e) {
36225
+ setError(e.message);
36226
+ }
36227
+ };
36137
36228
  const emptyBox = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
36138
36229
  className: "dshl-drop",
36139
36230
  children: [
@@ -36143,6 +36234,11 @@ function LiveView(props) {
36143
36234
  children: sourceBar
36144
36235
  }),
36145
36236
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: srcTab === "camera" && devices && !mics.length ? t("src.cam.none") : t(srcTab === "file" ? "src.file.body" : srcTab === "camera" ? "src.cam.body" : srcTab === "push" ? "src.push.body" : "empty.body") }),
36237
+ pageMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(History, {
36238
+ t,
36239
+ ui,
36240
+ onOpen: (v) => void openPast(v)
36241
+ }),
36146
36242
  srcTab === "push" && armed?.rtmp && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36147
36243
  className: "dshl-pushinfo",
36148
36244
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dl", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyRow, {
@@ -36448,11 +36544,6 @@ function LiveView(props) {
36448
36544
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Settings, {
36449
36545
  engine: engine || "local",
36450
36546
  onEngine: setEngine,
36451
- mode,
36452
- onMode: changeMode,
36453
- spoken,
36454
- onSpoken: setSpoken,
36455
- langOptions,
36456
36547
  login,
36457
36548
  onLogin: chooseLogin,
36458
36549
  browsers,
@@ -36640,9 +36731,38 @@ function LiveView(props) {
36640
36731
  name: nameWithSpoken,
36641
36732
  style: subStyle,
36642
36733
  onStyle: setSubStyle,
36734
+ spoken: {
36735
+ value: spoken,
36736
+ options: langOptions,
36737
+ onChange: setSpoken
36738
+ },
36643
36739
  t
36644
36740
  }) })
36645
36741
  },
36742
+ ...!job.vod && job.status === "running" ? [{
36743
+ id: "mode",
36744
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.timer, {}),
36745
+ title: t("set.mode"),
36746
+ label: t(`mode.${mode}`),
36747
+ content: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
36748
+ className: "dshl-modemenu",
36749
+ children: [
36750
+ "live",
36751
+ "realtime",
36752
+ "polished"
36753
+ ].map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
36754
+ className: `mm ${mode === m ? "on" : ""}`,
36755
+ onClick: () => changeMode(m),
36756
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
36757
+ className: "n",
36758
+ children: [t(`mode.${m}`), mode === m && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.check, {})]
36759
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
36760
+ className: "d",
36761
+ children: t(`mode.${m}.tip`)
36762
+ })]
36763
+ }, m))
36764
+ })
36765
+ }] : [],
36646
36766
  {
36647
36767
  id: "restream",
36648
36768
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon.push, {}),
@@ -37479,6 +37599,12 @@ const en = {
37479
37599
  "dev.blocked": "No picture or sound from the device: DeepSeek Harness needs the system’s permission. Open System Settings, switch DeepSeek Harness on, then retry.",
37480
37600
  "dev.denied": "DeepSeek Harness needs permission to use the {d}. Open System Settings, switch it on in the list, then retry.",
37481
37601
  "dev.and": " and ",
37602
+ "hist.title": "Recent videos",
37603
+ "hist.untitled": "Untitled video",
37604
+ "hist.live": "live",
37605
+ "hist.running": "Live now",
37606
+ "hist.more": "More ({n})",
37607
+ "set.spoken.tip": "The language people speak in this video; Auto detects it.",
37482
37608
  "rs.mode": "Subtitle mode of the push-out",
37483
37609
  "rs.mode.live": "Pushed out about {s} s behind; subtitles come a little after the words.",
37484
37610
  "rs.mode.realtime": "Pushed out about {s} s behind, with first-pass subtitles in step.",
@@ -37927,6 +38053,12 @@ const zh = {
37927
38053
  "dev.blocked": "设备没有传来画面和声音:DeepSeek Harness 需要系统授权。点「打开系统设置」,在列表里打开 DeepSeek Harness,再点重试。",
37928
38054
  "dev.denied": "DeepSeek Harness 需要{d}权限。点「打开系统设置」,在列表里打开它,再点重试。",
37929
38055
  "dev.and": "和",
38056
+ "hist.title": "最近的视频",
38057
+ "hist.untitled": "未命名视频",
38058
+ "hist.live": "直播",
38059
+ "hist.running": "直播中",
38060
+ "hist.more": "更多(还有 {n} 个)",
38061
+ "set.spoken.tip": "视频里说的是哪种语言;自动 = 自己识别。",
37930
38062
  "rs.mode": "推流用的字幕模式",
37931
38063
  "rs.mode.live": "推出去比实时晚约 {s} 秒,字幕会比说话稍晚出现。",
37932
38064
  "rs.mode.realtime": "推出去比实时晚约 {s} 秒,带对齐好的初版字幕。",
@@ -38598,6 +38730,26 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
38598
38730
  .dshl-menu .dshl-seg button.on { background: rgba(255,255,255,.92); color: #111; box-shadow: none; }
38599
38731
  .dshl-menu .dshl-switch { background: rgba(255,255,255,.22); }
38600
38732
  .dshl-menu .dshl-switch.on { background: var(--accent); }
38733
+ .dshl-history { width: 100%; max-width: 680px; margin-top: 40px; text-align: left; }
38734
+ .dshl-history .hh { margin: 0 0 8px 4px; color: var(--faint); font-size: 12.5px; }
38735
+ .dshl-history .hl { display: grid; gap: 2px; }
38736
+ .dshl-history .hi { display: flex; align-items: center; gap: 12px; width: 100%; padding: 6px; border: 0; border-radius: 10px; background: none; color: var(--ink); text-align: left; cursor: pointer; }
38737
+ .dshl-history .hi:hover { background: var(--hover); }
38738
+ .dshl-history .th { position: relative; flex: none; width: 96px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: var(--l2); }
38739
+ .dshl-history .th img { display: block; width: 100%; height: 100%; object-fit: cover; }
38740
+ .dshl-history .dur { position: absolute; right: 4px; bottom: 3px; padding: 0 4px; border-radius: 3px; background: rgba(0,0,0,.65); color: #fff; font-size: 10.5px; line-height: 16px; font-variant-numeric: tabular-nums; }
38741
+ .dshl-history .tx { display: grid; gap: 3px; min-width: 0; flex: 1; }
38742
+ .dshl-history .tt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 550; }
38743
+ .dshl-history .mm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font-size: 12px; }
38744
+ .dshl-history .chip { flex: none; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--live) 14%, transparent); color: var(--live); font-size: 11.5px; font-weight: 600; }
38745
+ .dshl-history .more { margin: 8px 0 0 6px; font-size: 12.5px; }
38746
+ .dshl-modemenu { display: grid; gap: 4px; width: 300px; max-width: 100%; }
38747
+ .dshl-modemenu .mm { display: grid; gap: 3px; padding: 9px 10px; border: 0; border-radius: 8px; background: none; color: inherit; text-align: left; cursor: pointer; }
38748
+ .dshl-modemenu .mm:hover { background: rgba(255,255,255,.08); }
38749
+ .dshl-modemenu .mm.on { background: rgba(255,255,255,.12); }
38750
+ .dshl-modemenu .n { display: flex; align-items: center; justify-content: space-between; font-size: 13.5px; font-weight: 600; }
38751
+ .dshl-modemenu .n svg { width: 14px; height: 14px; }
38752
+ .dshl-modemenu .d { color: rgba(255,255,255,.62); font-size: 12px; line-height: 1.45; }
38601
38753
  .dshl-menu .opt { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 32px; padding: 0 6px; color: var(--muted); }
38602
38754
  .dshl-menu .opt .dshl-seg { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
38603
38755
  .dshl-menu .opt input[type=range] { flex: 1; max-width: 180px; accent-color: var(--accent); }
@@ -38965,7 +39117,11 @@ async function openPage(sessions, opts = {}) {
38965
39117
  }
38966
39118
  async function showPage(sessions, opts) {
38967
39119
  const current = sessions.current();
38968
- const fresh = () => pageSession(sessions, opts.workspaces, opts.title ?? "Video");
39120
+ const fresh = async () => {
39121
+ let id = await pageSession(sessions, opts.workspaces, opts.title ?? "Video");
39122
+ if (await fetch(`/live/api/job?conversationId=${encodeURIComponent(id)}`).then((r) => r.json()).catch(() => null)) id = await pageSession(sessions, opts.workspaces, opts.title ?? "Video", true);
39123
+ return id;
39124
+ };
38969
39125
  let sid = opts.sessionId && sessions.has(opts.sessionId) ? opts.sessionId : await fresh();
38970
39126
  if (sid !== current) try {
38971
39127
  sessions.open(sid);
@@ -39030,6 +39186,23 @@ function videoHandlers(sessions, workspaces, sessionId, fallbackTitle) {
39030
39186
  return false;
39031
39187
  }
39032
39188
  },
39189
+ /** Open a past video in its own conversation; false when that conversation is gone. */
39190
+ onOpenVideo: async (conversationId, jobId) => {
39191
+ if (!conversationId || !sessions.has(conversationId)) return false;
39192
+ await fetch("/live/api/reopen", {
39193
+ method: "POST",
39194
+ headers: { "content-type": "application/json" },
39195
+ body: JSON.stringify({
39196
+ jobId,
39197
+ conversationId
39198
+ })
39199
+ }).catch(() => {});
39200
+ await openPage(sessions, {
39201
+ sessionId: conversationId,
39202
+ workspaces
39203
+ });
39204
+ return true;
39205
+ },
39033
39206
  onTitle: (title) => {
39034
39207
  own().then((yes) => {
39035
39208
  const now = sessions.info?.(sessionId)?.title;
@@ -39115,11 +39288,7 @@ function SidebarButton(props) {
39115
39288
  const pct = item?.subs === "running" && item.duration ? Math.min(99, Math.round(item.at / item.duration * 100)) : void 0;
39116
39289
  const meta = !busy ? "" : item?.subs === "running" ? pct !== void 0 ? t("side.subs").replace("{p}", String(pct)) : t("side.subsLive") : item?.recording ? t("side.recording") : item?.loading ? t("side.loading") : t("side.downloading");
39117
39290
  const thumb = busy && frame && (!item || frame.jobId === item.jobId) ? frame.dataUrl : void 0;
39118
- const open = () => void openPage(sessions, item ? {
39119
- sessionId: item.conversationId,
39120
- workspaces,
39121
- title: t("view.live")
39122
- } : {
39291
+ const open = () => void openPage(sessions, {
39123
39292
  workspaces,
39124
39293
  title: t("view.live")
39125
39294
  });
package/lib/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
+ import { execFile, spawn } from "node:child_process";
1
2
  import { appendFileSync, chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
3
  import { homedir, networkInterfaces } from "node:os";
3
4
  import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
4
5
  import z from "@deepseek-ai/schemastery";
5
- import { execFile, spawn } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
7
  import { EventEmitter } from "node:events";
8
8
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -2111,6 +2111,12 @@ var LiveJob = class LiveJob extends EventEmitter {
2111
2111
  this.persist();
2112
2112
  this.emit("state", this.summary());
2113
2113
  }
2114
+ /** Show this video in another conversation (opened again from the history): saved, so it stays there. */
2115
+ rebind(conversationId) {
2116
+ this.opts.conversationId = conversationId;
2117
+ this.closed = false;
2118
+ this.persist();
2119
+ }
2114
2120
  /** job.json: enough to bring the job back after a restart (finished, not recording). */
2115
2121
  persist() {
2116
2122
  if (this.opts.source === "push" && this.message) return;
@@ -6430,6 +6436,137 @@ function apply(ctx, config) {
6430
6436
  };
6431
6437
  }
6432
6438
  /** The conversation's video: in memory, or the latest one on disk (after a restart). */
6439
+ /** A video by id: the running one, or brought back from its folder. */
6440
+ function loadJob(id) {
6441
+ if (jobs.has(id)) return jobs.get(id);
6442
+ if (!/^[\w-]+$/.test(id)) return void 0;
6443
+ const dir = join(config.dataDir, "jobs", id);
6444
+ const file = join(dir, "job.json");
6445
+ if (!existsSync(file)) return void 0;
6446
+ try {
6447
+ const raw = JSON.parse(readFileSync(file, "utf8"));
6448
+ let job;
6449
+ job = LiveJob.restore(dir, jobDeps(dir, raw.opts, () => job));
6450
+ if (job) jobs.set(job.id, job);
6451
+ return job;
6452
+ } catch {
6453
+ return;
6454
+ }
6455
+ }
6456
+ /** Past videos for the start page, newest first: those with something to watch. */
6457
+ const historyCache = /* @__PURE__ */ new Map();
6458
+ function history() {
6459
+ const root = join(config.dataDir, "jobs");
6460
+ if (!existsSync(root)) return [];
6461
+ const out = [];
6462
+ for (const name of readdirSync(root).filter((n) => /^[\w-]+$/.test(n)).sort().reverse()) {
6463
+ const dir = join(root, name), file = join(dir, "job.json");
6464
+ const live = jobs.get(name);
6465
+ if (live && live.summary().status === "running" && !live.summary().message) {
6466
+ const s = live.summary();
6467
+ out.push({
6468
+ id: s.id,
6469
+ title: s.title || (s.source === "url" ? s.url : ""),
6470
+ source: s.source,
6471
+ url: s.source === "url" ? s.url : void 0,
6472
+ startedAt: s.startedAt,
6473
+ duration: s.vod ? s.duration : s.recorded,
6474
+ live: !s.vod,
6475
+ running: true,
6476
+ conversationId: s.conversationId
6477
+ });
6478
+ continue;
6479
+ }
6480
+ let mtime = 0;
6481
+ try {
6482
+ mtime = statSync(file).mtimeMs;
6483
+ } catch {
6484
+ continue;
6485
+ }
6486
+ let hit = historyCache.get(name);
6487
+ if (!hit || hit.mtime !== mtime) {
6488
+ let item = null;
6489
+ try {
6490
+ const j = JSON.parse(readFileSync(file, "utf8"));
6491
+ if ((existsSync(join(dir, "recording.mp4")) || existsSync(join(dir, "hls", "index.m3u8"))) && j.status !== "error") {
6492
+ const device = j.opts.source === "device" ? (() => {
6493
+ try {
6494
+ return JSON.parse(j.opts.url).title;
6495
+ } catch {
6496
+ return "";
6497
+ }
6498
+ })() : "";
6499
+ let duration = j.duration;
6500
+ if (!duration) try {
6501
+ duration = [...readFileSync(join(dir, "hls", "index.m3u8"), "utf8").matchAll(/#EXTINF:([\d.]+)/g)].reduce((n, m) => n + Number(m[1]), 0) || void 0;
6502
+ } catch {}
6503
+ item = {
6504
+ id: j.id,
6505
+ title: j.title || device || (j.opts.source === "url" ? j.opts.url : ""),
6506
+ source: j.opts.source,
6507
+ url: j.opts.source === "url" ? j.opts.url : void 0,
6508
+ startedAt: j.startedAt,
6509
+ duration,
6510
+ live: !j.vod,
6511
+ running: false,
6512
+ conversationId: j.opts.conversationId
6513
+ };
6514
+ }
6515
+ } catch {}
6516
+ hit = {
6517
+ mtime,
6518
+ item
6519
+ };
6520
+ historyCache.set(name, hit);
6521
+ }
6522
+ if (hit.item) out.push(hit.item);
6523
+ }
6524
+ return out;
6525
+ }
6526
+ /** A small picture of a video for its history row (made once, kept in its folder). */
6527
+ async function thumbnail(id) {
6528
+ if (!/^[\w-]+$/.test(id)) return void 0;
6529
+ const dir = join(config.dataDir, "jobs", id);
6530
+ const out = join(dir, "thumb.jpg");
6531
+ if (existsSync(out)) return out;
6532
+ for (const cover of ["cover.jpg", "cover.png"]) if (existsSync(join(dir, cover))) return join(dir, cover);
6533
+ const src = existsSync(join(dir, "recording.mp4")) ? join(dir, "recording.mp4") : existsSync(join(dir, "hls", "index.m3u8")) ? join(dir, "hls", "index.m3u8") : void 0;
6534
+ if (!src) return void 0;
6535
+ if (await new Promise((resolve) => {
6536
+ execFile("ffmpeg", [
6537
+ "-v",
6538
+ "error",
6539
+ "-y",
6540
+ "-i",
6541
+ src,
6542
+ "-ss",
6543
+ "3",
6544
+ "-frames:v",
6545
+ "1",
6546
+ "-vf",
6547
+ "scale=320:-2",
6548
+ "-q:v",
6549
+ "5",
6550
+ out
6551
+ ], { timeout: 2e4 }, (err) => resolve(!err && existsSync(out)));
6552
+ })) return out;
6553
+ return await new Promise((resolve) => {
6554
+ execFile("ffmpeg", [
6555
+ "-v",
6556
+ "error",
6557
+ "-y",
6558
+ "-i",
6559
+ src,
6560
+ "-frames:v",
6561
+ "1",
6562
+ "-vf",
6563
+ "scale=320:-2",
6564
+ "-q:v",
6565
+ "5",
6566
+ out
6567
+ ], { timeout: 2e4 }, (err) => resolve(!err && existsSync(out)));
6568
+ }) ? out : void 0;
6569
+ }
6433
6570
  function jobForConversation(conversationId) {
6434
6571
  const id = byConversation.get(conversationId);
6435
6572
  if (id === "") return void 0;
@@ -6890,6 +7027,50 @@ function apply(ctx, config) {
6890
7027
  saveAs: typeof b.saveAs === "string" ? b.saveAs.replace(/[\\/:*?"<>|]+/g, " ").slice(0, 150) : "audio"
6891
7028
  }));
6892
7029
  }
7030
+ if (route === "history") {
7031
+ const all = history();
7032
+ const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
7033
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit")) || 5));
7034
+ return send(res, 200, {
7035
+ items: all.slice(offset, offset + limit),
7036
+ total: all.length
7037
+ });
7038
+ }
7039
+ if (route === "thumb") {
7040
+ const file = await thumbnail(url.searchParams.get("jobId") ?? "");
7041
+ if (!file) {
7042
+ res.writeHead(404);
7043
+ res.end();
7044
+ return;
7045
+ }
7046
+ res.writeHead(200, {
7047
+ "content-type": file.endsWith(".png") ? "image/png" : "image/jpeg",
7048
+ "cache-control": "max-age=3600"
7049
+ });
7050
+ createReadStream(file).pipe(res);
7051
+ return;
7052
+ }
7053
+ if (route === "reopen" && req.method === "POST") {
7054
+ const b = await readJson(req);
7055
+ const job = loadJob(String(b.jobId));
7056
+ const conv = String(b.conversationId ?? "");
7057
+ if (!job || !conv) return send(res, 404, { error: "no such video" });
7058
+ try {
7059
+ rmSync(join(job.summary().dir, "closed"), { force: true });
7060
+ } catch {}
7061
+ const prev = byConversation.get(conv);
7062
+ if (prev && prev !== job.id) {
7063
+ const other = jobs.get(prev);
7064
+ if (other && other.summary().status !== "running") byConversation.delete(conv);
7065
+ }
7066
+ job.rebind(conv);
7067
+ byConversation.set(conv, job.id);
7068
+ return send(res, 200, {
7069
+ ...job.summary(),
7070
+ cues: job.cues,
7071
+ terms: job.terms
7072
+ });
7073
+ }
6893
7074
  if (route === "preview") {
6894
7075
  const job = jobs.get(url.searchParams.get("jobId") ?? "");
6895
7076
  if (!job) return send(res, 404, { error: "no such job" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vidi",
3
- "version": "0.8.9",
3
+ "version": "0.9.1",
4
4
  "description": "A video tool for DeepSeek Harness: watch live streams, videos and podcasts with real-time subtitles in any language, then clip and download",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",