@decentnetwork/lan 0.1.193 → 0.1.194

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.
Binary file
@@ -20,6 +20,10 @@ export interface HlsVariant {
20
20
  * Returns [] for a media playlist (no variants).
21
21
  */
22
22
  export declare function parseM3u8Variants(playlist: string, baseUrl: string): HlsVariant[];
23
+ /** Remove master-playlist variants above a delivery-safe bandwidth. Variant
24
+ * metadata and its following URI are removed as one pair. Media playlists are
25
+ * returned unchanged. */
26
+ export declare function capM3u8Variants(playlist: string, maxBandwidth: number): string;
23
27
  /**
24
28
  * Extract the media-segment URLs from an m3u8 playlist, resolved against the
25
29
  * playlist's own URL. Returns [] for master playlists (variant lists) — those
@@ -53,15 +57,15 @@ export interface HlsPrefetcherOptions {
53
57
  * exit pool (default 4). 1 disables striping. Lets ONE segment ride the SUM
54
58
  * of the exits' bandwidth instead of a single throttled connection. */
55
59
  stripes?: number;
56
- /** Dynamic stripe count: returns how many exits are ACTUALLY delivering right
57
- * now, so a segment is split only as many ways as there are working exits.
58
- * With one working exit it returns 1 (whole fetch — striping onto a single
59
- * exit just serializes and stalls); as exits recover it rises and aggregation
60
- * resumes. Overrides `stripes` when provided. */
60
+ /** Dynamic stripe count: may reflect either distinct exits or parallel
61
+ * connections on one proven exit. Overrides `stripes` when provided. */
61
62
  stripesFn?: () => number;
62
63
  /** Delay before duplicating a slow whole-segment fetch. Primarily exposed for
63
64
  * deterministic tests; default 3.5s is just below CCTV's 4s live segment. */
64
65
  wholeHedgeMs?: number;
66
+ /** Delay before duplicating a slow byte range. Exposed for deterministic
67
+ * tests; default 2s leaves time for the rescue to beat a 4s live segment. */
68
+ stripeHedgeMs?: number;
65
69
  /** Stripe chunk size — also the first probe chunk (default 256 KB). Segments
66
70
  * smaller than this aren't striped. */
67
71
  stripeChunkBytes?: number;
@@ -89,6 +93,13 @@ export declare class HlsPrefetcher {
89
93
  * lands — lets a player request "join" an in-flight prefetch instead of
90
94
  * double-downloading. */
91
95
  pending(url: string): Promise<CacheEntry | null> | null;
96
+ /** Delay only the first response for a media-playlist URL while caching up to
97
+ * two of its oldest listed segments. This intentionally starts playback
98
+ * about 4-8 seconds behind the live edge, giving the player enough media
99
+ * buffer to absorb an occasional link-wide five-second slowdown. Later
100
+ * refreshes are never delayed. A fetch that outlives the wait remains tracked
101
+ * so the player's segment request joins it instead of downloading twice. */
102
+ primePlaylist(playlistUrl: string, body: string, maxWaitMs?: number, targetSegments?: number): Promise<number>;
92
103
  /** Called with every playlist body that passes through the router: parse it
93
104
  * and top up the prefetch pipeline for its upcoming segments. */
94
105
  onPlaylist(playlistUrl: string, body: string): void;
@@ -23,6 +23,9 @@ const SEGMENT_EXTS = [".ts", ".m4s", ".m4a", ".m4v", ".aac", ".mp3", ".mp4", ".v
23
23
  const STRIPE_DEADLINE_MS = 6000;
24
24
  /** How many exits to try for one stripe before giving up on it. */
25
25
  const STRIPE_TRIES = 3;
26
+ /** Start a duplicate of the same half-range before the four-second live buffer
27
+ * is exhausted. The first byte-correct response wins and aborts its siblings. */
28
+ const STRIPE_HEDGE_MS = 2000;
26
29
  /** Whole-segment hedge delay: a live segment is ~4s of video; one that hasn't
27
30
  * landed in this long is late (a healthy exit delivers in 1-3.5s), so a
28
31
  * parallel duplicate on another exit is launched. Rarely fires in steady
@@ -78,6 +81,32 @@ export function parseM3u8Variants(playlist, baseUrl) {
78
81
  }
79
82
  return out;
80
83
  }
84
+ /** Remove master-playlist variants above a delivery-safe bandwidth. Variant
85
+ * metadata and its following URI are removed as one pair. Media playlists are
86
+ * returned unchanged. */
87
+ export function capM3u8Variants(playlist, maxBandwidth) {
88
+ if (!playlist.includes("#EXT-X-STREAM-INF") || maxBandwidth <= 0)
89
+ return playlist;
90
+ const out = [];
91
+ let dropUri = false;
92
+ for (const line of playlist.split(/\r?\n/)) {
93
+ if (line.trim().startsWith("#EXT-X-STREAM-INF")) {
94
+ const bw = /BANDWIDTH=(\d+)/.exec(line);
95
+ dropUri = Boolean(bw && Number(bw[1]) > maxBandwidth);
96
+ if (!dropUri)
97
+ out.push(line);
98
+ continue;
99
+ }
100
+ if (dropUri) {
101
+ if (line.trim().length === 0 || line.trim().startsWith("#"))
102
+ continue;
103
+ dropUri = false;
104
+ continue;
105
+ }
106
+ out.push(line);
107
+ }
108
+ return out.join("\n");
109
+ }
81
110
  /**
82
111
  * Extract the media-segment URLs from an m3u8 playlist, resolved against the
83
112
  * playlist's own URL. Returns [] for master playlists (variant lists) — those
@@ -104,6 +133,7 @@ export class HlsPrefetcher {
104
133
  #cache = new Map(); // Map preserves insertion order → LRU-ish
105
134
  #cacheBytes = 0;
106
135
  #inflight = new Map();
136
+ #liveBufferPrimed = false;
107
137
  #fetcher;
108
138
  #maxBytes;
109
139
  #ttlMs;
@@ -115,6 +145,7 @@ export class HlsPrefetcher {
115
145
  #stripesFn;
116
146
  #stripeChunkBytes;
117
147
  #wholeHedgeMs;
148
+ #stripeHedgeMs;
118
149
  #hits = 0;
119
150
  #misses = 0;
120
151
  constructor(fetcher, opts = {}) {
@@ -129,6 +160,7 @@ export class HlsPrefetcher {
129
160
  this.#stripesFn = opts.stripesFn;
130
161
  this.#stripeChunkBytes = opts.stripeChunkBytes ?? 256 * 1024;
131
162
  this.#wholeHedgeMs = opts.wholeHedgeMs ?? WHOLE_HEDGE_MS;
163
+ this.#stripeHedgeMs = opts.stripeHedgeMs ?? STRIPE_HEDGE_MS;
132
164
  }
133
165
  /** Effective stripe count right now (dynamic if a stripesFn was provided). */
134
166
  #stripeCount() {
@@ -244,27 +276,73 @@ export class HlsPrefetcher {
244
276
  * STRIPE_TRIES exits. Returns null only if every try failed. `exactLen`, when
245
277
  * given, rejects a 206 whose body isn't exactly that long (so assembly is
246
278
  * byte-correct); the probe passes it undefined to accept whatever 206 arrives. */
247
- async #fetchRange(u, start, end, extra, exactLen, exitHint) {
248
- for (let t = 0; t < STRIPE_TRIES; t++) {
249
- const ac = new AbortController();
250
- const timer = setTimeout(() => ac.abort(), STRIPE_DEADLINE_MS);
251
- timer.unref?.();
252
- try {
253
- // Each retry shifts to the NEXT exit so a failed range moves off a bad one.
254
- const hint = exitHint === undefined ? undefined : exitHint + t;
255
- const r = await this.#fetcher(u, { ...extra, host: u.host, range: `bytes=${start}-${end}` }, "stripe", ac.signal, hint);
256
- clearTimeout(timer);
257
- if (r.status === 200)
258
- return r; // origin ignored Range → whole body
259
- if (r.status === 206 && (exactLen === undefined || r.body.length === exactLen))
260
- return r;
261
- // wrong status/length → try another exit
262
- }
263
- catch {
264
- clearTimeout(timer);
265
- }
266
- }
267
- return null;
279
+ #fetchRange(u, start, end, extra, exactLen, exitHint) {
280
+ return new Promise((resolve) => {
281
+ let settled = false;
282
+ let launched = 0;
283
+ let finished = 0;
284
+ let hedgeTimer = null;
285
+ const controllers = [];
286
+ const clearHedge = () => { if (hedgeTimer) {
287
+ clearTimeout(hedgeTimer);
288
+ hedgeTimer = null;
289
+ } };
290
+ const finish = (r, winner) => {
291
+ if (settled)
292
+ return;
293
+ settled = true;
294
+ clearHedge();
295
+ for (const ac of controllers)
296
+ if (ac !== winner)
297
+ ac.abort();
298
+ resolve(r);
299
+ };
300
+ const scheduleHedge = () => {
301
+ if (settled || launched >= STRIPE_TRIES || hedgeTimer)
302
+ return;
303
+ hedgeTimer = setTimeout(() => { hedgeTimer = null; launch(); }, this.#stripeHedgeMs);
304
+ hedgeTimer.unref?.();
305
+ };
306
+ const launch = () => {
307
+ if (settled || launched >= STRIPE_TRIES)
308
+ return;
309
+ const attempt = launched++;
310
+ const ac = new AbortController();
311
+ controllers.push(ac);
312
+ const deadline = setTimeout(() => ac.abort(), STRIPE_DEADLINE_MS);
313
+ deadline.unref?.();
314
+ // A hedge is another connection for the SAME bytes. Router-side live
315
+ // ordering keeps it on the proven pool and may select another proven
316
+ // exit when one exists.
317
+ const hint = exitHint === undefined ? undefined : exitHint + attempt;
318
+ void this.#fetcher(u, { ...extra, host: u.host, range: `bytes=${start}-${end}` }, "stripe", ac.signal, hint)
319
+ .then((r) => {
320
+ clearTimeout(deadline);
321
+ if (settled)
322
+ return;
323
+ if (r.status === 200 || (r.status === 206 && (exactLen === undefined || r.body.length === exactLen))) {
324
+ finish(r, ac);
325
+ return;
326
+ }
327
+ failed();
328
+ }, () => { clearTimeout(deadline); failed(); });
329
+ scheduleHedge();
330
+ };
331
+ const failed = () => {
332
+ if (settled)
333
+ return;
334
+ finished++;
335
+ // A hard failure should not wait for the hedge delay.
336
+ if (launched < STRIPE_TRIES) {
337
+ clearHedge();
338
+ launch();
339
+ return;
340
+ }
341
+ if (finished >= launched)
342
+ finish(null);
343
+ };
344
+ launch();
345
+ });
268
346
  }
269
347
  /** Split a segment into `stripes` parallel byte-ranges across the exit pool,
270
348
  * each hedged. Assembles them into the full body with a corrected
@@ -272,9 +350,8 @@ export class HlsPrefetcher {
272
350
  * any stripe truly can't be fetched (correctness over speed). */
273
351
  async #stripeFetch(u, extra) {
274
352
  const stripeCount = this.#stripeCount();
275
- // Only ONE exit is delivering right now → striping would split the segment
276
- // onto a single exit that serializes the parts (14s vs a 2.4s whole fetch).
277
- // Just fetch the whole segment; aggregation resumes when a 2nd exit recovers.
353
+ // A count of one explicitly disables striping (emergency rollback or an
354
+ // origin/path known not to benefit from parallel range connections).
278
355
  if (stripeCount <= 1)
279
356
  return this.#whole(u, extra);
280
357
  // Tiny probe (2 bytes): learn the total size + confirm the origin honors
@@ -339,6 +416,50 @@ export class HlsPrefetcher {
339
416
  pending(url) {
340
417
  return this.#inflight.get(url) ?? null;
341
418
  }
419
+ /** Delay only the first response for a media-playlist URL while caching up to
420
+ * two of its oldest listed segments. This intentionally starts playback
421
+ * about 4-8 seconds behind the live edge, giving the player enough media
422
+ * buffer to absorb an occasional link-wide five-second slowdown. Later
423
+ * refreshes are never delayed. A fetch that outlives the wait remains tracked
424
+ * so the player's segment request joins it instead of downloading twice. */
425
+ async primePlaylist(playlistUrl, body, maxWaitMs = 8000, targetSegments = 2) {
426
+ const segs = parseM3u8Segments(body, playlistUrl).slice(-this.#ahead);
427
+ if (segs.length === 0)
428
+ return 0;
429
+ // One global prime only. CCTV probes/switches several variant and backup-CDN
430
+ // playlist URLs during startup; priming each URL independently launched
431
+ // 600/1350/576/1800 segments together and created the very congestion this
432
+ // buffer is intended to absorb.
433
+ if (this.#liveBufferPrimed) {
434
+ this.onPlaylist(playlistUrl, body);
435
+ return 0;
436
+ }
437
+ this.#liveBufferPrimed = true;
438
+ const deadline = Date.now() + Math.max(0, maxWaitMs);
439
+ let ready = 0;
440
+ for (const url of segs.slice(0, Math.max(1, targetSegments))) {
441
+ if (this.#peek(url)) {
442
+ ready++;
443
+ continue;
444
+ }
445
+ const remaining = deadline - Date.now();
446
+ if (remaining <= 0)
447
+ break;
448
+ const timeout = new Promise((resolve) => {
449
+ const t = setTimeout(() => resolve(null), remaining);
450
+ t.unref?.();
451
+ });
452
+ const fetched = await Promise.race([
453
+ this.fetchSegmentStriped(url).then((r) => r.status === 200 && r.body.length > 0 ? r : null),
454
+ timeout,
455
+ ]);
456
+ if (!fetched)
457
+ break;
458
+ ready++;
459
+ }
460
+ this.onPlaylist(playlistUrl, body);
461
+ return ready;
462
+ }
342
463
  #peek(url) {
343
464
  const e = this.#cache.get(url);
344
465
  if (!e)
@@ -21,7 +21,7 @@ import https from "node:https";
21
21
  import net from "node:net";
22
22
  import tls from "node:tls";
23
23
  import { egressConnect } from "./egress.js";
24
- import { HlsPrefetcher, looksLikePlaylist, looksLikeSegment, parseM3u8Variants, parseM3u8Segments } from "./hls-prefetch.js";
24
+ import { HlsPrefetcher, capM3u8Variants, looksLikePlaylist, looksLikeSegment, parseM3u8Variants, parseM3u8Segments } from "./hls-prefetch.js";
25
25
  import { RouteLearner } from "./route-learner.js";
26
26
  import { GeoCnClassifier } from "./geo-cn.js";
27
27
  import { MitmGateway } from "./mitm-gateway.js";
@@ -155,7 +155,15 @@ const NEAR_DEAD_EXIT_BPS = 60_000;
155
155
  // buffer. Keep such exits available for last-resort failover, but do not count
156
156
  // them as HLS delivery capacity or let a slow redemption probe re-enable them.
157
157
  // This is bytes/sec (150 KB/s = 1.2 Mbit/s).
158
- const HLS_DELIVERY_BPS = 150_000;
158
+ // A foreground HD exit must sustain at least 1.6 Mbit/s. The former 1.2 Mbit/s
159
+ // threshold admitted 10.86.15.133 on a short peak even though its full CCTV
160
+ // segments averaged only 0.7-1.2 Mbit/s, causing each half-range to take 4-6s.
161
+ const HLS_DELIVERY_BPS = 200_000;
162
+ // Stability-first default for constrained exit pools. Set 0 to expose every
163
+ // origin variant, or 1800000 to restore CCTV 720P when another fast exit joins.
164
+ const HLS_MAX_VARIANT_BPS = process.env.AGENTNET_HLS_MAX_BPS === undefined
165
+ ? 1_400_000
166
+ : Math.max(0, Number(process.env.AGENTNET_HLS_MAX_BPS));
159
167
  export function isUsefulRecoverySample(peakBps, averageBps, minBps = HLS_DELIVERY_BPS) {
160
168
  return peakBps >= minBps && averageBps >= minBps;
161
169
  }
@@ -262,9 +270,26 @@ function sampleSpeed(exit, meter, durMs, recoveryMinBps = NEAR_DEAD_EXIT_BPS) {
262
270
  * exit is degraded, keep the original pool as a last resort. Exported for the
263
271
  * policy regression tests. */
264
272
  export function selectHlsPrimaryPool(pool) {
273
+ const fastestTier = (candidates) => {
274
+ const best = Math.max(...candidates.map((e) => e.speedEwma ?? 0));
275
+ return candidates.filter((e) => (e.speedEwma ?? 0) >= best * 0.8);
276
+ };
265
277
  const proven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null && e.speedEwma >= HLS_DELIVERY_BPS);
266
- if (proven.length > 0)
267
- return proven;
278
+ if (proven.length > 0) {
279
+ // A fixed floor alone is not enough: a marginal path can briefly cross it
280
+ // while remaining far slower than the best live path, then hold one stripe
281
+ // hostage. Keep only the fastest tier (within 80% of the current best).
282
+ // Similar-speed exits can still aggregate; a clearly slower one is reserved
283
+ // for background redemption/failover.
284
+ return fastestTier(proven);
285
+ }
286
+ // If every measured path is below the HD floor, the best measured path is
287
+ // still safer than falling back to every unproven/degraded exit. This is the
288
+ // adaptive-bitrate path: the master playlist is capped separately, while
289
+ // foreground delivery stays on the least-bad known tier.
290
+ const measured = pool.filter((e) => e.flooredAtMs === null && e.speedEwma !== null);
291
+ if (measured.length > 0)
292
+ return fastestTier(measured);
268
293
  const unproven = pool.filter((e) => e.flooredAtMs === null && e.speedEwma === null);
269
294
  return unproven.length > 0 ? unproven : pool;
270
295
  }
@@ -1174,24 +1199,22 @@ export function startMultiExitRouter(opts) {
1174
1199
  // exists, so a segment/stripe is never handed to a 0.1 Mbps exit that would
1175
1200
  // stall the player. Unproven exits are kept (they get a chance; the caller's
1176
1201
  // hedge covers them if they turn out dead).
1177
- // A striped range (exitHint set) pins to a DISTINCT exit across the FULL
1178
- // healthy pool (base0, already fastest-first, tripped exits excluded) so
1179
- // parallel ranges spread over ALL usable exits and aggregate even the
1180
- // moderately-slow ones (0.4–0.6 Mbps) that we WANT for aggregation. A
1181
- // single (non-striped) fetch instead skips near-dead exits and rotates.
1202
+ // Foreground HLS traffic must stay on the proven delivery pool. In
1203
+ // particular, a stripe must not borrow a marginal/unproven exit merely to
1204
+ // obtain a second path: one 0.1 Mbps half-range holds the entire assembled
1205
+ // segment hostage. Multiple range connections may still share one proven
1206
+ // exit; that is intentional because the observed long tail is per
1207
+ // connection and the CDN supports byte ranges.
1182
1208
  let order;
1183
1209
  if (forcedExit) {
1184
1210
  order = base0;
1185
1211
  }
1186
1212
  else if (exitHint !== undefined) {
1187
- // Striped range: use pickOrder's live LOAD-AWARE order directly (base0 is
1188
- // sorted by speed/(active+1), least-loaded-fastest first). Because each
1189
- // buffered fetch now increments exit.active at connect time, concurrent
1190
- // stripes each see the prior stripe's load and pick the NEXT exit —
1191
- // spreading across DISTINCT exits and aggregating their bandwidth. (An
1192
- // explicit exitHint pin fought this: base0 reorders under load, so the
1193
- // pinned index kept landing back on the same fast exit.)
1194
- order = base0;
1213
+ // Keep the live load-aware ordering, but filter it through the same
1214
+ // foreground-safe policy as whole segments. With one proven exit both
1215
+ // stripes use separate connections through it; with two proven exits
1216
+ // active-count ordering naturally spreads the stripes.
1217
+ order = selectHlsPrimaryPool(base0);
1195
1218
  }
1196
1219
  else {
1197
1220
  // Load-aware order for whole fetches too: exit.active is incremented at
@@ -1348,7 +1371,7 @@ export function startMultiExitRouter(opts) {
1348
1371
  // it. Player requests JOIN an in-flight prefetch (dedup) so bandwidth isn't
1349
1372
  // split fetching the same segment twice. concurrency:2 keeps at most 2
1350
1373
  // segments (≈6 stripes over ~3 exits) in flight so the pool isn't overwhelmed.
1351
- const AGG = process.env.AGENTNET_HLS_STRIPES ? Math.max(1, Number(process.env.AGENTNET_HLS_STRIPES)) : 1;
1374
+ const AGG = process.env.AGENTNET_HLS_STRIPES ? Math.max(1, Number(process.env.AGENTNET_HLS_STRIPES)) : 3;
1352
1375
  // How many china exits are ACTUALLY delivering right now — healthy (or with
1353
1376
  // live traffic), not tripped, and not speed-penalized (an exit that returned
1354
1377
  // empty/0-byte gets its score floored, so it drops out here until it serves
@@ -1363,7 +1386,23 @@ export function startMultiExitRouter(opts) {
1363
1386
  (e.healthy || (e.lastSuccessMs !== null && now - e.lastSuccessMs < RECENT_SUCCESS_MS)));
1364
1387
  return countHlsDeliveryExits(eligible);
1365
1388
  };
1366
- const usableChinaExits = () => Math.min(AGG, deliveringChinaExits());
1389
+ const hasMeasuredChinaExit = () => {
1390
+ if (!fallbackRegion)
1391
+ return false;
1392
+ const now = Date.now();
1393
+ return exits.some((e) => e.region === fallbackRegion && e.trippedUntil <= now &&
1394
+ e.flooredAtMs === null && e.speedEwma !== null);
1395
+ };
1396
+ // Stripe count is a connection count, not a distinct-exit count. Keep three
1397
+ // parallel ranges even when only one exit is qualified: two ranges still
1398
+ // delivered CCTV's 1800 stream in 3.6-4.9s, which is marginal for a 4s live
1399
+ // segment, while one connection repeatedly showed 6-10s tails. During
1400
+ // startup, keep whole-segment mode until at least one
1401
+ // exit has delivered a real fast sample; otherwise the unproven startup pool
1402
+ // would scatter half-ranges onto every slow exit and hold assembly hostage.
1403
+ // Set AGENTNET_HLS_STRIPES=1 for an emergency rollback.
1404
+ const usableChinaExits = () => AGG <= 1 || !hasMeasuredChinaExit() ? 1 : AGG;
1405
+ const hlsConcurrency = () => hasMeasuredChinaExit() ? deliveringChinaExits() : 1;
1367
1406
  // Prefetch parallelism = the number of exits ACTUALLY delivering right now
1368
1407
  // (deliveringChinaExits). Each concurrent prefetch is a whole-segment fetch,
1369
1408
  // and the load-aware exit pick (active++ at dispatch) lands each one on a
@@ -1372,7 +1411,7 @@ export function startMultiExitRouter(opts) {
1372
1411
  // degrades to one-at-a-time (full bandwidth per segment, no splitting —
1373
1412
  // several fetches on ONE exit divide it N ways and everything stalls).
1374
1413
  // prefetchAhead=3 matches the 3-segment live window CCTV publishes.
1375
- const hls = new HlsPrefetcher(fetchViaRegionBuffered, { stripes: AGG, stripesFn: usableChinaExits, stripeChunkBytes: 256 * 1024, concurrencyFn: deliveringChinaExits, prefetchAhead: 3 });
1414
+ const hls = new HlsPrefetcher(fetchViaRegionBuffered, { stripes: AGG, stripesFn: usableChinaExits, stripeChunkBytes: 256 * 1024, concurrencyFn: hlsConcurrency, prefetchAhead: 3 });
1376
1415
  let lastHlsHits = 0;
1377
1416
  function bumpHlsStats() {
1378
1417
  const s = hls.stats;
@@ -1452,7 +1491,31 @@ export function startMultiExitRouter(opts) {
1452
1491
  new Promise((_, rej) => { const t = setTimeout(() => rej(new Error("segment total deadline")), SEGMENT_TOTAL_DEADLINE_MS); t.unref?.(); }),
1453
1492
  ]);
1454
1493
  fetchPromise
1455
- .then((r) => {
1494
+ .then(async (r) => {
1495
+ let playlistText = null;
1496
+ let playlistVariants = [];
1497
+ if (r.status === 200 && isPlaylist) {
1498
+ playlistText = r.body.toString("utf8");
1499
+ playlistVariants = parseM3u8Variants(playlistText, fullUrl);
1500
+ if (playlistVariants.length > 0 && HLS_MAX_VARIANT_BPS > 0) {
1501
+ const originalCount = playlistVariants.length;
1502
+ const capped = capM3u8Variants(playlistText, HLS_MAX_VARIANT_BPS);
1503
+ if (capped !== playlistText) {
1504
+ playlistText = capped;
1505
+ r.body = Buffer.from(capped, "utf8");
1506
+ playlistVariants = parseM3u8Variants(capped, fullUrl);
1507
+ log(` playlist ${host}: capped ${originalCount} variants to ${playlistVariants.length} at ${(HLS_MAX_VARIANT_BPS / 1e6).toFixed(1)}Mbps`);
1508
+ }
1509
+ }
1510
+ else if (playlistVariants.length === 0) {
1511
+ const segs = parseM3u8Segments(playlistText, fullUrl);
1512
+ const primed = await hls.primePlaylist(fullUrl, playlistText, 8000, 2);
1513
+ if (primed > 0)
1514
+ log(` playlist ${host}: ${primed} segment(s) cached before first delivery`);
1515
+ else
1516
+ log(` playlist ${host} is MEDIA — ${segs.length} segments, prefetching ahead`);
1517
+ }
1518
+ }
1456
1519
  log(` MITM ${method} ${host}${(path || "/").slice(0, 80)} → ${r.status} (${r.body.length}B)`);
1457
1520
  if (r.status >= 400) {
1458
1521
  const snip = r.body.slice(0, 300).toString("utf8").replace(/\s+/g, " ");
@@ -1467,17 +1530,13 @@ export function startMultiExitRouter(opts) {
1467
1530
  if (r.status === 200) {
1468
1531
  const ct = r.headers["content-type"];
1469
1532
  if (isPlaylist) {
1470
- const text = r.body.toString("utf8");
1471
- const variants = parseM3u8Variants(text, fullUrl);
1533
+ const text = playlistText ?? r.body.toString("utf8");
1534
+ const variants = playlistVariants.length > 0 ? playlistVariants : parseM3u8Variants(text, fullUrl);
1472
1535
  if (variants.length > 0) {
1473
1536
  const desc = variants.map((v) => `${v.resolution || "?"}@${(v.bandwidth / 1e6).toFixed(1)}Mbps`).join(", ");
1474
1537
  log(` playlist ${host} is MASTER — variants: ${desc}`);
1538
+ hls.onPlaylist(fullUrl, text);
1475
1539
  }
1476
- else {
1477
- const segs = parseM3u8Segments(text, fullUrl);
1478
- log(` playlist ${host} is MEDIA — ${segs.length} segments, prefetching ahead`);
1479
- }
1480
- hls.onPlaylist(fullUrl, text);
1481
1540
  }
1482
1541
  else {
1483
1542
  hls.put(fullUrl, r.body, Array.isArray(ct) ? ct[0] : ct);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.193",
3
+ "version": "0.1.194",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",