dsh-vidi 0.1.0 → 0.2.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.
package/lib/index.mjs CHANGED
@@ -1088,6 +1088,504 @@ function findOriginal(dir) {
1088
1088
  const f = readdirSync(dir).find((n) => /^original\.(mp4|mkv|webm|m4a|mp3)$/.test(n));
1089
1089
  return f ? join(dir, f) : void 0;
1090
1090
  }
1091
+ // src/restream.ts
1092
+ /** Platforms people push to; only a label on our side — the address and key come from the platform's own streaming tool. */
1093
+ const PLATFORMS = [
1094
+ "bilibili",
1095
+ "douyin",
1096
+ "kuaishou",
1097
+ "wechat",
1098
+ "douyu",
1099
+ "huya",
1100
+ "yy",
1101
+ "cc",
1102
+ "youtube",
1103
+ "twitch",
1104
+ "custom"
1105
+ ];
1106
+ const mask$1 = (k) => !k ? "" : k.length > 10 ? `${k.slice(0, 3)}…${k.slice(-4)}` : "••••";
1107
+ /** The full push address: the server plus the key, unless the server already ends with it. */
1108
+ function pushUrl(t) {
1109
+ const server = t.server.trim();
1110
+ const key = t.key.trim();
1111
+ if (!key || server.endsWith(key)) return server;
1112
+ return server.endsWith("/") ? server + key : `${server}/${key}`;
1113
+ }
1114
+ var Targets = class {
1115
+ file;
1116
+ constructor(file) {
1117
+ this.file = file;
1118
+ }
1119
+ list() {
1120
+ if (!existsSync(this.file)) return [];
1121
+ try {
1122
+ return JSON.parse(readFileSync(this.file, "utf8")).filter((t) => t && t.id && t.server);
1123
+ } catch {
1124
+ return [];
1125
+ }
1126
+ }
1127
+ view() {
1128
+ return this.list().map((t) => ({
1129
+ id: t.id,
1130
+ platform: t.platform,
1131
+ server: t.server,
1132
+ key: mask$1(t.key)
1133
+ }));
1134
+ }
1135
+ /** Add or change one (an empty key keeps the saved one); returns the saved list. */
1136
+ save(input) {
1137
+ const all = this.list();
1138
+ const platform = PLATFORMS.includes(String(input.platform)) ? input.platform : "custom";
1139
+ const server = String(input.server ?? "").trim();
1140
+ if (!/^(rtmps?|srt):\/\//i.test(server)) throw new Error("the server address should start with rtmp://, rtmps:// or srt://");
1141
+ const old = input.id ? all.find((t) => t.id === input.id) : void 0;
1142
+ const key = String(input.key ?? "").trim() || old?.key || "";
1143
+ const next = {
1144
+ id: old?.id ?? Math.random().toString(36).slice(2, 10),
1145
+ platform,
1146
+ server,
1147
+ key
1148
+ };
1149
+ this.write(old ? all.map((t) => t.id === old.id ? next : t) : [...all, next]);
1150
+ return this.view();
1151
+ }
1152
+ remove(id) {
1153
+ this.write(this.list().filter((t) => t.id !== id));
1154
+ return this.view();
1155
+ }
1156
+ write(list) {
1157
+ writeFileSync(this.file, JSON.stringify(list, null, 2), { mode: 384 });
1158
+ try {
1159
+ chmodSync(this.file, 384);
1160
+ } catch {}
1161
+ }
1162
+ };
1163
+ let canvasLib;
1164
+ /** The canvas library is loaded on first use (it is a native module; restreaming still works without subtitles if it fails). */
1165
+ const loadCanvas = () => canvasLib ??= import("@napi-rs/canvas").then((m) => m, () => void 0);
1166
+ const FONT = "\"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Noto Sans CJK SC\", \"Noto Sans SC\", \"Source Han Sans SC\", \"Helvetica Neue\", Arial, sans-serif";
1167
+ /**
1168
+ * Draws the subtitle lines for one moment as a transparent PNG the size of
1169
+ * the output picture, matching the player: the main line large and bold,
1170
+ * the rest smaller, each on a rounded dark box, wrapped to 92% of the width.
1171
+ */
1172
+ async function drawSubtitles(rows, style, w, h) {
1173
+ const lib = await loadCanvas();
1174
+ if (!lib) return void 0;
1175
+ const c = lib.createCanvas(w, h);
1176
+ const g = c.getContext("2d");
1177
+ const maxW = w * .92;
1178
+ const blocks = rows.map((r) => {
1179
+ const px = Math.round(r.size / 100 * w);
1180
+ g.font = `${r.main ? 600 : 500} ${px}px ${FONT}`;
1181
+ const lines = [];
1182
+ let cur = "";
1183
+ for (const ch of Array.from(r.text)) {
1184
+ const next = cur + ch;
1185
+ if (cur && g.measureText(next).width > maxW - px * .9) {
1186
+ const sp = cur.lastIndexOf(" ");
1187
+ if (sp > cur.length * .5 && /[A-Za-z]/.test(ch)) {
1188
+ lines.push(cur.slice(0, sp));
1189
+ cur = cur.slice(sp + 1) + ch;
1190
+ } else {
1191
+ lines.push(cur);
1192
+ cur = ch.trimStart();
1193
+ }
1194
+ } else cur = next;
1195
+ }
1196
+ if (cur.trim()) lines.push(cur);
1197
+ return {
1198
+ px,
1199
+ main: r.main,
1200
+ lines
1201
+ };
1202
+ });
1203
+ const lineH = (px) => px * 1.55;
1204
+ const gap = (px) => px * .18;
1205
+ const total = blocks.reduce((n, b) => n + b.lines.length * lineH(b.px) + gap(b.px), 0);
1206
+ let y = style.position === "top" ? style.offset / 100 * h : h - style.offset / 100 * h - total;
1207
+ for (const b of blocks) {
1208
+ g.font = `${b.main ? 600 : 500} ${b.px}px ${FONT}`;
1209
+ g.textAlign = "center";
1210
+ g.textBaseline = "middle";
1211
+ for (const line of b.lines) {
1212
+ const tw = g.measureText(line).width;
1213
+ const padX = b.px * .45, boxH = b.px * 1.4;
1214
+ const cy = y + lineH(b.px) / 2;
1215
+ if (style.bg > .02) {
1216
+ g.fillStyle = `rgba(0,0,0,${style.bg})`;
1217
+ roundRect(g, w / 2 - tw / 2 - padX, cy - boxH / 2, tw + padX * 2, boxH, b.px * .22);
1218
+ g.fill();
1219
+ } else {
1220
+ g.shadowColor = "rgba(0,0,0,.85)";
1221
+ g.shadowBlur = b.px * .18;
1222
+ g.shadowOffsetY = b.px * .04;
1223
+ }
1224
+ g.fillStyle = b.main ? "#ffffff" : "#e8e8e8";
1225
+ g.fillText(line, w / 2, cy);
1226
+ g.shadowColor = "transparent";
1227
+ g.shadowBlur = 0;
1228
+ g.shadowOffsetY = 0;
1229
+ y += lineH(b.px);
1230
+ }
1231
+ y += gap(b.px);
1232
+ }
1233
+ return c.toBuffer("image/png");
1234
+ }
1235
+ function roundRect(g, x, y, w, h, r) {
1236
+ g.beginPath();
1237
+ g.moveTo(x + r, y);
1238
+ g.arcTo(x + w, y, x + w, y + h, r);
1239
+ g.arcTo(x + w, y + h, x, y + h, r);
1240
+ g.arcTo(x, y + h, x, y, r);
1241
+ g.arcTo(x, y, x + w, y, r);
1242
+ g.closePath();
1243
+ }
1244
+ const FPS_SUBS = 5;
1245
+ const RETRY_MS = [
1246
+ 2e3,
1247
+ 5e3,
1248
+ 1e4,
1249
+ 2e4,
1250
+ 3e4
1251
+ ];
1252
+ /**
1253
+ * Restreaming: one ffmpeg reads the recording in real time from `from`,
1254
+ * overlays the subtitle pictures (drawn here, a few per second, for the
1255
+ * exact media time of each one) and encodes once to MPEG-TS; every
1256
+ * destination gets its own small ffmpeg that copies that stream to the
1257
+ * platform as FLV. A platform that drops or refuses only retries itself.
1258
+ */
1259
+ var Restream = class extends EventEmitter {
1260
+ source;
1261
+ opts;
1262
+ log;
1263
+ encoder;
1264
+ pushes = /* @__PURE__ */ new Map();
1265
+ status;
1266
+ stopped = false;
1267
+ sentFrames = 0;
1268
+ outTime = 0;
1269
+ subsTimer;
1270
+ lastKey = "";
1271
+ lastPng;
1272
+ blank;
1273
+ startAt = 0;
1274
+ spawnedAt = 0;
1275
+ constructor(source, opts, targets, log) {
1276
+ super();
1277
+ this.source = source;
1278
+ this.opts = opts;
1279
+ this.log = log;
1280
+ this.status = {
1281
+ running: true,
1282
+ at: opts.from,
1283
+ startedAt: Date.now(),
1284
+ burn: opts.burn,
1285
+ height: opts.height,
1286
+ targets: []
1287
+ };
1288
+ for (const t of targets) this.pushes.set(t.id, {
1289
+ target: t,
1290
+ tries: 0,
1291
+ status: {
1292
+ id: t.id,
1293
+ platform: t.platform,
1294
+ server: t.server,
1295
+ state: "connecting",
1296
+ since: Date.now()
1297
+ }
1298
+ });
1299
+ }
1300
+ get summary() {
1301
+ return {
1302
+ ...this.status,
1303
+ targets: [...this.pushes.values()].map((p) => ({ ...p.status }))
1304
+ };
1305
+ }
1306
+ changed() {
1307
+ this.emit("status", this.summary);
1308
+ }
1309
+ async start() {
1310
+ const h = this.opts.height, w = Math.round(h * 16 / 9);
1311
+ const { args: input, at } = this.source.input(this.opts.from);
1312
+ this.startAt = at;
1313
+ this.status.at = at;
1314
+ const burn = this.opts.burn && !!await loadCanvas();
1315
+ if (this.opts.burn && !burn) this.status.message = "no-burn";
1316
+ this.status.burn = burn;
1317
+ if (burn) this.blank = await drawSubtitles([], this.opts.style, w, h);
1318
+ const video = `[0:v]scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=25,setpts=PTS-STARTPTS`;
1319
+ const filter = burn ? `${video}[v];[1:v]format=rgba,setpts=PTS-STARTPTS[s];[v][s]overlay=0:0:eof_action=repeat:shortest=1,format=yuv420p[o]` : `${video},format=yuv420p[o]`;
1320
+ const b = this.opts.bitrateK;
1321
+ const args = [
1322
+ "-hide_banner",
1323
+ "-loglevel",
1324
+ "error",
1325
+ "-re",
1326
+ ...input,
1327
+ ...burn ? [
1328
+ "-probesize",
1329
+ "65536",
1330
+ "-analyzeduration",
1331
+ "0",
1332
+ "-f",
1333
+ "image2pipe",
1334
+ "-framerate",
1335
+ String(FPS_SUBS),
1336
+ "-c:v",
1337
+ "png",
1338
+ "-i",
1339
+ "pipe:0"
1340
+ ] : [],
1341
+ "-filter_complex",
1342
+ filter,
1343
+ "-map",
1344
+ "[o]",
1345
+ "-map",
1346
+ "0:a:0?",
1347
+ "-c:v",
1348
+ "libx264",
1349
+ "-preset",
1350
+ "veryfast",
1351
+ "-tune",
1352
+ "zerolatency",
1353
+ "-profile:v",
1354
+ "high",
1355
+ "-b:v",
1356
+ `${b}k`,
1357
+ "-maxrate",
1358
+ `${b}k`,
1359
+ "-bufsize",
1360
+ `${b * 2}k`,
1361
+ "-g",
1362
+ "50",
1363
+ "-keyint_min",
1364
+ "50",
1365
+ "-sc_threshold",
1366
+ "0",
1367
+ "-c:a",
1368
+ "aac",
1369
+ "-b:a",
1370
+ "128k",
1371
+ "-ar",
1372
+ "44100",
1373
+ "-ac",
1374
+ "2",
1375
+ "-progress",
1376
+ "pipe:3",
1377
+ "-f",
1378
+ "mpegts",
1379
+ "pipe:1"
1380
+ ];
1381
+ const enc = spawn("ffmpeg", args, { stdio: [
1382
+ "pipe",
1383
+ "pipe",
1384
+ "pipe",
1385
+ "pipe"
1386
+ ] });
1387
+ this.encoder = enc;
1388
+ this.spawnedAt = Date.now();
1389
+ process.once("exit", this.killAll);
1390
+ let errTail = "";
1391
+ enc.stderr.on("data", (d) => {
1392
+ errTail = (errTail + d.toString()).slice(-600);
1393
+ });
1394
+ enc.stdio[3].on("data", (d) => {
1395
+ const m = /out_time_us=(\d+)/g;
1396
+ let last, hit = null;
1397
+ while (last = m.exec(d.toString())) hit = last;
1398
+ if (hit) {
1399
+ this.outTime = Number(hit[1]) / 1e6;
1400
+ this.status.at = this.startAt + this.outTime;
1401
+ }
1402
+ });
1403
+ enc.stdin.on("error", () => {});
1404
+ enc.stdout.on("data", (chunk) => {
1405
+ for (const p of this.pushes.values()) {
1406
+ const sin = p.proc?.stdin;
1407
+ if (sin && !sin.destroyed && sin.writableLength < 8 << 20) sin.write(chunk);
1408
+ }
1409
+ });
1410
+ enc.on("exit", (code) => {
1411
+ if (this.stopped) return;
1412
+ const reason = code === 0 ? "ended" : errTail.trim().split("\n").pop() || `encoder exited (${code})`;
1413
+ this.log(`restream: ${reason}`);
1414
+ this.stop(reason);
1415
+ });
1416
+ if (burn) this.feedSubtitles(w, h);
1417
+ for (const p of this.pushes.values()) this.connect(p.target.id);
1418
+ this.changed();
1419
+ }
1420
+ /** Subtitle pictures for media times from the start on, kept about a second ahead of the encoder. */
1421
+ feedSubtitles(w, h) {
1422
+ const sin = this.encoder.stdin;
1423
+ const tick = async () => {
1424
+ if (this.stopped) return;
1425
+ const ahead = Math.max(4, (Date.now() - this.spawnedAt) / 1e3 + 2.5);
1426
+ while (!this.stopped && this.sentFrames / FPS_SUBS <= ahead && sin.writableLength < 4 << 20) {
1427
+ const t = this.startAt + this.sentFrames / FPS_SUBS;
1428
+ const rows = this.source.rowsAt(t);
1429
+ const key = JSON.stringify(rows);
1430
+ if (key !== this.lastKey) {
1431
+ this.lastKey = key;
1432
+ try {
1433
+ this.lastPng = rows.length ? await drawSubtitles(rows, this.opts.style, w, h) ?? this.blank : this.blank;
1434
+ } catch (err) {
1435
+ this.log(`restream: subtitle drawing failed (${err.message})`);
1436
+ this.lastPng = this.blank;
1437
+ }
1438
+ }
1439
+ if (this.lastPng) sin.write(this.lastPng);
1440
+ this.sentFrames++;
1441
+ }
1442
+ };
1443
+ let busy = false;
1444
+ this.subsTimer = setInterval(() => {
1445
+ if (busy) return;
1446
+ busy = true;
1447
+ tick().catch((err) => this.log(`restream: subtitles: ${err.message}`)).finally(() => {
1448
+ busy = false;
1449
+ });
1450
+ }, 100);
1451
+ }
1452
+ /** Start (or restart) one destination. */
1453
+ connect(id) {
1454
+ const p = this.pushes.get(id);
1455
+ if (!p || this.stopped) return;
1456
+ const url = pushUrl(p.target);
1457
+ const proc = spawn("ffmpeg", [
1458
+ "-hide_banner",
1459
+ "-nostats",
1460
+ "-loglevel",
1461
+ "info",
1462
+ "-f",
1463
+ "mpegts",
1464
+ "-i",
1465
+ "pipe:0",
1466
+ "-c",
1467
+ "copy",
1468
+ "-f",
1469
+ "flv",
1470
+ "-flvflags",
1471
+ "no_duration_filesize",
1472
+ url
1473
+ ], { stdio: [
1474
+ "pipe",
1475
+ "ignore",
1476
+ "pipe"
1477
+ ] });
1478
+ p.proc = proc;
1479
+ p.status = {
1480
+ ...p.status,
1481
+ state: p.tries ? "retrying" : "connecting",
1482
+ since: Date.now(),
1483
+ message: void 0
1484
+ };
1485
+ let errTail = "";
1486
+ let opened = false;
1487
+ proc.stderr.on("data", (d) => {
1488
+ const text = d.toString();
1489
+ errTail = (errTail + text).slice(-600);
1490
+ if (!opened && /Output #0/.test(errTail) && p.proc === proc) {
1491
+ opened = true;
1492
+ p.tries = 0;
1493
+ p.status = {
1494
+ ...p.status,
1495
+ state: "live",
1496
+ message: void 0,
1497
+ since: Date.now()
1498
+ };
1499
+ this.changed();
1500
+ }
1501
+ });
1502
+ proc.stdin.on("error", () => {});
1503
+ proc.on("exit", () => {
1504
+ if (p.proc !== proc || this.stopped) return;
1505
+ p.proc = void 0;
1506
+ const reason = errTail.split("\n").map((l) => l.replace(/^\[[^\]]*\]\s*/, "").trim()).filter(Boolean).reverse().find((l) => /error|fail|refused|denied|invalid|timed out|not found|unauthor|forbidden|reset|broken/i.test(l)) || "the platform closed the connection";
1507
+ if (p.tries >= RETRY_MS.length) {
1508
+ p.status = {
1509
+ ...p.status,
1510
+ state: "error",
1511
+ message: reason,
1512
+ since: Date.now()
1513
+ };
1514
+ this.changed();
1515
+ return;
1516
+ }
1517
+ p.status = {
1518
+ ...p.status,
1519
+ state: "retrying",
1520
+ message: reason,
1521
+ since: Date.now()
1522
+ };
1523
+ p.timer = setTimeout(() => this.connect(id), RETRY_MS[p.tries++]);
1524
+ this.changed();
1525
+ });
1526
+ this.changed();
1527
+ }
1528
+ /** Add a destination while running (no interruption for the others). */
1529
+ add(t) {
1530
+ if (this.pushes.has(t.id) || this.stopped) return;
1531
+ this.pushes.set(t.id, {
1532
+ target: t,
1533
+ tries: 0,
1534
+ status: {
1535
+ id: t.id,
1536
+ platform: t.platform,
1537
+ server: t.server,
1538
+ state: "connecting",
1539
+ since: Date.now()
1540
+ }
1541
+ });
1542
+ this.connect(t.id);
1543
+ }
1544
+ /** Stop one destination; the rest keep going. Returns true when none are left. */
1545
+ drop(id) {
1546
+ const p = this.pushes.get(id);
1547
+ if (p) {
1548
+ clearTimeout(p.timer);
1549
+ const proc = p.proc;
1550
+ p.proc = void 0;
1551
+ proc?.stdin?.end();
1552
+ proc?.kill("SIGTERM");
1553
+ this.pushes.delete(id);
1554
+ this.changed();
1555
+ }
1556
+ return this.pushes.size === 0;
1557
+ }
1558
+ killAll = () => {
1559
+ this.encoder?.kill("SIGKILL");
1560
+ for (const p of this.pushes.values()) p.proc?.kill("SIGKILL");
1561
+ };
1562
+ async stop(message) {
1563
+ if (this.stopped) return;
1564
+ this.stopped = true;
1565
+ process.off("exit", this.killAll);
1566
+ clearInterval(this.subsTimer);
1567
+ for (const p of this.pushes.values()) {
1568
+ clearTimeout(p.timer);
1569
+ const proc = p.proc;
1570
+ proc?.stdin?.end();
1571
+ if (proc) setTimeout(() => {
1572
+ if (proc.exitCode === null) proc.kill("SIGTERM");
1573
+ }, 3e3);
1574
+ p.status = {
1575
+ ...p.status,
1576
+ state: "stopped",
1577
+ since: Date.now()
1578
+ };
1579
+ }
1580
+ this.encoder?.kill("SIGTERM");
1581
+ this.status = {
1582
+ ...this.status,
1583
+ running: false,
1584
+ message: message ?? this.status.message
1585
+ };
1586
+ this.changed();
1587
+ }
1588
+ };
1091
1589
  // src/job.ts
1092
1590
  const run$1 = promisify(execFile);
1093
1591
  /** URLs ffmpeg can open directly (no yt-dlp resolution). */
@@ -1199,6 +1697,7 @@ var LiveJob = class LiveJob extends EventEmitter {
1199
1697
  hls: this.hlsName,
1200
1698
  height: this.height,
1201
1699
  heights: this.heights,
1700
+ restream: this.restream,
1202
1701
  subs: { ...this.subs },
1203
1702
  usage: this.deps.usage.summary(),
1204
1703
  site: this.siteTracks,
@@ -1559,7 +2058,7 @@ var LiveJob = class LiveJob extends EventEmitter {
1559
2058
  "pipe"
1560
2059
  ] });
1561
2060
  this.ffmpeg = ff;
1562
- if (this.message) {
2061
+ if (this.opts.source === "push") {
1563
2062
  const wait = setInterval(() => {
1564
2063
  if (this.ffmpeg !== ff) {
1565
2064
  clearInterval(wait);
@@ -1621,12 +2120,7 @@ var LiveJob = class LiveJob extends EventEmitter {
1621
2120
  let at = Math.max(0, from ?? this.subs.at);
1622
2121
  const finished = readFileSync(this.m3u8, "utf8").includes("#EXT-X-ENDLIST");
1623
2122
  let input;
1624
- if (finished) input = [
1625
- "-ss",
1626
- String(at),
1627
- "-i",
1628
- this.m3u8
1629
- ];
2123
+ if (finished) input = this.seekable(at);
1630
2124
  else {
1631
2125
  const i = Math.max(0, segs.findLastIndex((sg) => sg.start <= at));
1632
2126
  at = segs[i].start;
@@ -1900,9 +2394,96 @@ var LiveJob = class LiveJob extends EventEmitter {
1900
2394
  }
1901
2395
  /** Stop everything (closing the job): recording and subtitles. */
1902
2396
  async close(reason = "closed") {
2397
+ await this.restreamer?.stop();
1903
2398
  await this.stop(reason);
1904
2399
  await this.pauseSubs();
1905
2400
  }
2401
+ restreamer;
2402
+ get restream() {
2403
+ return this.restreamer?.summary;
2404
+ }
2405
+ /** Push this video (from media time `from`, subtitles drawn in) to the chosen destinations. */
2406
+ async startRestream(targets, opts) {
2407
+ if (this.restreamer?.summary.running) {
2408
+ for (const t of targets) this.restreamer.add(t);
2409
+ return;
2410
+ }
2411
+ if (!this.segments().length) throw new Error("nothing recorded yet");
2412
+ const r = new Restream({
2413
+ input: (from) => this.restreamInput(from),
2414
+ rowsAt: (t) => this.rowsAt(t, opts.style)
2415
+ }, {
2416
+ ...opts,
2417
+ bitrateK: opts.height === 1080 ? 4500 : 2500
2418
+ }, targets, (m) => this.deps.log(`[${this.id}] ${m}`));
2419
+ this.restreamer = r;
2420
+ r.on("status", () => this.emit("state", this.summary()));
2421
+ await r.start();
2422
+ }
2423
+ async stopRestream(targetId) {
2424
+ if (!this.restreamer) return;
2425
+ if (targetId && !this.restreamer.drop(targetId)) return;
2426
+ await this.restreamer.stop();
2427
+ }
2428
+ /**
2429
+ * Input arguments for a finished recording from media time `from`. Seeking
2430
+ * an HLS playlist to 0 skips its whole first segment when the stream's
2431
+ * timestamps don't start at 0, so: no seek from the start, and the single
2432
+ * recording file (exact seeking) once it exists.
2433
+ */
2434
+ seekable(from) {
2435
+ const file = this.captureDone && existsSync(join(this.deps.dir, "recording.mp4")) ? join(this.deps.dir, "recording.mp4") : this.m3u8;
2436
+ return from > .05 ? [
2437
+ "-ss",
2438
+ String(from),
2439
+ "-i",
2440
+ file
2441
+ ] : ["-i", file];
2442
+ }
2443
+ /** The recording from `from` on, for an ffmpeg that reads it in real time. */
2444
+ restreamInput(from) {
2445
+ const segs = this.segments();
2446
+ if (readFileSync(this.m3u8, "utf8").includes("#EXT-X-ENDLIST")) return {
2447
+ args: this.seekable(from),
2448
+ at: Math.max(0, from)
2449
+ };
2450
+ const i = Math.max(0, segs.findLastIndex((sg) => sg.start <= from));
2451
+ return {
2452
+ args: [
2453
+ "-live_start_index",
2454
+ String(i),
2455
+ "-max_reload",
2456
+ "100000",
2457
+ "-i",
2458
+ this.m3u8
2459
+ ],
2460
+ at: segs[i]?.start ?? 0
2461
+ };
2462
+ }
2463
+ /** What the player would show at media time t, line by line (the same rules: no repeats, line-end marks dropped). */
2464
+ rowsAt(t, style) {
2465
+ const cue = this.cues.find((c) => c.final && c.start <= t && t < Math.max(c.end, c.start + 1.2));
2466
+ const text = (key) => {
2467
+ if (key.startsWith("site:")) return this.siteCues.get(key)?.find((c) => c.start <= t && t < c.end)?.text;
2468
+ if (!cue) return void 0;
2469
+ if (key === "src") return cue.src;
2470
+ const tr = cue.tr?.[key];
2471
+ return tr && tr.trim() !== cue.src.trim() ? tr : cue.lang?.split("-")[0] === key.split("-")[0] ? cue.src : void 0;
2472
+ };
2473
+ const seen = /* @__PURE__ */ new Set();
2474
+ const rows = [];
2475
+ for (const l of style.lines) {
2476
+ const x = l.show ? text(l.key)?.trim() : void 0;
2477
+ if (!x || seen.has(x)) continue;
2478
+ seen.add(x);
2479
+ rows.push({
2480
+ text: subtitleText(x),
2481
+ size: l.size,
2482
+ main: rows.length === 0
2483
+ });
2484
+ }
2485
+ return rows;
2486
+ }
1906
2487
  /** Write the default subtitle and transcript set (source, each target, multilingual). */
1907
2488
  save() {
1908
2489
  for (const { name, langs, formats } of defaultExports(this.opts.tgtLangs)) for (const f of formats) {
@@ -2380,6 +2961,10 @@ var KeyStore = class {
2380
2961
  settings;
2381
2962
  cache = /* @__PURE__ */ new Map();
2382
2963
  creds;
2964
+ /** Model providers configured in DSH (their key names and addresses), read fresh on each refresh. */
2965
+ discover;
2966
+ /** Addresses from DSH's provider setup, per service (e.g. SiliconFlow's international endpoint). */
2967
+ bases = /* @__PURE__ */ new Map();
2383
2968
  constructor(file, settings) {
2384
2969
  this.file = file;
2385
2970
  this.settings = settings;
@@ -2395,17 +2980,33 @@ var KeyStore = class {
2395
2980
  async refresh() {
2396
2981
  const file = this.readFile();
2397
2982
  const next = /* @__PURE__ */ new Map();
2983
+ const found = (() => {
2984
+ try {
2985
+ return this.discover?.() ?? [];
2986
+ } catch {
2987
+ return [];
2988
+ }
2989
+ })();
2990
+ this.bases = new Map(found.filter((p) => p.base).map((p) => [p.service, p.base]));
2398
2991
  for (const name of KEY_NAMES) {
2399
2992
  let hit;
2400
- for (const ref of KEY_REFS[name]) {
2993
+ const refs = [...found.filter((p) => p.service === name && p.ref).map((p) => ({
2994
+ ref: p.ref,
2995
+ provider: p.name
2996
+ })), ...KEY_REFS[name].map((ref) => ({
2997
+ ref,
2998
+ provider: void 0
2999
+ }))];
3000
+ for (const { ref, provider } of refs) {
2401
3001
  const r = await this.creds?.resolve(ref).catch(() => void 0);
2402
3002
  if (r?.value.trim()) {
2403
3003
  hit = {
2404
3004
  key: r.value.trim(),
2405
3005
  info: {
2406
3006
  set: true,
2407
- source: r.source === "file" ? "dsh" : "env",
2408
- ref
3007
+ source: r.source === "file" || provider ? "dsh" : "env",
3008
+ ref,
3009
+ provider
2409
3010
  }
2410
3011
  };
2411
3012
  break;
@@ -3142,6 +3743,21 @@ const SERVICES = [
3142
3743
  }
3143
3744
  ];
3144
3745
  const service = (id) => SERVICES.find((s) => s.id === id);
3746
+ /** Which speech service a DSH model provider also is, by its address (or its id when it has none). */
3747
+ function speechServiceOf(id, baseURL) {
3748
+ const k = `${(() => {
3749
+ try {
3750
+ return baseURL ? new URL(baseURL).host : "";
3751
+ } catch {
3752
+ return "";
3753
+ }
3754
+ })()} ${id}`.toLowerCase();
3755
+ if (/generativelanguage\.googleapis\.com|^ (google|gemini)$/.test(k)) return "gemini";
3756
+ if (/api\.openai\.com|^ openai$/.test(k)) return "openai";
3757
+ if (/api\.groq\.com|^ groq$/.test(k)) return "groq";
3758
+ if (/siliconflow/.test(k)) return "siliconflow";
3759
+ if (/bigmodel\.cn|(^|\.)z\.ai|^ (glm|zhipu|zhipuai|zai)$/.test(k)) return "glm";
3760
+ }
3145
3761
  const TIMEOUT = 3e4;
3146
3762
  async function fail(r) {
3147
3763
  const body = await r.text().catch(() => "");
@@ -3802,7 +4418,30 @@ function apply(ctx, config) {
3802
4418
  }, "dsh-vidi: translator model");
3803
4419
  });
3804
4420
  const translator = new Translator(() => llm);
4421
+ const restreamTargets = new Targets(join(config.dataDir, "restream.json"));
3805
4422
  const keys = new KeyStore(join(config.dataDir, "keys.json"), () => ({ openai: config.openaiApiKey }));
4423
+ keys.discover = () => {
4424
+ const loader = ctx.get?.("loader") ?? ctx.loader;
4425
+ if (!loader?.entries) return [];
4426
+ const val = (x) => x && typeof x.get === "function" ? x.get() : x;
4427
+ const out = [];
4428
+ for (const row of loader.entries()) {
4429
+ if (!/llm-pi-ai$/.test(String(row.options?.name ?? ""))) continue;
4430
+ const providers = val((row.fiber?.effectiveConfig ?? row.options?.config ?? {}).providers) ?? {};
4431
+ for (const [id, raw] of Object.entries(providers)) {
4432
+ const p = val(raw) ?? {};
4433
+ const base = typeof val(p.baseURL) === "string" ? String(val(p.baseURL)).replace(/\/+$/, "") : void 0;
4434
+ const service = speechServiceOf(id, base);
4435
+ if (service) out.push({
4436
+ service,
4437
+ ref: val(p.apiKeyEnv),
4438
+ base: service === "gemini" || service === "openai" ? void 0 : base,
4439
+ name: String(val(p.displayName) ?? id)
4440
+ });
4441
+ }
4442
+ }
4443
+ return out;
4444
+ };
3806
4445
  keys.refresh();
3807
4446
  ctx.inject(["credentials"], (c) => {
3808
4447
  c.effect(() => {
@@ -3874,7 +4513,10 @@ function apply(ctx, config) {
3874
4513
  });
3875
4514
  const key = () => keys.get(svc.id);
3876
4515
  return new LocalProvider({
3877
- engine: svc.kind === "gemini" ? geminiEngine(config.geminiModel, key, (model, u) => usage.add("gemini", model, u), log) : whisperApiEngine(svc, svc.model, key),
4516
+ engine: svc.kind === "gemini" ? geminiEngine(config.geminiModel, key, (model, u) => usage.add("gemini", model, u), log) : whisperApiEngine({
4517
+ ...svc,
4518
+ base: keys.bases.get(svc.id) ?? svc.base
4519
+ }, svc.model, key),
3878
4520
  lang: opts.srcLang === "auto" ? "auto" : opts.srcLang,
3879
4521
  minSec: 3,
3880
4522
  maxSec: 10,
@@ -4195,6 +4837,41 @@ function apply(ctx, config) {
4195
4837
  conversationId: String(b.conversationId ?? "default")
4196
4838
  }));
4197
4839
  }
4840
+ if (route === "restream/targets") {
4841
+ if (req.method === "POST") {
4842
+ const b = await readJson(req);
4843
+ try {
4844
+ if (typeof b.remove === "string") return send(res, 200, { targets: restreamTargets.remove(b.remove) });
4845
+ return send(res, 200, { targets: restreamTargets.save(b.target ?? {}) });
4846
+ } catch (err) {
4847
+ return send(res, 400, { error: err.message });
4848
+ }
4849
+ }
4850
+ return send(res, 200, { targets: restreamTargets.view() });
4851
+ }
4852
+ if (route === "restream" && req.method === "POST") {
4853
+ const b = await readJson(req);
4854
+ const job = jobs.get(String(b.jobId));
4855
+ if (!job) return send(res, 404, { error: "no such job" });
4856
+ if (b.action === "stop") {
4857
+ await job.stopRestream(typeof b.targetId === "string" ? b.targetId : void 0);
4858
+ return send(res, 200, job.summary());
4859
+ }
4860
+ const ids = Array.isArray(b.targets) ? b.targets.map(String) : [];
4861
+ const chosen = restreamTargets.list().filter((t) => ids.includes(t.id));
4862
+ if (!chosen.length) return send(res, 400, { error: "choose where to stream first" });
4863
+ try {
4864
+ await job.startRestream(chosen, {
4865
+ from: typeof b.from === "number" ? b.from : 0,
4866
+ burn: b.burn !== false,
4867
+ style: b.style,
4868
+ height: b.height === 1080 ? 1080 : 720
4869
+ });
4870
+ } catch (err) {
4871
+ return send(res, 400, { error: err.message });
4872
+ }
4873
+ return send(res, 200, job.summary());
4874
+ }
4198
4875
  if (route === "models") {
4199
4876
  const out = [];
4200
4877
  for (const p of llmService?.listProviders?.() ?? []) {