@mega-yfue/eufy-sdk 0.6.0-beta.30 → 0.6.0-beta.32

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.
@@ -153,8 +153,9 @@ export declare class DeviceChannelUnresolvedError extends Error {
153
153
  /**
154
154
  * Why {@link MediaProvider.downloadRecording} returned no recording.
155
155
  *
156
- * - `invalid-recording` — the recording name is not one the station could hold.
157
- * - `key-unavailable` — the key the recording was stored under could not be obtained.
156
+ * - `invalid-recording` — the recording name is not one the camera's station could hold.
157
+ * - `key-unavailable` — the station stores the recording sealed and the key it was sealed under could not be
158
+ * obtained.
158
159
  * - `no-data` — the station sent nothing within the wait.
159
160
  * - `incomplete` — the transfer ended before the station's finish frame.
160
161
  * - `undecodable` — data arrived but no video frame could be decoded from it.
@@ -170,10 +171,10 @@ export declare class RecordingDownloadError extends Error {
170
171
  /**
171
172
  * A recording downloaded from the station, as elementary streams.
172
173
  *
173
- * `video` is Annex-B H.264 in decode order, `audio` AAC-LC 16 kHz mono in ADTS framing (absent when the
174
- * recording carried none that could be decoded). `frames` counts the distinct video sequences emitted.
175
- * `missingFrames` counts gaps between received sequences and received sequences that yielded no usable
176
- * picture, including at either end. `durationMs` spans the first to the last emitted picture by the
174
+ * `video` is Annex-B H.264 or H.265 in decode order, `audio` AAC-LC 16 kHz mono in ADTS framing (absent
175
+ * when the recording carried none that could be decoded). `frames` counts the distinct video sequences
176
+ * emitted. `missingFrames` counts gaps between received sequences and received sequences that yielded no
177
+ * usable picture, including at either end. `durationMs` spans the first to the last emitted picture by the
177
178
  * camera's own timestamps; `fps` is derived from those stamps.
178
179
  */
179
180
  export interface RecordingDownload {
@@ -884,9 +885,11 @@ export interface MediaProvider {
884
885
  encoder?: AacEncoder;
885
886
  } & SharedSourceHints): Promise<TalkbackHandle>;
886
887
  /**
887
- * Download one recording the camera's station stored, identified by the recording name and cipher id of
888
- * the event. One download runs at a time per station. Rejects with {@link RecordingDownloadError}.
889
- * Optional, and absent on a camera whose station's recording download is unverified.
888
+ * Download one recording the camera's station stored. `recording` names it: the file name the station stores
889
+ * it under, without the folder or the extension. `cipherId` is the cipher the station names for it, and is not
890
+ * read on a station that stores its recordings in the clear. One download runs at a time per station. Rejects with
891
+ * {@link RecordingDownloadError}. Optional, and absent on a camera whose station's recording download is
892
+ * unverified.
890
893
  */
891
894
  downloadRecording?(opts: {
892
895
  recording: string;
package/dist/index.js CHANGED
@@ -1609,11 +1609,17 @@ var CONTENDED_SESSION_HINT = "the token was rejected again soon after it was las
1609
1609
  function tokenRejected(code, msg) {
1610
1610
  return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
1611
1611
  }
1612
+ function shardOfDomain(domain) {
1613
+ const m = /-([a-z]{2})(?:[.-]|$)/i.exec(domain);
1614
+ return m ? `${m[1].toLowerCase()}-pr` : void 0;
1615
+ }
1612
1616
  var MegaHttpClient = class {
1613
1617
  cfg;
1614
1618
  region;
1615
1619
  /** The host `estimate_domain` answered, persisted with the session. */
1616
1620
  estimatedDomain = "";
1621
+ /** The in-flight (then settled, so cached) RTC-sign shard request from {@link portalShard}. */
1622
+ portalShardInFlight;
1617
1623
  bootstrapDomain;
1618
1624
  sessionKey;
1619
1625
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
@@ -1730,13 +1736,36 @@ var MegaHttpClient = class {
1730
1736
  return this.region;
1731
1737
  }
1732
1738
  /**
1733
- * The shard the RTC signalling signs on: the regional prefix of the estimated domain
1734
- * (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The eu/us classification cannot name shards
1735
- * like `ie-pr`, whose accounts need the IE sign host and cluster.
1739
+ * The shard the estimated domain names (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The
1740
+ * eu/us classification cannot name shards like `ie-pr`.
1736
1741
  */
1737
1742
  get rtcShard() {
1738
- const m = /-([a-z]{2})(?:[.-]|$)/i.exec(this.estimatedDomain);
1739
- return m ? `${m[1].toLowerCase()}-pr` : this.region;
1743
+ return shardOfDomain(this.estimatedDomain) ?? this.region;
1744
+ }
1745
+ /**
1746
+ * The shard the RTC signalling signs on, from `estimate_domain` asked with the portal's WEB identity
1747
+ * (`Model-Type: WEB`, `App-Name: eufy_mega`) — the identity the sign itself is sent with, which can name
1748
+ * a different shard than the PHONE identity does for one account. The in-flight request is shared by
1749
+ * concurrent first callers, and once it settles to a shard the resolved promise is the cache; a call
1750
+ * that fails or names no shard answers {@link rtcShard}, clears the field and is asked again next time.
1751
+ */
1752
+ async portalShard() {
1753
+ return this.portalShardInFlight ??= this.fetchPortalShard();
1754
+ }
1755
+ async fetchPortalShard() {
1756
+ try {
1757
+ const res = await this.estimateDomainRequest({ "Model-Type": "WEB", "App-Name": "eufy_mega" });
1758
+ const domain = res.data?.data?.domain;
1759
+ const shard = typeof domain === "string" ? shardOfDomain(domain) : void 0;
1760
+ if (shard) {
1761
+ this.logger.debug("[mega] portal shard:", shard);
1762
+ return shard;
1763
+ }
1764
+ } catch (e) {
1765
+ this.logger.debug("[mega] portal estimate_domain failed:", e instanceof Error ? e.message : String(e));
1766
+ }
1767
+ this.portalShardInFlight = void 0;
1768
+ return this.rtcShard;
1740
1769
  }
1741
1770
  /**
1742
1771
  * The credentials the RTC signalling needs; `undefined` while logged out. The sign is refused
@@ -1833,13 +1862,13 @@ var MegaHttpClient = class {
1833
1862
  });
1834
1863
  return { status: res.status, data: parseMaybeJson(await res.text()) };
1835
1864
  }
1865
+ /** POST `estimate_domain` with the given identity headers. */
1866
+ estimateDomainRequest(headers) {
1867
+ return this.httpPost(`https://mega-${this.region}.eufy.com/passport/estimate_domain`, { ab: this.cfg.countryCode.toLowerCase(), mode: 1 }, { ...headers, "content-type": "application/json" });
1868
+ }
1836
1869
  /** Step 1: discover the account's region shard (and the bootstrap host). */
1837
1870
  async estimateDomain() {
1838
- const url = `https://mega-${this.region}.eufy.com/passport/estimate_domain`;
1839
- const res = await this.httpPost(url, { ab: this.cfg.countryCode.toLowerCase(), mode: 1 }, {
1840
- ...this.baseHeaders(),
1841
- "content-type": "application/json"
1842
- });
1871
+ const res = await this.estimateDomainRequest(this.baseHeaders());
1843
1872
  this.logger.debug("[mega] estimate_domain raw:", JSON.stringify(res.data).slice(0, 400));
1844
1873
  const env = res.data;
1845
1874
  const d = env?.data ?? {};
@@ -18951,15 +18980,19 @@ var VideoFrameDecoder = class {
18951
18980
  var STATION_CHUNK_BYTES = 64e3;
18952
18981
  var AccessUnitAssembler = class {
18953
18982
  onDropped;
18983
+ chunkBytes;
18954
18984
  open;
18955
18985
  droppedUnits = 0;
18956
18986
  /**
18957
18987
  * @param onDropped called for each incomplete unit discarded, with what had arrived, how many frames it
18958
18988
  * arrived in, and how many this assembler has dropped in total — a running count, so no caller keeps
18959
18989
  * its own.
18990
+ * @param chunkBytes the payload size the station fills a frame to before continuing a unit in the next. A
18991
+ * unit is held while its latest frame declares exactly this. {@link STATION_CHUNK_BYTES} when omitted.
18960
18992
  */
18961
- constructor(onDropped) {
18993
+ constructor(onDropped, chunkBytes = STATION_CHUNK_BYTES) {
18962
18994
  this.onDropped = onDropped;
18995
+ this.chunkBytes = chunkBytes;
18963
18996
  }
18964
18997
  /**
18965
18998
  * Feed one raw `CMD_VIDEO_FRAME` payload; returns the access units it completed — none while a unit is
@@ -18978,7 +19011,7 @@ var AccessUnitAssembler = class {
18978
19011
  this.discard();
18979
19012
  return [];
18980
19013
  }
18981
- const full = header.payloadLength === STATION_CHUNK_BYTES;
19014
+ const full = header.payloadLength === this.chunkBytes;
18982
19015
  const open2 = this.open;
18983
19016
  if (open2 && sameUnit(open2.header, header) && !beginsAccessUnit(body)) {
18984
19017
  open2.chunks.push(body);
@@ -19992,16 +20025,31 @@ function annexbToJpeg(annexb, spawn2) {
19992
20025
  var RECORDING_DATA_TYPE = 3;
19993
20026
  var VIDEO_HEADER_LEN2 = 22;
19994
20027
  var AUDIO_CODEC_AAC_LC = 0;
19995
- var FINISH_CHANNEL = 0;
19996
20028
  var FIRST_FRAME_WAIT_MS = 2e4;
19997
20029
  var IDLE_END_MS = 15e3;
19998
20030
  var DEFAULT_TRANSFER_MS = 12e4;
19999
20031
  var MAX_TRANSFER_BYTES = 32 * 1024 * 1024;
20032
+ function cameraFolder(channel) {
20033
+ if (!Number.isInteger(channel) || channel < 0 || channel > 99)
20034
+ return void 0;
20035
+ return `Camera${String(channel).padStart(2, "0")}`;
20036
+ }
20000
20037
  function homeBase2RecordingPath(channel, recording) {
20001
- if (!/^(?:h265_)?\d{14}$/.test(recording) || !Number.isInteger(channel) || channel < 0 || channel > 99)
20038
+ const folder = cameraFolder(channel);
20039
+ if (!folder || !/^(?:h265_)?\d{14}$/.test(recording))
20040
+ return void 0;
20041
+ return `/media/mmcblk0p1/${folder}/${recording}.dat`;
20042
+ }
20043
+ function homeBase3RecordingPath(channel, recording) {
20044
+ const folder = cameraFolder(channel);
20045
+ if (!folder || !/^\d{14}$/.test(recording))
20002
20046
  return void 0;
20003
- return `/media/mmcblk0p1/Camera${String(channel).padStart(2, "0")}/${recording}.dat`;
20047
+ return `/zx/emmcdata/${folder}/${recording.slice(0, 6)}/${recording}/${recording}.zxvideo`;
20004
20048
  }
20049
+ var RECORDING_STATIONS = /* @__PURE__ */ new Map([
20050
+ ["T8010", { path: homeBase2RecordingPath, sealed: true, finishChannel: 0, chunkBytes: STATION_CHUNK_BYTES }],
20051
+ ["T8030", { path: homeBase3RecordingPath, sealed: false, finishChannel: STATION_CHANNEL3, chunkBytes: 4e5 }]
20052
+ ]);
20005
20053
  function receiveRecording(session, request) {
20006
20054
  let resolveFrames;
20007
20055
  let rejectFrames;
@@ -20045,7 +20093,7 @@ function receiveRecording(session, request) {
20045
20093
  };
20046
20094
  const onData = (frame) => {
20047
20095
  if (frame.commandId === CommandType.CMD_DOWNLOAD_FINISH) {
20048
- if (frame.channel !== request.channel && frame.channel !== FINISH_CHANNEL)
20096
+ if (frame.channel !== request.channel && frame.channel !== request.finishChannel)
20049
20097
  return;
20050
20098
  if (draining)
20051
20099
  incomplete(draining);
@@ -20102,12 +20150,17 @@ function receiveRecording(session, request) {
20102
20150
  }
20103
20151
  return { frames, drained };
20104
20152
  }
20105
- function decodeRecording(frames, eccPrivateKeyHex) {
20106
- const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
20107
- if (eccPrivateKey.length !== 32)
20153
+ function clearVideoPayload(raw, header) {
20154
+ const end = VIDEO_HEADER_LEN2 + header.payloadLength;
20155
+ return raw.length >= end ? raw.subarray(VIDEO_HEADER_LEN2, end) : void 0;
20156
+ }
20157
+ function decodeRecording(frames, station, eccPrivateKeyHex) {
20158
+ const eccPrivateKey = station.sealed ? Buffer.from(eccPrivateKeyHex ?? "", "hex") : void 0;
20159
+ if (eccPrivateKey && eccPrivateKey.length !== 32) {
20108
20160
  throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
20109
- const decoder = new VideoFrameDecoder(eccPrivateKey);
20110
- const assembler = new AccessUnitAssembler();
20161
+ }
20162
+ const decoder = eccPrivateKey && new VideoFrameDecoder(eccPrivateKey);
20163
+ const assembler = new AccessUnitAssembler(void 0, station.chunkBytes);
20111
20164
  const video = [];
20112
20165
  const audio = [];
20113
20166
  const numbers = /* @__PURE__ */ new Set();
@@ -20120,7 +20173,7 @@ function decodeRecording(frames, eccPrivateKeyHex) {
20120
20173
  if (!header)
20121
20174
  continue;
20122
20175
  receivedNumbers.add(header.sequence);
20123
- const units = assembler.push(raw, (payload) => header.keyframe ? decoder.decodeFrame(payload)?.h264 : payload.length >= VIDEO_HEADER_LEN2 + header.payloadLength ? payload.subarray(VIDEO_HEADER_LEN2, VIDEO_HEADER_LEN2 + header.payloadLength) : void 0);
20176
+ const units = assembler.push(raw, (payload) => decoder && header.keyframe ? decoder.decodeFrame(payload)?.h264 : clearVideoPayload(payload, header));
20124
20177
  for (const unit of units) {
20125
20178
  video.push(unit.data);
20126
20179
  numbers.add(header.sequence);
@@ -20129,10 +20182,10 @@ function decodeRecording(frames, eccPrivateKeyHex) {
20129
20182
  lastStamp = stamp;
20130
20183
  }
20131
20184
  } else if (commandId === CommandType.CMD_AUDIO_FRAME) {
20132
- const mediaKey = decoder.currentMediaKey;
20133
- if (!mediaKey || raw[5] !== AUDIO_CODEC_AAC_LC)
20185
+ if (raw[5] !== AUDIO_CODEC_AAC_LC)
20134
20186
  continue;
20135
- const aac = openSealedAudio(raw, mediaKey);
20187
+ const mediaKey = decoder?.currentMediaKey;
20188
+ const aac = decoder ? mediaKey && openSealedAudio(raw, mediaKey) : raw.subarray(AUDIO_HEADER_LEN);
20136
20189
  if (aac?.length)
20137
20190
  audio.push(aac);
20138
20191
  }
@@ -22864,7 +22917,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
22864
22917
  * start has no level-1 form for.
22865
22918
  */
22866
22919
  mediaProviderFor(sn) {
22867
- const recordings = this.downloadsRecordings(sn) ? { downloadRecording: (opts) => this.downloadRecording(sn, opts) } : {};
22920
+ const station = this.recordingStationFor(sn);
22921
+ const recordings = station ? { downloadRecording: (opts) => this.downloadRecording(sn, station, opts) } : {};
22868
22922
  return {
22869
22923
  ...recordings,
22870
22924
  snapshotLive: async (opts) => {
@@ -22906,29 +22960,32 @@ var P2PCommandRouter = class _P2PCommandRouter {
22906
22960
  };
22907
22961
  }
22908
22962
  /**
22909
- * Whether `sn` is a camera attached to a HomeBase 2 (T8010), the one station whose recording path layout
22910
- * and frame formats are confirmed.
22963
+ * The {@link RecordingStation} of the station `sn` is attached to, when `sn` is a camera attached to a
22964
+ * station with an entry in {@link RECORDING_STATIONS}. A standalone device, or a camera on any other
22965
+ * station, answers `undefined`.
22911
22966
  */
22912
- downloadsRecordings(sn) {
22967
+ recordingStationFor(sn) {
22913
22968
  const dev = this.recordFor(sn);
22914
22969
  if (!dev)
22915
- return false;
22970
+ return void 0;
22916
22971
  const parentSn = stationOf(dev);
22917
- return parentSn !== sn && this.recordFor(parentSn)?.model === "T8010";
22972
+ if (parentSn === sn)
22973
+ return void 0;
22974
+ return RECORDING_STATIONS.get(this.recordFor(parentSn)?.model ?? "");
22918
22975
  }
22919
22976
  /**
22920
- * Download one recording a HomeBase 2 holds for an attached camera, then decode it
22921
- * ({@link decodeRecording}). Downloads queue per station: the station streams one recording at a time
22922
- * on the camera's channel, so a download starts only once the previous one has drained
22923
- * ({@link RecordingTransfer.drained}), even when that one's caller already has its answer. An abort
22924
- * while waiting rejects without sending anything; an abort mid-transfer rejects at once and the station's
22925
- * turn is held until the transfer drains.
22977
+ * Download one recording `station` holds for the attached camera `sn`, then decode it
22978
+ * ({@link decodeRecording}). `station` is the entry {@link mediaProviderFor} bound. Downloads queue per
22979
+ * station: the station streams one recording at a time on the camera's channel, so a download starts only
22980
+ * once the previous one has drained ({@link RecordingTransfer.drained}), even when that one's caller already
22981
+ * has its answer. An abort while waiting rejects without sending anything; an abort mid-transfer rejects at
22982
+ * once and the station's turn is held until the transfer drains.
22926
22983
  */
22927
- async downloadRecording(sn, opts) {
22984
+ async downloadRecording(sn, station, opts) {
22928
22985
  opts.signal?.throwIfAborted();
22929
22986
  const parentSn = this.stationKeyOf(sn);
22930
22987
  const previous = this.recordingDownloads.get(parentSn) ?? Promise.resolve();
22931
- const started = this.waitTurn(previous, opts.signal).then(() => this.startRecordingDownload(sn, opts));
22988
+ const started = this.waitTurn(previous, opts.signal).then(() => this.startRecordingDownload(sn, station, opts));
22932
22989
  const turn = Promise.allSettled([previous, started.then((t) => t.drained)]).then(() => {
22933
22990
  if (this.recordingDownloads.get(parentSn) === turn)
22934
22991
  this.recordingDownloads.delete(parentSn);
@@ -22942,25 +22999,32 @@ var P2PCommandRouter = class _P2PCommandRouter {
22942
22999
  }
22943
23000
  /**
22944
23001
  * Start one recording download, once the station's previous download has drained: the decoded recording,
22945
- * and when the station is done sending it.
23002
+ * and when the station is done sending it. The path is `station.path(channel, opts.recording)`, and a name
23003
+ * it refuses rejects `invalid-recording` before anything is sent. On a sealed station the cipher's ECC key
23004
+ * is looked up, and its absence rejects `key-unavailable` before anything is sent. A station in the clear
23005
+ * reads no cipher, and `opts.cipherId` is not read.
22946
23006
  */
22947
- async startRecordingDownload(sn, opts) {
23007
+ async startRecordingDownload(sn, station, opts) {
22948
23008
  const { session, parentSn, channel, accountId } = await this.resolveSession(sn, { signal: opts.signal });
22949
- const path = homeBase2RecordingPath(channel, opts.recording);
23009
+ const path = station.path(channel, opts.recording);
22950
23010
  if (!path)
22951
23011
  throw new RecordingDownloadError("invalid-recording", `no recording named ${opts.recording}`);
22952
- const eccKey = (await this.cipherKeysFor(session, opts.cipherId, accountId, parentSn))?.ecc;
22953
- if (!eccKey) {
23012
+ const eccKey = station.sealed ? (await this.cipherKeysFor(session, opts.cipherId, accountId, parentSn))?.ecc : void 0;
23013
+ if (station.sealed && !eccKey) {
22954
23014
  throw new RecordingDownloadError("key-unavailable", `cipher ${opts.cipherId} could not be obtained`);
22955
23015
  }
22956
23016
  const transfer = receiveRecording(session, {
22957
23017
  path,
22958
23018
  accountId,
22959
23019
  channel,
23020
+ finishChannel: station.finishChannel,
22960
23021
  timeoutMs: opts.timeoutMs,
22961
23022
  signal: opts.signal
22962
23023
  });
22963
- return { recording: transfer.frames.then((frames) => decodeRecording(frames, eccKey)), drained: transfer.drained };
23024
+ return {
23025
+ recording: transfer.frames.then((frames) => decodeRecording(frames, station, eccKey)),
23026
+ drained: transfer.drained
23027
+ };
22964
23028
  }
22965
23029
  /**
22966
23030
  * Open a {@link Talkback} on a device's camera channel.
@@ -25692,6 +25756,8 @@ var RtcCommandRouter = class {
25692
25756
  warm = /* @__PURE__ */ new Map();
25693
25757
  callSources = /* @__PURE__ */ new Map();
25694
25758
  lastTransaction = 0;
25759
+ /** The latest shard each `call` source resolved, read by its `makeStream` (see {@link callMediaProviderFor}). */
25760
+ callShards = /* @__PURE__ */ new Map();
25695
25761
  constructor(deps) {
25696
25762
  this.deps = deps;
25697
25763
  }
@@ -25818,7 +25884,13 @@ var RtcCommandRouter = class {
25818
25884
  views
25819
25885
  };
25820
25886
  }
25821
- /** Share a dedicated-call source and its consumer operations under one route key. */
25887
+ /**
25888
+ * Share a dedicated-call source and its consumer operations under one route key.
25889
+ *
25890
+ * `SharedLiveSource` calls `makeStream` synchronously, so the shard cannot be awaited inside it. Each
25891
+ * async entry point resolves the shard and records it in {@link callShards} before touching the source;
25892
+ * `makeStream` reads the latest recorded value.
25893
+ */
25822
25894
  callMediaProviderFor(route, key, makeStream, recordError, warmTimeoutMs) {
25823
25895
  const sourceFor = (opts = {}) => {
25824
25896
  opts.signal?.throwIfAborted();
@@ -25832,7 +25904,7 @@ var RtcCommandRouter = class {
25832
25904
  if (!identity)
25833
25905
  throw new Error(`rtc: not logged in, cannot call ${route.stationSn}`);
25834
25906
  const accountId = route.adminUserId || identity.userId;
25835
- return makeStream(this.newSession(route, accountId, identity), accountId, view);
25907
+ return makeStream(this.newSession(route, accountId, identity, this.callShards.get(key)), accountId, view);
25836
25908
  },
25837
25909
  label: sourceKey,
25838
25910
  logger: this.deps.logger,
@@ -25844,14 +25916,26 @@ var RtcCommandRouter = class {
25844
25916
  }
25845
25917
  return source;
25846
25918
  };
25919
+ const resolveShard = async () => {
25920
+ this.callShards.set(key, await this.deps.shard(route));
25921
+ };
25847
25922
  return {
25848
- live: async (opts) => attachUnlessAborted(sourceFor(opts), opts?.signal),
25849
- openReadable: async (opts) => openReadableFromConsumer(attachUnlessAborted(sourceFor(opts), opts?.signal), opts),
25850
- snapshotLive: async (opts) => captureSnapshotFromShared(sourceFor(opts), {
25851
- ...opts,
25852
- logger: this.deps.logger,
25853
- ffmpegPath: this.deps.ffmpegPath
25854
- }),
25923
+ live: async (opts) => {
25924
+ await resolveShard();
25925
+ return attachUnlessAborted(sourceFor(opts), opts?.signal);
25926
+ },
25927
+ openReadable: async (opts) => {
25928
+ await resolveShard();
25929
+ return openReadableFromConsumer(attachUnlessAborted(sourceFor(opts), opts?.signal), opts);
25930
+ },
25931
+ snapshotLive: async (opts) => {
25932
+ await resolveShard();
25933
+ return captureSnapshotFromShared(sourceFor(opts), {
25934
+ ...opts,
25935
+ logger: this.deps.logger,
25936
+ ffmpegPath: this.deps.ffmpegPath
25937
+ });
25938
+ },
25855
25939
  record: async () => {
25856
25940
  throw new Error(recordError);
25857
25941
  }
@@ -25866,6 +25950,7 @@ var RtcCommandRouter = class {
25866
25950
  this.callSources.delete(sn);
25867
25951
  src.dispose();
25868
25952
  }
25953
+ this.callShards.clear();
25869
25954
  for (const [sn, live] of this.lives) {
25870
25955
  this.lives.delete(sn);
25871
25956
  live.source.dispose();
@@ -26059,31 +26144,50 @@ var RtcCommandRouter = class {
26059
26144
  }
26060
26145
  });
26061
26146
  }
26062
- newSession(route, adminUserId, identity) {
26147
+ newSession(route, adminUserId, identity, shard) {
26063
26148
  return (this.deps.createSession ?? ((o) => new RtcSession(o)))({
26064
26149
  authToken: identity.authToken,
26065
26150
  gtoken: identity.gtoken,
26066
26151
  stationSn: route.stationSn,
26067
26152
  adminUserId,
26068
- shard: this.deps.shard(),
26153
+ shard,
26069
26154
  country: this.deps.country ?? "US",
26070
26155
  logger: this.deps.logger,
26071
26156
  signalingMode: route.signalingMode,
26072
26157
  peer: { logger: this.deps.logger, iceTransportPolicy: route.iceTransportPolicy, mediaTrack: route.mediaTrack }
26073
26158
  });
26074
26159
  }
26160
+ /** A connected session for `sn` to reuse, or undefined (dropping a dead one). */
26161
+ async reusable(sn) {
26162
+ const st = this.sessions.get(sn);
26163
+ if (!st)
26164
+ return void 0;
26165
+ await st.ready;
26166
+ if (st.session.isConnected) {
26167
+ this.touch(sn, st);
26168
+ return st;
26169
+ }
26170
+ this.drop(sn, st);
26171
+ return void 0;
26172
+ }
26173
+ /**
26174
+ * Get or open the station's session. A connected session is reused before the shard is awaited, so a
26175
+ * command on an already-connected station never waits on the unauthenticated `estimate_domain` round
26176
+ * trip — only the create path awaits it. After the await the map is re-checked, with no `await` between
26177
+ * the lookup and `this.sessions.set`, so concurrent creators still share one session.
26178
+ */
26075
26179
  async stationSession(route, adminUserId, identity) {
26076
26180
  const sn = route.stationSn;
26077
- const existing = this.sessions.get(sn);
26078
- if (existing) {
26079
- await existing.ready;
26080
- if (existing.session.isConnected) {
26081
- this.touch(sn, existing);
26082
- return existing;
26083
- }
26084
- this.drop(sn, existing);
26085
- }
26086
- const session = this.newSession(route, adminUserId, identity);
26181
+ const reused = await this.reusable(sn);
26182
+ if (reused)
26183
+ return reused;
26184
+ const shard = await this.deps.shard(route);
26185
+ if (this.sessions.has(sn)) {
26186
+ const again = await this.reusable(sn);
26187
+ if (again)
26188
+ return again;
26189
+ }
26190
+ const session = this.newSession(route, adminUserId, identity, shard);
26087
26191
  const ready = this.bringUp(sn, session);
26088
26192
  const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve(), leases: 0 };
26089
26193
  session.on("error", (e) => this.deps.onError?.(e));
@@ -29733,7 +29837,7 @@ var EufyMega = class _EufyMega extends EventEmitter14 {
29733
29837
  });
29734
29838
  this.rtc = new RtcCommandRouter({
29735
29839
  identity: () => this.mega.rtcIdentity(),
29736
- shard: () => this.mega.rtcShard,
29840
+ shard: (route) => route.mediaTrack ? this.mega.rtcShard : this.mega.portalShard(),
29737
29841
  country: opts.countryCode,
29738
29842
  logger: opts.logger,
29739
29843
  ffmpegPath: opts.ffmpegPath,