@mega-yfue/eufy-sdk 0.2.0-beta.13 → 0.2.0-beta.15

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/dist/index.js CHANGED
@@ -217,18 +217,33 @@ BLOCKED_ADDRESSES.addAddress("::1", "ipv6");
217
217
  BLOCKED_ADDRESSES.addSubnet("fc00::", 7, "ipv6");
218
218
  BLOCKED_ADDRESSES.addSubnet("fe80::", 10, "ipv6");
219
219
  var MediaDownloadError = class extends Error {
220
+ mediaFailure;
221
+ status;
222
+ constructor(message, mediaFailure, status) {
223
+ super(message);
224
+ this.name = "MediaDownloadError";
225
+ this.mediaFailure = mediaFailure;
226
+ if (status !== void 0)
227
+ this.status = status;
228
+ }
220
229
  };
230
+ function mediaFailureError(message, reason, cause) {
231
+ const error = new MediaDownloadError(message, reason);
232
+ if (cause !== void 0)
233
+ error.cause = cause;
234
+ return error;
235
+ }
221
236
  var MediaDownloadAuthenticationError = class extends Error {
222
237
  };
223
- function allowedMediaUrl(value, hostPattern) {
238
+ function allowedMediaUrl(value, hostPattern, reason) {
224
239
  let url;
225
240
  try {
226
241
  url = new URL(value);
227
242
  } catch {
228
- throw new MediaDownloadError("Media download rejected");
243
+ throw new MediaDownloadError("Media download rejected", reason);
229
244
  }
230
245
  if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.port !== "" || isIP(url.hostname) !== 0 || !hostPattern.test(url.hostname)) {
231
- throw new MediaDownloadError("Media download rejected");
246
+ throw new MediaDownloadError("Media download rejected", reason);
232
247
  }
233
248
  return url;
234
249
  }
@@ -243,7 +258,7 @@ async function assertPublicResolution(url, resolveHost2, signal) {
243
258
  try {
244
259
  addresses = await Promise.race([resolveHost2(url.hostname), aborted]);
245
260
  } catch {
246
- throw new MediaDownloadError("Media download rejected");
261
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
247
262
  } finally {
248
263
  signal.removeEventListener("abort", onAbort);
249
264
  }
@@ -251,14 +266,14 @@ async function assertPublicResolution(url, resolveHost2, signal) {
251
266
  const type = family === 6 ? "ipv6" : family === 4 ? "ipv4" : void 0;
252
267
  return type === void 0 || BLOCKED_ADDRESSES.check(address, type);
253
268
  })) {
254
- throw new MediaDownloadError("Media download rejected");
269
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
255
270
  }
256
271
  }
257
272
  var resolveHost = (hostname) => lookup(hostname, { all: true, verbatim: true });
258
273
  async function readMediaBody(response) {
259
274
  const contentLength = response.headers.get("content-length");
260
275
  if (contentLength && /^\d+$/.test(contentLength) && BigInt(contentLength) > BigInt(MEDIA_DOWNLOAD_MAX_BYTES)) {
261
- throw new MediaDownloadError("Media download rejected");
276
+ throw new MediaDownloadError("Media download rejected", "too-large");
262
277
  }
263
278
  if (!response.body)
264
279
  return Buffer.alloc(0);
@@ -272,14 +287,14 @@ async function readMediaBody(response) {
272
287
  bytes2 += value.byteLength;
273
288
  if (bytes2 > MEDIA_DOWNLOAD_MAX_BYTES) {
274
289
  void reader2.cancel().catch(() => void 0);
275
- throw new MediaDownloadError("Media download rejected");
290
+ throw new MediaDownloadError("Media download rejected", "too-large");
276
291
  }
277
292
  chunks.push(value);
278
293
  }
279
294
  return Buffer.concat(chunks, bytes2);
280
295
  }
281
296
  async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetch, resolver = resolveHost) {
282
- const original = allowedMediaUrl(url, EUFY_MEDIA_HOST);
297
+ const original = allowedMediaUrl(url, EUFY_MEDIA_HOST, "url-not-allowed");
283
298
  const controller = new AbortController();
284
299
  const timeout = setTimeout(() => controller.abort(), MEDIA_DOWNLOAD_TIMEOUT_MS);
285
300
  try {
@@ -292,31 +307,33 @@ async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetc
292
307
  if (originalResponse.status >= 300 && originalResponse.status < 400) {
293
308
  const location = originalResponse.headers.get("location");
294
309
  if (!location)
295
- throw new MediaDownloadError("Media download rejected");
296
- const target = allowedMediaUrl(location, EUFY_OBJECT_HOST);
310
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
311
+ const target = allowedMediaUrl(location, EUFY_OBJECT_HOST, "redirect-not-allowed");
297
312
  await assertPublicResolution(target, resolver, controller.signal);
298
313
  const redirectedResponse = await fetchImpl(target, { redirect: "manual", signal: controller.signal });
299
314
  if (redirectedResponse.status >= 300 && redirectedResponse.status < 400) {
300
- throw new MediaDownloadError("Media download rejected");
315
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
316
+ }
317
+ if (redirectedResponse.status !== 200) {
318
+ throw new MediaDownloadError("Media download failed", "http-status", redirectedResponse.status);
301
319
  }
302
- if (redirectedResponse.status !== 200)
303
- throw new MediaDownloadError("Media download failed");
304
320
  return await readMediaBody(redirectedResponse);
305
321
  }
306
322
  if (originalResponse.status === 401 || originalResponse.status === 403) {
307
323
  throw new MediaDownloadAuthenticationError("Media authentication failed");
308
324
  }
309
- if (originalResponse.status !== 200)
310
- throw new MediaDownloadError("Media download failed");
325
+ if (originalResponse.status !== 200) {
326
+ throw new MediaDownloadError("Media download failed", "http-status", originalResponse.status);
327
+ }
311
328
  return await readMediaBody(originalResponse);
312
329
  } catch (error) {
313
330
  if (controller.signal.aborted)
314
- throw new MediaDownloadError("Media download timed out");
331
+ throw new MediaDownloadError("Media download timed out", "timeout");
315
332
  if (error instanceof MediaDownloadAuthenticationError)
316
333
  throw error;
317
334
  if (error instanceof MediaDownloadError)
318
335
  throw error;
319
- throw new MediaDownloadError("Media download failed");
336
+ throw new MediaDownloadError("Media download failed", "network");
320
337
  } finally {
321
338
  clearTimeout(timeout);
322
339
  }
@@ -375,8 +392,228 @@ function randomUserAgent(seed, model) {
375
392
  // dist/transport/http/decodeImageV1.js
376
393
  import { createDecipheriv as createDecipheriv2, createHash as createHash2 } from "node:crypto";
377
394
 
395
+ // dist/transport/http/jpeg-scan.js
396
+ var DHT = 196;
397
+ var SOS = 218;
398
+ var DRI = 221;
399
+ var RST_FIRST = 208;
400
+ var RST_LAST = 215;
401
+ var LUMA_SAMPLING = { 0: [1, 1], 1: [2, 1], 2: [2, 2] };
402
+ var ScanEnd = class extends Error {
403
+ };
404
+ function readHuffmanTables(segment, into) {
405
+ const end = 2 + (segment[2] << 8 | segment[3]);
406
+ let at = 4;
407
+ while (at < end) {
408
+ const id = segment[at];
409
+ const counts = segment.subarray(at + 1, at + 17);
410
+ let total = 0;
411
+ for (const count2 of counts)
412
+ total += count2;
413
+ const values = segment.slice(at + 17, at + 17 + total);
414
+ const minCode = new Int32Array(17);
415
+ const maxCode = new Int32Array(17).fill(-1);
416
+ const valPtr = new Int32Array(17);
417
+ let code = 0;
418
+ let index = 0;
419
+ for (let length = 1; length <= 16; length++) {
420
+ const count2 = counts[length - 1];
421
+ if (count2 > 0) {
422
+ valPtr[length] = index;
423
+ minCode[length] = code;
424
+ code += count2;
425
+ index += count2;
426
+ maxCode[length] = code - 1;
427
+ }
428
+ code <<= 1;
429
+ }
430
+ into.set(id, { minCode, maxCode, valPtr, values });
431
+ at += 17 + total;
432
+ }
433
+ }
434
+ var BitReader = class {
435
+ /** Byte offset of the next byte to read. */
436
+ position;
437
+ bits = 0;
438
+ count = 0;
439
+ /** Set when the reader stepped over a restart marker — the caller resets its DC predictors. */
440
+ restarted = false;
441
+ data;
442
+ constructor(data, start) {
443
+ this.data = data;
444
+ this.position = start;
445
+ }
446
+ /** The offset the last whole byte ended at — what "how much of the scan did this consume" reads. */
447
+ get consumed() {
448
+ return this.position;
449
+ }
450
+ readBit() {
451
+ if (this.count === 0) {
452
+ if (this.position >= this.data.length)
453
+ throw new ScanEnd("out of data");
454
+ let byte = this.data[this.position++];
455
+ if (byte === 255) {
456
+ const next = this.data[this.position];
457
+ if (next === 0) {
458
+ this.position++;
459
+ } else if (next !== void 0 && next >= RST_FIRST && next <= RST_LAST) {
460
+ this.position++;
461
+ this.restarted = true;
462
+ if (this.position >= this.data.length)
463
+ throw new ScanEnd("out of data");
464
+ byte = this.data[this.position++];
465
+ if (byte === 255)
466
+ throw new ScanEnd("marker after restart");
467
+ } else {
468
+ this.position--;
469
+ throw new ScanEnd("marker");
470
+ }
471
+ }
472
+ this.bits = byte;
473
+ this.count = 8;
474
+ }
475
+ this.count--;
476
+ return this.bits >> this.count & 1;
477
+ }
478
+ /** Read `length` bits, most significant first. */
479
+ readBits(length) {
480
+ let value = 0;
481
+ for (let i = 0; i < length; i++)
482
+ value = value << 1 | this.readBit();
483
+ return value;
484
+ }
485
+ /** Drop the current byte's remaining bits — what a restart interval boundary does. */
486
+ align() {
487
+ this.count = 0;
488
+ }
489
+ };
490
+ function decodeHuffman(reader2, table) {
491
+ let code = reader2.readBit();
492
+ for (let length = 1; length <= 16; length++) {
493
+ if (table.maxCode[length] >= 0 && code <= table.maxCode[length]) {
494
+ return table.values[table.valPtr[length] + code - table.minCode[length]];
495
+ }
496
+ code = code << 1 | reader2.readBit();
497
+ }
498
+ throw new ScanEnd("no huffman code of any length");
499
+ }
500
+ function extend(value, length) {
501
+ return value < 1 << length - 1 ? value - (1 << length) + 1 : value;
502
+ }
503
+ function decodeBlock(reader2, component) {
504
+ const dcLength = decodeHuffman(reader2, component.dc);
505
+ if (dcLength > 16)
506
+ throw new ScanEnd("dc magnitude out of range");
507
+ component.pred += dcLength === 0 ? 0 : extend(reader2.readBits(dcLength), dcLength);
508
+ let k = 1;
509
+ while (k < 64) {
510
+ const rs = decodeHuffman(reader2, component.ac);
511
+ const size = rs & 15;
512
+ const run = rs >> 4;
513
+ if (size === 0) {
514
+ if (run !== 15)
515
+ break;
516
+ k += 16;
517
+ } else {
518
+ k += run;
519
+ if (k > 63)
520
+ throw new ScanEnd("ac coefficient index out of range");
521
+ reader2.readBits(size);
522
+ k++;
523
+ }
524
+ }
525
+ return component.pred;
526
+ }
527
+ function scanEntropy(tail, subsampling, extraTables) {
528
+ const tables = /* @__PURE__ */ new Map();
529
+ for (const segment of extraTables)
530
+ readHuffmanTables(segment, tables);
531
+ let at = 0;
532
+ let restartInterval = 0;
533
+ let scanStart = -1;
534
+ let assignments = [];
535
+ while (at + 3 < tail.length) {
536
+ if (tail[at] !== 255)
537
+ return null;
538
+ const marker = tail[at + 1];
539
+ const length = tail[at + 2] << 8 | tail[at + 3];
540
+ if (marker === DHT) {
541
+ readHuffmanTables(tail.subarray(at, at + 2 + length), tables);
542
+ } else if (marker === DRI) {
543
+ restartInterval = tail[at + 4] << 8 | tail[at + 5];
544
+ } else if (marker === SOS) {
545
+ const count2 = tail[at + 4];
546
+ assignments = [];
547
+ for (let i = 0; i < count2; i++) {
548
+ const id = tail[at + 5 + i * 2];
549
+ const tableByte = tail[at + 6 + i * 2];
550
+ assignments.push({ id, dc: tableByte >> 4, ac: tableByte & 15 });
551
+ }
552
+ scanStart = at + 2 + length;
553
+ break;
554
+ }
555
+ at += 2 + length;
556
+ }
557
+ if (scanStart < 0 || assignments.length !== 3)
558
+ return null;
559
+ const [lumaH, lumaV] = LUMA_SAMPLING[subsampling] ?? LUMA_SAMPLING[0];
560
+ const components = [];
561
+ for (const [index, assignment] of assignments.entries()) {
562
+ const dc = tables.get(assignment.dc);
563
+ const ac = tables.get(16 | assignment.ac);
564
+ if (!dc || !ac)
565
+ return null;
566
+ components.push({ h: index === 0 ? lumaH : 1, v: index === 0 ? lumaV : 1, dc, ac, pred: 0 });
567
+ }
568
+ const reader2 = new BitReader(tail, scanStart);
569
+ let luma = new Int32Array(1024);
570
+ const lumaBlocks = lumaH * lumaV;
571
+ let mcus = 0;
572
+ let consumed = reader2.consumed;
573
+ let complete = false;
574
+ try {
575
+ for (; ; ) {
576
+ if (restartInterval > 0 && mcus > 0 && mcus % restartInterval === 0) {
577
+ reader2.align();
578
+ for (const component of components)
579
+ component.pred = 0;
580
+ }
581
+ if (reader2.restarted) {
582
+ reader2.restarted = false;
583
+ for (const component of components)
584
+ component.pred = 0;
585
+ }
586
+ let lumaSum = 0;
587
+ for (const component of components) {
588
+ for (let block = 0; block < component.h * component.v; block++) {
589
+ const dc = decodeBlock(reader2, component);
590
+ if (component === components[0])
591
+ lumaSum += dc;
592
+ }
593
+ }
594
+ if (mcus === luma.length) {
595
+ const grown = new Int32Array(luma.length * 2);
596
+ grown.set(luma);
597
+ luma = grown;
598
+ }
599
+ luma[mcus] = Math.round(lumaSum / lumaBlocks);
600
+ mcus++;
601
+ consumed = reader2.consumed;
602
+ if (consumed >= tail.length) {
603
+ complete = true;
604
+ break;
605
+ }
606
+ }
607
+ } catch (error) {
608
+ if (!(error instanceof ScanEnd))
609
+ throw error;
610
+ complete = tail.length - consumed <= 2;
611
+ }
612
+ return { mcus, luma: luma.subarray(0, mcus), complete };
613
+ }
614
+
378
615
  // dist/transport/http/decodeImageV2.js
379
- import { decode as jpegDecode, encode as jpegEncode } from "jpeg-js";
616
+ import { decode as jpegDecode } from "jpeg-js";
380
617
  var V2_PREFIX = "v2_eufysecurity:";
381
618
  var DC_CHROMA = Buffer.from([255, 196, 0, 31, 1]);
382
619
  var ZIGZAG = [
@@ -582,35 +819,6 @@ var DHT_AC_LUMA = Buffer.from("ffc400b5100002010303020403050504040000017d0102030
582
819
  var SOI = Buffer.from([255, 216]);
583
820
  var APP0 = Buffer.from("ffe000104a46494600010100000100010000", "hex");
584
821
  var MCU = { 0: [8, 8], 1: [16, 8], 2: [16, 16] };
585
- var LADDER = [
586
- [160, 90],
587
- [240, 135],
588
- [256, 144],
589
- [320, 180],
590
- [384, 216],
591
- [400, 225],
592
- [480, 270],
593
- [512, 288],
594
- [576, 324],
595
- [640, 360],
596
- [704, 396],
597
- [768, 432],
598
- [848, 480],
599
- [960, 540],
600
- [1024, 576],
601
- [1280, 720],
602
- [1600, 900],
603
- [1920, 1080],
604
- [176, 144],
605
- [320, 240],
606
- [352, 288],
607
- [480, 360],
608
- [640, 480],
609
- [800, 600],
610
- [1024, 768],
611
- [256, 480],
612
- [320, 384]
613
- ];
614
822
  function scaleQuant(base, quality) {
615
823
  const factor = quality < 50 ? Math.floor(5e3 / quality) : 200 - quality * 2;
616
824
  return base.map((v) => Math.min(255, Math.max(1, Math.floor((v * factor + 50) / 100))));
@@ -647,7 +855,7 @@ function sofSegment(width, height, subsampling) {
647
855
  1
648
856
  ]);
649
857
  }
650
- function buildHeader(width, height, subsampling, quality = 85) {
858
+ function buildHeader(width, height, subsampling, quality) {
651
859
  return Buffer.concat([
652
860
  SOI,
653
861
  APP0,
@@ -658,99 +866,107 @@ function buildHeader(width, height, subsampling, quality = 85) {
658
866
  DHT_AC_LUMA
659
867
  ]);
660
868
  }
661
- function decodeCandidate(tail, width, height, subsampling) {
869
+ var PROBE_MEMORY_MB = 24;
870
+ function decodeSpliced(header, tail) {
662
871
  try {
663
- const jpeg = Buffer.concat([buildHeader(width, height, subsampling), tail]);
664
- return jpegDecode(jpeg, { useTArray: true, maxMemoryUsageInMB: 128 });
872
+ return jpegDecode(Buffer.concat([header, tail]), { useTArray: true, maxMemoryUsageInMB: PROBE_MEMORY_MB });
665
873
  } catch {
666
874
  return null;
667
875
  }
668
876
  }
669
- function colorSpread(img) {
670
- const { width, height, data } = img;
671
- let sum = 0;
672
- let count2 = 0;
673
- for (let i = 0; i < width * height; i += 37) {
674
- const p = i * 4;
675
- const r = data[p];
676
- const g = data[p + 1];
677
- const b = data[p + 2];
678
- sum += Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
679
- count2++;
680
- }
681
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
682
- }
683
- function rowShear(img, rows) {
684
- const { width, data } = img;
685
- let sum = 0;
686
- let count2 = 0;
687
- for (let y = 1; y < rows; y++) {
688
- for (let x = 0; x < width; x += 4) {
689
- sum += Math.abs(data[(y * width + x) * 4] - data[((y - 1) * width + x) * 4]);
690
- count2++;
691
- }
877
+ var REFERENCE_QUALITY = 85;
878
+ var MAX_STRETCH = 8;
879
+ var CUTOFF_PERCENT = 0.5;
880
+ function trimmedRange(data, channel, total, cutoff) {
881
+ const hist = new Array(256).fill(0);
882
+ for (let i = 0; i < total; i++)
883
+ hist[data[i * 4 + channel]]++;
884
+ let remaining = Math.floor(total * cutoff / 100);
885
+ let lo = 0;
886
+ while (lo < 255 && remaining > 0) {
887
+ if (remaining < hist[lo])
888
+ break;
889
+ remaining -= hist[lo];
890
+ lo++;
891
+ }
892
+ remaining = Math.floor(total * cutoff / 100);
893
+ let hi = 255;
894
+ while (hi > 0 && remaining > 0) {
895
+ if (remaining < hist[hi])
896
+ break;
897
+ remaining -= hist[hi];
898
+ hi--;
692
899
  }
693
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
900
+ return { lo, hi };
694
901
  }
695
- function autoContrast(data, width, height, cutoff = 0.5) {
696
- const total = width * height;
902
+ function contrastScale(img, cutoff = CUTOFF_PERCENT) {
903
+ const total = img.width * img.height;
904
+ if (total === 0)
905
+ return 1;
906
+ let stretch = Number.POSITIVE_INFINITY;
697
907
  for (let channel = 0; channel < 3; channel++) {
698
- const hist = new Array(256).fill(0);
699
- for (let i = 0; i < total; i++)
700
- hist[data[i * 4 + channel]]++;
701
- let remaining = Math.floor(total * cutoff / 100);
702
- for (let bin = 0; bin < 256 && remaining > 0; bin++) {
703
- if (remaining > hist[bin]) {
704
- remaining -= hist[bin];
705
- hist[bin] = 0;
706
- } else {
707
- hist[bin] -= remaining;
708
- remaining = 0;
709
- }
710
- }
711
- remaining = Math.floor(total * cutoff / 100);
712
- for (let bin = 255; bin >= 0 && remaining > 0; bin--) {
713
- if (remaining > hist[bin]) {
714
- remaining -= hist[bin];
715
- hist[bin] = 0;
716
- } else {
717
- hist[bin] -= remaining;
718
- remaining = 0;
719
- }
720
- }
721
- let lo = 0;
722
- while (lo < 256 && hist[lo] === 0)
723
- lo++;
724
- let hi = 255;
725
- while (hi >= 0 && hist[hi] === 0)
726
- hi--;
908
+ const { lo, hi } = trimmedRange(img.data, channel, total, cutoff);
727
909
  if (hi <= lo)
728
910
  continue;
729
- const scale = 255 / (hi - lo);
730
- const offset = -lo * scale;
731
- const lut = new Uint8Array(256);
732
- for (let v = 0; v < 256; v++)
733
- lut[v] = Math.min(255, Math.max(0, Math.trunc(v * scale + offset)));
734
- for (let i = 0; i < total; i++)
735
- data[i * 4 + channel] = lut[data[i * 4 + channel]];
736
- }
737
- }
738
- function maxHeight(tail, width, subsampling, seed) {
739
- const mcuHeight = MCU[subsampling][1];
740
- let height = Math.max(mcuHeight, Math.round(seed / mcuHeight) * mcuHeight);
741
- if (!decodeCandidate(tail, width, height, subsampling)) {
742
- while (height > mcuHeight && !decodeCandidate(tail, width, height, subsampling))
743
- height -= mcuHeight;
744
- } else {
745
- while (height < 4096 && decodeCandidate(tail, width, height + mcuHeight, subsampling))
746
- height += mcuHeight;
747
- }
748
- return height;
749
- }
750
- function ladderByMcuCount(subsampling) {
751
- const [mcuWidth, mcuHeight] = MCU[subsampling];
752
- const mcus = ([width, height]) => width / mcuWidth * (height / mcuHeight);
753
- return [...LADDER].sort((a, b) => mcus(a) - mcus(b));
911
+ if (lo < 128)
912
+ stretch = Math.min(stretch, 128 / (128 - lo));
913
+ if (hi > 128)
914
+ stretch = Math.min(stretch, 127 / (hi - 128));
915
+ }
916
+ if (!Number.isFinite(stretch))
917
+ return 1;
918
+ return Math.min(MAX_STRETCH, Math.max(1, stretch));
919
+ }
920
+ function qualityForStretch(stretch) {
921
+ const referenceFactor = REFERENCE_QUALITY < 50 ? Math.floor(5e3 / REFERENCE_QUALITY) : 200 - REFERENCE_QUALITY * 2;
922
+ const factor = referenceFactor * stretch;
923
+ const quality = factor <= 100 ? (200 - factor) / 2 : 5e3 / factor;
924
+ return Math.min(99, Math.max(1, Math.round(quality)));
925
+ }
926
+ var MAX_EDGE = 4096;
927
+ var MIN_ASPECT = 0.4;
928
+ var MAX_ASPECT = 4;
929
+ function mcuRowShear(luma, mcuWidth, mcuHeight) {
930
+ if (mcuHeight < 2)
931
+ return Number.POSITIVE_INFINITY;
932
+ let sum = 0;
933
+ for (let y = 1; y < mcuHeight; y++) {
934
+ for (let x = 0; x < mcuWidth; x++)
935
+ sum += Math.abs(luma[y * mcuWidth + x] - luma[(y - 1) * mcuWidth + x]);
936
+ }
937
+ return sum / (mcuWidth * (mcuHeight - 1));
938
+ }
939
+ function bestGeometry(luma, mcus, subsampling) {
940
+ const [mcuPixelWidth, mcuPixelHeight] = MCU[subsampling];
941
+ let best = null;
942
+ for (let mcuWidth = 1; mcuWidth <= mcus; mcuWidth++) {
943
+ if (mcus % mcuWidth !== 0)
944
+ continue;
945
+ const mcuHeight = mcus / mcuWidth;
946
+ const width = mcuWidth * mcuPixelWidth;
947
+ const height = mcuHeight * mcuPixelHeight;
948
+ if (width > MAX_EDGE || height > MAX_EDGE)
949
+ continue;
950
+ const aspect = width / height;
951
+ if (aspect < MIN_ASPECT || aspect > MAX_ASPECT)
952
+ continue;
953
+ const shear = mcuRowShear(luma, mcuWidth, mcuHeight);
954
+ if (!best || shear < best.shear)
955
+ best = { subsampling, width, height, shear };
956
+ }
957
+ return best;
958
+ }
959
+ function findGeometry(tail) {
960
+ let best = null;
961
+ for (const subsampling of [0, 1, 2]) {
962
+ const scan = scanEntropy(tail, subsampling, [DHT_DC_LUMA, DHT_AC_LUMA]);
963
+ if (!scan?.complete || scan.mcus < 1)
964
+ continue;
965
+ const geometry = bestGeometry(scan.luma, scan.mcus, subsampling);
966
+ if (geometry && (!best || geometry.shear < best.shear))
967
+ best = geometry;
968
+ }
969
+ return best;
754
970
  }
755
971
  function isV2Image(data) {
756
972
  return data.length >= V2_PREFIX.length && data.subarray(0, V2_PREFIX.length).toString("latin1") === V2_PREFIX;
@@ -762,53 +978,17 @@ function decodeImageV2(data) {
762
978
  if (cut < 0)
763
979
  return null;
764
980
  const tail = data.subarray(cut);
765
- let best = null;
766
- for (const subsampling2 of [2, 0, 1]) {
767
- let filled = null;
768
- for (const [width2, height2] of ladderByMcuCount(subsampling2)) {
769
- const img2 = decodeCandidate(tail, width2, height2, subsampling2);
770
- if (!img2)
771
- break;
772
- if (!filled || width2 * height2 > filled.width * filled.height)
773
- filled = { width: width2, height: height2, img: img2 };
774
- }
775
- if (!filled)
776
- continue;
777
- const spread = colorSpread(filled.img);
778
- if (!best || spread < best.spread)
779
- best = { spread, subsampling: subsampling2, width: filled.width, height: filled.height };
780
- }
781
- if (!best)
981
+ const geometry = findGeometry(tail);
982
+ if (!geometry)
782
983
  return null;
783
- const { subsampling, width: coarseWidth } = best;
784
- const [mcuWidth, mcuHeight] = MCU[subsampling];
785
- const coarseHeight = maxHeight(tail, coarseWidth, subsampling, best.height);
786
- const totalMcus = coarseWidth / mcuWidth * (coarseHeight / mcuHeight);
787
- let width = coarseWidth;
788
- let height = coarseHeight;
789
- let bestShear = Number.POSITIVE_INFINITY;
790
- const lowWidth = Math.max(mcuWidth * 4, Math.round(coarseWidth * 0.75 / mcuWidth) * mcuWidth);
791
- const highWidth = Math.round(coarseWidth * 1.25 / mcuWidth) * mcuWidth;
792
- for (let candidateWidth = lowWidth; candidateWidth <= highWidth; candidateWidth += mcuWidth) {
793
- const candidateHeight = Math.floor(totalMcus / (candidateWidth / mcuWidth)) * mcuHeight;
794
- if (candidateHeight < mcuHeight)
795
- continue;
796
- const img2 = decodeCandidate(tail, candidateWidth, candidateHeight, subsampling);
797
- if (!img2)
798
- continue;
799
- const shear = rowShear(img2, candidateHeight);
800
- if (shear < bestShear) {
801
- bestShear = shear;
802
- width = candidateWidth;
803
- height = candidateHeight;
804
- }
805
- }
806
- height = maxHeight(tail, width, subsampling, height);
807
- const img = decodeCandidate(tail, width, height, subsampling);
808
- if (!img)
984
+ const { width, height, subsampling } = geometry;
985
+ const reference = buildHeader(width, height, subsampling, REFERENCE_QUALITY);
986
+ const probe = decodeSpliced(reference, tail);
987
+ if (!probe)
809
988
  return null;
810
- autoContrast(img.data, img.width, img.height);
811
- return Buffer.from(jpegEncode({ data: img.data, width: img.width, height: img.height }, 90).data);
989
+ const stretch = contrastScale(probe);
990
+ const header = stretch > 1.01 ? buildHeader(width, height, subsampling, qualityForStretch(stretch)) : reference;
991
+ return Buffer.concat([header, tail]);
812
992
  }
813
993
 
814
994
  // dist/transport/http/decodeImageV1.js
@@ -1420,9 +1600,20 @@ var MegaHttpClient = class {
1420
1600
  throw error;
1421
1601
  }
1422
1602
  }
1423
- /** Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available. */
1603
+ /**
1604
+ * Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available.
1605
+ *
1606
+ * A decoder throw is tagged `decode-failed`: to anything downstream, the difference between "the
1607
+ * bytes never arrived" and "the bytes arrived and the wrapper would not decrypt" is the difference
1608
+ * between a network problem and a key problem, and one of them is this SDK's to fix.
1609
+ */
1424
1610
  async downloadImage(url, p2pDid) {
1425
- return normalizePushImage(await this.downloadMedia(url), p2pDid);
1611
+ const data = await this.downloadMedia(url);
1612
+ try {
1613
+ return normalizePushImage(data, p2pDid);
1614
+ } catch (error) {
1615
+ throw mediaFailureError("Push image could not be decoded", "decode-failed", error);
1616
+ }
1426
1617
  }
1427
1618
  /** The security-app data host for this region (face recognition, media, etc.). */
1428
1619
  securityAppHost() {
@@ -12880,7 +13071,7 @@ function lz4BlockDecompress(block, size) {
12880
13071
  const end = block.length;
12881
13072
  let pos = 0;
12882
13073
  let written = 0;
12883
- const extend = (base) => {
13074
+ const extend2 = (base) => {
12884
13075
  let len = base;
12885
13076
  if (base !== 15)
12886
13077
  return len;
@@ -12895,7 +13086,7 @@ function lz4BlockDecompress(block, size) {
12895
13086
  };
12896
13087
  while (pos < end) {
12897
13088
  const token = block[pos++];
12898
- const literals = extend(token >> 4);
13089
+ const literals = extend2(token >> 4);
12899
13090
  if (literals === void 0)
12900
13091
  return void 0;
12901
13092
  if (pos + literals > end || written + literals > size)
@@ -12910,7 +13101,7 @@ function lz4BlockDecompress(block, size) {
12910
13101
  pos += 2;
12911
13102
  if (offset === 0 || offset > written)
12912
13103
  return void 0;
12913
- const match = extend(token & 15);
13104
+ const match = extend2(token & 15);
12914
13105
  if (match === void 0)
12915
13106
  return void 0;
12916
13107
  const length = match + 4;
@@ -14876,6 +15067,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14876
15067
  if (host && !this.closed)
14877
15068
  this.selfAddress = { host, port: boundPort };
14878
15069
  });
15070
+ this.trace({
15071
+ phase: "lookup-channels",
15072
+ local: !this.cfg.noBroadcast || this.cfg.localAddress !== void 0,
15073
+ cloud: this.cfg.dskKey !== void 0 && (this.cfg.cloudAddresses?.length ?? 0) > 0
15074
+ });
14879
15075
  this.sendLookups();
14880
15076
  this.lookupTimer = setInterval(() => this.sendLookups(), LOOKUP_RETRY_MS);
14881
15077
  this.connectTimer = setTimeout(() => {
@@ -14958,8 +15154,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14958
15154
  for (const addr of this.cfg.cloudAddresses)
14959
15155
  this.send(addr, type, payload);
14960
15156
  this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: cloud -> ${this.cfg.cloudAddresses.map((a) => `${a.host}:${a.port}`).join(", ")} self=${this.selfAddress?.host}:${this.selfAddress?.port}`);
14961
- } else {
14962
- this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: NO cloud lookup sent (dskKey=${!!this.cfg.dskKey} cloudAddresses=${this.cfg.cloudAddresses?.length ?? 0})`);
14963
15157
  }
14964
15158
  }
14965
15159
  /**
@@ -19392,7 +19586,6 @@ var FragmentRecording = class extends EventEmitter6 {
19392
19586
 
19393
19587
  // dist/transport/p2p/command-router.js
19394
19588
  var DIRECT_CMD_SENDS = 5;
19395
- var CONNECT_WAIT_MS = 2e4;
19396
19589
  function abortable(work, signal) {
19397
19590
  if (!signal)
19398
19591
  return work;
@@ -19407,7 +19600,7 @@ function abortable(work, signal) {
19407
19600
  var LEVEL2_GRACE_MS = 25e3;
19408
19601
  var LEVEL2_SETTLE_MS = 8e3;
19409
19602
  var P2P_STATION_WAITS = {
19410
- connect: CONNECT_WAIT_MS,
19603
+ connect: CONNECT_TIMEOUT_MS,
19411
19604
  level2Grace: LEVEL2_GRACE_MS,
19412
19605
  level2Settle: LEVEL2_SETTLE_MS
19413
19606
  };
@@ -20578,12 +20771,20 @@ var P2PCommandRouter = class _P2PCommandRouter {
20578
20771
  this.manager.bumpCommand(parentSn, parentSn);
20579
20772
  const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
20580
20773
  const stationAdminId = raw.member?.admin_user_id;
20774
+ const stationModel = this.recordFor(parentSn)?.model;
20581
20775
  const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
20776
+ this.traceOnStation(session, {
20777
+ phase: "station-resolved",
20778
+ topology: homeBaseAttached ? "attached" : "own",
20779
+ channel,
20780
+ stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other",
20781
+ ...stationModel ? { stationModel } : {}
20782
+ });
20582
20783
  const t0 = Date.now();
20583
20784
  let waitedMs = 0;
20584
20785
  if (!session.isConnected) {
20585
- this.traceOnStation(session, { phase: "session-connect-wait", waitMs: CONNECT_WAIT_MS });
20586
- while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
20786
+ this.traceOnStation(session, { phase: "session-connect-wait", waitMs: P2P_STATION_WAITS.connect });
20787
+ while (!session.isConnected && Date.now() - t0 < P2P_STATION_WAITS.connect) {
20587
20788
  opts.signal?.throwIfAborted();
20588
20789
  await sleep2(200);
20589
20790
  }
@@ -20593,12 +20794,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
20593
20794
  opts.signal?.throwIfAborted();
20594
20795
  if (!session.isConnected)
20595
20796
  throw new StationUnreachableError(parentSn, waitedMs);
20596
- this.traceOnStation(session, {
20597
- phase: "station-resolved",
20598
- topology: homeBaseAttached ? "attached" : "own",
20599
- channel,
20600
- stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other"
20601
- });
20602
20797
  if (opts.waitLevel2) {
20603
20798
  if (opts.waitLevel2 === "settle") {
20604
20799
  await abortable(session.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
@@ -23363,8 +23558,21 @@ var FileFcmStore = class {
23363
23558
  }
23364
23559
  };
23365
23560
 
23561
+ // dist/transport/media-failure.js
23562
+ var MEDIA_FAILURE_REASONS = [
23563
+ "url-not-allowed",
23564
+ "address-not-public",
23565
+ "redirect-not-allowed",
23566
+ "http-status",
23567
+ "too-large",
23568
+ "timeout",
23569
+ "network",
23570
+ "decode-failed"
23571
+ ];
23572
+
23366
23573
  // dist/transport/stored-image-cache.js
23367
23574
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
23575
+ var MAX_REMEMBERED_URLS = 64;
23368
23576
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
23369
23577
  var StoredImageCache = class {
23370
23578
  downloader;
@@ -23381,7 +23589,13 @@ var StoredImageCache = class {
23381
23589
  this.clock = clock;
23382
23590
  this.isLifecycleError = isLifecycleError;
23383
23591
  }
23384
- /** Observe a normalized thumbnail URL and start acquisition eagerly. */
23592
+ /**
23593
+ * Observe a normalized thumbnail URL and start acquisition eagerly.
23594
+ *
23595
+ * A URL already inside this device's window of recent attempts is ignored, so one event arriving as
23596
+ * several pushes downloads one thumbnail. The window is a `Set`, which iterates in insertion order,
23597
+ * so the entry evicted once it is full is the oldest attempt.
23598
+ */
23385
23599
  observe(deviceKey, url) {
23386
23600
  let state = this.devices.get(deviceKey);
23387
23601
  if (!state) {
@@ -23396,6 +23610,12 @@ var StoredImageCache = class {
23396
23610
  if (state.seenUrls.has(url))
23397
23611
  return;
23398
23612
  state.seenUrls.add(url);
23613
+ while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
23614
+ const oldest = state.seenUrls.values().next();
23615
+ if (oldest.done)
23616
+ break;
23617
+ state.seenUrls.delete(oldest.value);
23618
+ }
23399
23619
  state.queued = {
23400
23620
  deviceKey,
23401
23621
  url,
@@ -23445,7 +23665,7 @@ var StoredImageCache = class {
23445
23665
  } else if (image === void 0) {
23446
23666
  state.lifecycleError = void 0;
23447
23667
  state.reason = "download-failed";
23448
- this.diagnose(state, candidate, "download-failed");
23668
+ this.diagnose(state, candidate, "download-failed", error);
23449
23669
  } else if (!this.isValidJpeg(image)) {
23450
23670
  state.lifecycleError = void 0;
23451
23671
  state.reason = "invalid-image";
@@ -23461,7 +23681,7 @@ var StoredImageCache = class {
23461
23681
  isValidJpeg(image) {
23462
23682
  return Buffer.isBuffer(image) && image.length > 0 && image.length <= MAX_JPEG_BYTES && image.length >= 5 && image[0] === 255 && image[1] === 216 && image[2] === 255 && image[image.length - 2] === 255 && image[image.length - 1] === 217;
23463
23683
  }
23464
- diagnose(state, candidate, failure) {
23684
+ diagnose(state, candidate, failure, error) {
23465
23685
  const now2 = this.clock();
23466
23686
  const last = state.loggedFailures.get(failure);
23467
23687
  if (last !== void 0 && now2 - last < DIAGNOSTIC_INTERVAL_MS)
@@ -23469,11 +23689,23 @@ var StoredImageCache = class {
23469
23689
  state.loggedFailures.set(failure, now2);
23470
23690
  this.logger.warn("[stored-snapshot-cache] candidate failed", {
23471
23691
  class: failure,
23692
+ ...mediaFailureTag(error),
23472
23693
  observedAt: candidate.observedAt,
23473
23694
  retained: state.retained !== void 0
23474
23695
  });
23475
23696
  }
23476
23697
  };
23698
+ function mediaFailureTag(error) {
23699
+ if (typeof error !== "object" || error === null)
23700
+ return {};
23701
+ const { mediaFailure, status } = error;
23702
+ if (typeof mediaFailure !== "string")
23703
+ return {};
23704
+ const cause = MEDIA_FAILURE_REASONS.find((reason) => reason === mediaFailure);
23705
+ if (!cause)
23706
+ return {};
23707
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? { cause, status } : { cause };
23708
+ }
23477
23709
 
23478
23710
  // dist/client/device-registry.js
23479
23711
  function resolvedStationSn(raw, sn) {
@@ -26616,6 +26848,7 @@ export {
26616
26848
  CLEAN_EXTENTS,
26617
26849
  CLEAN_FINISH_REASONS,
26618
26850
  CLEAN_PARAMS,
26851
+ CONNECT_TIMEOUT_MS,
26619
26852
  CONTACT_MEMBERS,
26620
26853
  CO_MEMBERS,
26621
26854
  CameraDisabledError,
@@ -26742,7 +26975,6 @@ export {
26742
26975
  aesKey,
26743
26976
  asBool,
26744
26977
  assertNever,
26745
- autoContrast,
26746
26978
  bizChannelName,
26747
26979
  buildActions,
26748
26980
  buildAppShapedClientId,