@camstack/addon-provider-reolink 1.2.158 → 1.2.160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +3143 -209
  2. package/dist/addon.mjs +3143 -209
  3. package/package.json +2 -2
package/dist/addon.js CHANGED
@@ -10,6 +10,7 @@ path = require_chunk.__toESM(path, 1);
10
10
  let crypto$1 = require("crypto");
11
11
  crypto$1 = require_chunk.__toESM(crypto$1, 1);
12
12
  let child_process = require("child_process");
13
+ let node_http = require("node:http");
13
14
  let node_fs_promises = require("node:fs/promises");
14
15
  let node_path = require("node:path");
15
16
  let stream = require("stream");
@@ -5390,7 +5391,7 @@ var ZodIssueCode = {
5390
5391
  var ZodFirstPartyTypeKind;
5391
5392
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5392
5393
  //#endregion
5393
- //#region ../types/dist/sleep-BVhJDJka.mjs
5394
+ //#region ../types/dist/sleep-De1BvmWo.mjs
5394
5395
  /**
5395
5396
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5396
5397
  * window to float samples (D455).
@@ -7859,10 +7860,20 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7859
7860
  var CameraSwitchUnavailableReasonSchema = _enum([
7860
7861
  "no-provider",
7861
7862
  "source-unreachable",
7862
- "not-configured",
7863
7863
  "device-disabled"
7864
7864
  ]);
7865
7865
  /**
7866
+ * Something true about a switch that is nonetheless OFFERED.
7867
+ *
7868
+ * The distinction from {@link CameraSwitchUnavailableReasonSchema} is the
7869
+ * whole point: a reason replaces the control, a warning travels WITH it. The
7870
+ * privacy mask taught us the difference — greying the one button that starts
7871
+ * the feature teaches the operator the feature is broken, while a live toggle
7872
+ * that flips nothing teaches them the switch is broken. Neither is true; the
7873
+ * honest surface is a working toggle that says what is missing.
7874
+ */
7875
+ var CameraSwitchWarningSchema = _enum(["no-privacy-zones"]);
7876
+ /**
7866
7877
  * One switch, resolved for one camera.
7867
7878
  *
7868
7879
  * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
@@ -7881,6 +7892,11 @@ var CameraSwitchSchema = object({
7881
7892
  /** False = do not render a control. `unavailableReason` says why. */
7882
7893
  available: boolean(),
7883
7894
  unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7895
+ /**
7896
+ * Present on an AVAILABLE switch only. Rendered beside the live control;
7897
+ * never a substitute for one.
7898
+ */
7899
+ warning: CameraSwitchWarningSchema.optional(),
7884
7900
  /** Current state. Meaningless when `available` is false — read it as `true`. */
7885
7901
  enabled: boolean(),
7886
7902
  authority: CameraSwitchAuthoritySchema
@@ -25144,6 +25160,15 @@ var CLIP_STILL_REASON_HEADER = "x-camstack-reason";
25144
25160
  var CLIP_STILL_REASON_CODE_HEADER = "x-camstack-reason-code";
25145
25161
  /** One {@link ClipStillDispositionSchema} token, alone. */
25146
25162
  var CLIP_STILL_DISPOSITION_HEADER = "x-camstack-disposition";
25163
+ /**
25164
+ * The clip STREAM route's own statement of what it is (D597): the value is
25165
+ * `forward-only`. A producer writes it on the 200; a consumer that dials a
25166
+ * URL and does not read it back refuses to play, because a progressive file
25167
+ * through a forward-only feeder looks like it worked until the first seek.
25168
+ * Named here because BOTH the provider and the broker spell it, and two
25169
+ * copies of a header name is how one of them stops being checked.
25170
+ */
25171
+ var CLIP_STREAM_TRANSPORT_HEADER = "x-camstack-clip-transport";
25147
25172
  var ClipSchema = object({
25148
25173
  /** Opaque, provider-namespaced id. The default provider encodes the time
25149
25174
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -25429,6 +25454,82 @@ var ClipBytesSchema = object({
25429
25454
  */
25430
25455
  durationMs: number().positive().optional()
25431
25456
  });
25457
+ /**
25458
+ * Where a clip's STREAM can be dialled (D597) — the answer to
25459
+ * {@link videoclipsCapability.methods.dialClipStream}.
25460
+ *
25461
+ * `url` is plain `http://` on the provider's own token-gated listener, one
25462
+ * shot, short-lived. It is NOT the hub-proxied `/addon/<id>/clip-stream/…`
25463
+ * path: that plane takes only a user credential, which an addon does not
25464
+ * hold, and the framework offers no addon→addon byte transport — the one
25465
+ * precedent is the broker's own restream URLs (`getStreamWithCodec`), which
25466
+ * a consumer dials directly, and this is that precedent one addon over.
25467
+ */
25468
+ /**
25469
+ * The audio a clip stream can be asked for, and the audio it carries.
25470
+ *
25471
+ * The camera stores AAC and no WebRTC session speaks it, so a stream that
25472
+ * carries sound carries it TRANSCODED, muxed as a second track beside the
25473
+ * video by the one ffmpeg the provider already runs (D600). `opus` is
25474
+ * 48 kHz mono, `pcmu` is 8 kHz µ-law — the two codecs a broker session
25475
+ * negotiates — and `none` is a stream asked for, or served, without sound.
25476
+ */
25477
+ var ClipStreamAudioSchema = _enum([
25478
+ "opus",
25479
+ "pcmu",
25480
+ "none"
25481
+ ]);
25482
+ /**
25483
+ * Why a stream that was ASKED for sound is not carrying it (D600).
25484
+ *
25485
+ * Only ever set beside `servedAudio: 'none'` after a non-`none` ask. Absent
25486
+ * means the ask was honoured, or there was no ask.
25487
+ *
25488
+ * - `not-requested` — the caller asked for `none`.
25489
+ * - `unsupported-ask` — the provider cannot produce that codec.
25490
+ *
25491
+ * Whether the RECORDING has sound at all is not knowable here: the camera
25492
+ * declares no audio track before its first frame arrives. That answer is in
25493
+ * the stream itself — an fMP4 with no audio track — and the broker reads it
25494
+ * there. A dial that says `opus` promises the codec, never the content.
25495
+ */
25496
+ var ClipStreamAudioReasonSchema = _enum(["not-requested", "unsupported-ask"]);
25497
+ var ClipStreamDialSchema = object({
25498
+ url: string().min(1),
25499
+ /** After this the token is gone and the URL answers 404 `dial-expired`. */
25500
+ expiresAtMs: number(),
25501
+ /** What the stream IS — a consumer that cannot honour it must not dial. */
25502
+ transport: literal("forward-only"),
25503
+ /**
25504
+ * Which twin the stream ACTUALLY carries — not what was asked for.
25505
+ *
25506
+ * The stream serves every profile (D599), and a recording whose main twin
25507
+ * does not exist is streamed as its sub copy, which this field reports as
25508
+ * `low`. That is the whole reason the dial no longer refuses `high`:
25509
+ * stating what was served is what makes accepting honest, where a silent
25510
+ * substitution would be the 33x lie D576 forbids. Stated rather than
25511
+ * assumed, the way {@link ClipPlaybackSchema.served} is (D549 15).
25512
+ */
25513
+ served: CamProfileSchema,
25514
+ /**
25515
+ * Which audio the stream will ACTUALLY carry — `served`'s companion for
25516
+ * sound, and the same reasoning (D599, D600).
25517
+ *
25518
+ * The broker asks for the codec its WebRTC session negotiated, and the
25519
+ * provider either muxes that codec or carries none. Stating it is what
25520
+ * lets the broker wire the matching sink before the first byte, and what
25521
+ * keeps a session from being handed a codec it cannot send — the audio
25522
+ * shape of the substitution `served` exists to prevent.
25523
+ *
25524
+ * It is a promise about the CODEC, not about the content: a recording with
25525
+ * no microphone track answers `opus` here and then arrives with no audio
25526
+ * track at all. That second answer is in the stream, and the broker reads
25527
+ * it from the `moov`.
25528
+ */
25529
+ servedAudio: ClipStreamAudioSchema,
25530
+ /** Why `servedAudio` is `none` although sound was asked for. */
25531
+ audioReason: ClipStreamAudioReasonSchema.optional()
25532
+ });
25432
25533
  var ClipSourceAvailabilitySchema = object({
25433
25534
  state: _enum([
25434
25535
  "ok",
@@ -25679,6 +25780,44 @@ var videoclipsCapability = {
25679
25780
  }), ClipBytesSchema, {
25680
25781
  kind: "query",
25681
25782
  auth: "protected"
25783
+ }),
25784
+ /**
25785
+ * Where this clip's STREAM can be dialled (D597) — the forward-only
25786
+ * fMP4 the provider writes from the first muxed byte, for the broker to
25787
+ * play through the same WebRTC session as recorded footage, with the
25788
+ * first frame at the first fragment instead of after the last byte.
25789
+ *
25790
+ * Nothing is fetched here: the answer is a one-shot URL on the
25791
+ * provider's own listener, and the camera is touched when it is dialled.
25792
+ * The gates the file path runs (`readClipBytes`) run on the dial too —
25793
+ * the id, the row, a clip still being written — and refuse in the same
25794
+ * vocabulary, THROWN with `clipExportFailure(<token>, <prose>)`. Every
25795
+ * profile is served (D599) and the answer states which twin it carries;
25796
+ * `high` is not refused. Optional on the provider for the same reason
25797
+ * `readClipBytes` is: a source with no camera socket behind it (the
25798
+ * recorder's analytics window, a HomeKit tee) has no stream.
25799
+ */
25800
+ dialClipStream: optionalMethod(object({
25801
+ deviceId: number(),
25802
+ clipId: string().min(1),
25803
+ /** WHICH provider holds the camera — see `readClipBytes.provider`. */
25804
+ provider: string().min(1),
25805
+ /** Which twin — `high` → the main file, everything else the sub (D599). */
25806
+ profile: CamProfileSchema.optional(),
25807
+ /**
25808
+ * Which audio codec the caller's session negotiated, so the provider
25809
+ * can mux it (D600). The ask travels on the DIAL because the mux is
25810
+ * spawned once, before the first byte, and cannot gain a track later.
25811
+ *
25812
+ * ABSENT IS `none`, never a guess: a caller that does not state an
25813
+ * ask is served the video-only stream that shipped first, so a broker
25814
+ * older than this field keeps working unchanged (D382 — an unstated
25815
+ * ask is zero).
25816
+ */
25817
+ audio: ClipStreamAudioSchema.optional()
25818
+ }), ClipStreamDialSchema, {
25819
+ kind: "query",
25820
+ auth: "protected"
25682
25821
  })
25683
25822
  }
25684
25823
  };
@@ -38017,6 +38156,1329 @@ var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
38017
38156
  DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
38018
38157
  new Set(Object.values(DeviceType));
38019
38158
  DeviceFeature.BatteryOperated;
38159
+ /**
38160
+ * The vocabulary, in the order a picker offers it.
38161
+ *
38162
+ * Adding a key here is not enough to make it pickable: both glyph tables must
38163
+ * gain it too, and their tests fail until they do. That is the intended
38164
+ * friction — it is the only thing that keeps the two surfaces honest.
38165
+ */
38166
+ var DISPLAY_ICON_VOCABULARY = [
38167
+ {
38168
+ key: "camera",
38169
+ group: "device",
38170
+ synonyms: [
38171
+ "telecamera",
38172
+ "videocamera",
38173
+ "camera",
38174
+ "cam"
38175
+ ]
38176
+ },
38177
+ {
38178
+ key: "network",
38179
+ group: "device",
38180
+ synonyms: [
38181
+ "rete",
38182
+ "network",
38183
+ "lan"
38184
+ ]
38185
+ },
38186
+ {
38187
+ key: "toggle-left",
38188
+ group: "device",
38189
+ synonyms: [
38190
+ "interruttore",
38191
+ "switch",
38192
+ "toggle"
38193
+ ]
38194
+ },
38195
+ {
38196
+ key: "toggle-right",
38197
+ group: "device",
38198
+ synonyms: [
38199
+ "interruttore",
38200
+ "switch",
38201
+ "toggle",
38202
+ "acceso"
38203
+ ]
38204
+ },
38205
+ {
38206
+ key: "circle-dot",
38207
+ group: "device",
38208
+ synonyms: [
38209
+ "pulsante",
38210
+ "bottone",
38211
+ "button",
38212
+ "dot"
38213
+ ]
38214
+ },
38215
+ {
38216
+ key: "refresh-cw",
38217
+ group: "device",
38218
+ synonyms: [
38219
+ "aggiorna",
38220
+ "ricarica",
38221
+ "refresh",
38222
+ "reload",
38223
+ "ciclo"
38224
+ ]
38225
+ },
38226
+ {
38227
+ key: "box",
38228
+ group: "device",
38229
+ synonyms: [
38230
+ "scatola",
38231
+ "box",
38232
+ "contenitore"
38233
+ ]
38234
+ },
38235
+ {
38236
+ key: "scroll-text",
38237
+ group: "device",
38238
+ synonyms: [
38239
+ "registro",
38240
+ "log",
38241
+ "testo"
38242
+ ]
38243
+ },
38244
+ {
38245
+ key: "workflow",
38246
+ group: "device",
38247
+ synonyms: [
38248
+ "automazione",
38249
+ "flusso",
38250
+ "workflow",
38251
+ "scenario"
38252
+ ]
38253
+ },
38254
+ {
38255
+ key: "lock",
38256
+ group: "device",
38257
+ synonyms: [
38258
+ "serratura",
38259
+ "chiave",
38260
+ "lucchetto",
38261
+ "chiuso",
38262
+ "locked"
38263
+ ]
38264
+ },
38265
+ {
38266
+ key: "lock-open",
38267
+ group: "device",
38268
+ synonyms: [
38269
+ "serratura",
38270
+ "aperta",
38271
+ "sbloccato",
38272
+ "unlocked"
38273
+ ]
38274
+ },
38275
+ {
38276
+ key: "blinds",
38277
+ group: "device",
38278
+ synonyms: [
38279
+ "tapparella",
38280
+ "persiana",
38281
+ "serranda",
38282
+ "avvolgibile",
38283
+ "shutter",
38284
+ "blind",
38285
+ "veneziana"
38286
+ ]
38287
+ },
38288
+ {
38289
+ key: "air-vent",
38290
+ group: "device",
38291
+ synonyms: [
38292
+ "bocchetta",
38293
+ "aria",
38294
+ "ventilazione",
38295
+ "condizionatore",
38296
+ "vent"
38297
+ ]
38298
+ },
38299
+ {
38300
+ key: "monitor-play",
38301
+ group: "device",
38302
+ synonyms: [
38303
+ "schermo",
38304
+ "riproduzione",
38305
+ "player",
38306
+ "display"
38307
+ ]
38308
+ },
38309
+ {
38310
+ key: "sliders-horizontal",
38311
+ group: "device",
38312
+ synonyms: [
38313
+ "regolazione",
38314
+ "cursori",
38315
+ "impostazioni",
38316
+ "slider"
38317
+ ]
38318
+ },
38319
+ {
38320
+ key: "map-pin",
38321
+ group: "device",
38322
+ synonyms: [
38323
+ "posizione",
38324
+ "luogo",
38325
+ "mappa",
38326
+ "pin"
38327
+ ]
38328
+ },
38329
+ {
38330
+ key: "bot",
38331
+ group: "device",
38332
+ synonyms: [
38333
+ "robot",
38334
+ "aspirapolvere",
38335
+ "bot"
38336
+ ]
38337
+ },
38338
+ {
38339
+ key: "tractor",
38340
+ group: "device",
38341
+ synonyms: [
38342
+ "trattore",
38343
+ "tosaerba",
38344
+ "giardino"
38345
+ ]
38346
+ },
38347
+ {
38348
+ key: "image",
38349
+ group: "device",
38350
+ synonyms: [
38351
+ "immagine",
38352
+ "foto",
38353
+ "snapshot"
38354
+ ]
38355
+ },
38356
+ {
38357
+ key: "bone",
38358
+ group: "device",
38359
+ synonyms: ["osso", "cane"]
38360
+ },
38361
+ {
38362
+ key: "door-closed",
38363
+ group: "device",
38364
+ synonyms: [
38365
+ "porta",
38366
+ "chiusa",
38367
+ "contatto"
38368
+ ]
38369
+ },
38370
+ {
38371
+ key: "door-open",
38372
+ group: "device",
38373
+ synonyms: [
38374
+ "porta",
38375
+ "aperta",
38376
+ "contatto",
38377
+ "ingresso",
38378
+ "uscita"
38379
+ ]
38380
+ },
38381
+ {
38382
+ key: "crosshair",
38383
+ group: "device",
38384
+ synonyms: [
38385
+ "mirino",
38386
+ "puntamento",
38387
+ "centro"
38388
+ ]
38389
+ },
38390
+ {
38391
+ key: "eye",
38392
+ group: "device",
38393
+ synonyms: [
38394
+ "occhio",
38395
+ "visibile",
38396
+ "visto"
38397
+ ]
38398
+ },
38399
+ {
38400
+ key: "eye-off",
38401
+ group: "device",
38402
+ synonyms: [
38403
+ "privacy",
38404
+ "nascosto",
38405
+ "oscurato",
38406
+ "mascherato",
38407
+ "invisibile"
38408
+ ]
38409
+ },
38410
+ {
38411
+ key: "smartphone",
38412
+ group: "device",
38413
+ synonyms: [
38414
+ "telefono",
38415
+ "cellulare",
38416
+ "smartphone"
38417
+ ]
38418
+ },
38419
+ {
38420
+ key: "phone",
38421
+ group: "device",
38422
+ synonyms: ["telefono", "chiamata"]
38423
+ },
38424
+ {
38425
+ key: "tablet",
38426
+ group: "device",
38427
+ synonyms: ["tablet", "ipad"]
38428
+ },
38429
+ {
38430
+ key: "laptop",
38431
+ group: "device",
38432
+ synonyms: [
38433
+ "portatile",
38434
+ "laptop",
38435
+ "notebook"
38436
+ ]
38437
+ },
38438
+ {
38439
+ key: "monitor",
38440
+ group: "device",
38441
+ synonyms: ["monitor", "schermo"]
38442
+ },
38443
+ {
38444
+ key: "tv",
38445
+ group: "device",
38446
+ synonyms: ["televisore", "tv"]
38447
+ },
38448
+ {
38449
+ key: "speaker",
38450
+ group: "device",
38451
+ synonyms: [
38452
+ "altoparlante",
38453
+ "cassa",
38454
+ "speaker"
38455
+ ]
38456
+ },
38457
+ {
38458
+ key: "printer",
38459
+ group: "device",
38460
+ synonyms: ["stampante"]
38461
+ },
38462
+ {
38463
+ key: "projector",
38464
+ group: "device",
38465
+ synonyms: ["proiettore"]
38466
+ },
38467
+ {
38468
+ key: "gamepad-2",
38469
+ group: "device",
38470
+ synonyms: [
38471
+ "console",
38472
+ "gioco",
38473
+ "joystick"
38474
+ ]
38475
+ },
38476
+ {
38477
+ key: "radio",
38478
+ group: "device",
38479
+ synonyms: ["radio"]
38480
+ },
38481
+ {
38482
+ key: "router",
38483
+ group: "device",
38484
+ synonyms: ["router", "modem"]
38485
+ },
38486
+ {
38487
+ key: "bluetooth",
38488
+ group: "device",
38489
+ synonyms: ["bluetooth"]
38490
+ },
38491
+ {
38492
+ key: "cpu",
38493
+ group: "device",
38494
+ synonyms: [
38495
+ "processore",
38496
+ "cpu",
38497
+ "chip",
38498
+ "dispositivo"
38499
+ ]
38500
+ },
38501
+ {
38502
+ key: "hard-drive",
38503
+ group: "device",
38504
+ synonyms: [
38505
+ "disco",
38506
+ "hdd",
38507
+ "archiviazione",
38508
+ "storage"
38509
+ ]
38510
+ },
38511
+ {
38512
+ key: "server",
38513
+ group: "device",
38514
+ synonyms: ["server", "nas"]
38515
+ },
38516
+ {
38517
+ key: "mic",
38518
+ group: "device",
38519
+ synonyms: ["microfono", "audio"]
38520
+ },
38521
+ {
38522
+ key: "mic-off",
38523
+ group: "device",
38524
+ synonyms: [
38525
+ "microfono",
38526
+ "spento",
38527
+ "muto"
38528
+ ]
38529
+ },
38530
+ {
38531
+ key: "video",
38532
+ group: "device",
38533
+ synonyms: ["video"]
38534
+ },
38535
+ {
38536
+ key: "video-off",
38537
+ group: "device",
38538
+ synonyms: ["video", "spento"]
38539
+ },
38540
+ {
38541
+ key: "music",
38542
+ group: "device",
38543
+ synonyms: ["musica"]
38544
+ },
38545
+ {
38546
+ key: "plug",
38547
+ group: "device",
38548
+ synonyms: [
38549
+ "presa",
38550
+ "spina",
38551
+ "plug"
38552
+ ]
38553
+ },
38554
+ {
38555
+ key: "plug-zap",
38556
+ group: "device",
38557
+ synonyms: ["presa", "alimentata"]
38558
+ },
38559
+ {
38560
+ key: "power",
38561
+ group: "device",
38562
+ synonyms: [
38563
+ "accensione",
38564
+ "alimentazione",
38565
+ "power"
38566
+ ]
38567
+ },
38568
+ {
38569
+ key: "power-off",
38570
+ group: "device",
38571
+ synonyms: ["spegnimento", "spento"]
38572
+ },
38573
+ {
38574
+ key: "cable",
38575
+ group: "device",
38576
+ synonyms: ["cavo"]
38577
+ },
38578
+ {
38579
+ key: "battery",
38580
+ group: "device",
38581
+ synonyms: ["batteria"]
38582
+ },
38583
+ {
38584
+ key: "battery-charging",
38585
+ group: "device",
38586
+ synonyms: [
38587
+ "batteria",
38588
+ "carica",
38589
+ "ricarica"
38590
+ ]
38591
+ },
38592
+ {
38593
+ key: "battery-full",
38594
+ group: "device",
38595
+ synonyms: [
38596
+ "batteria",
38597
+ "carica",
38598
+ "piena"
38599
+ ]
38600
+ },
38601
+ {
38602
+ key: "battery-low",
38603
+ group: "device",
38604
+ synonyms: ["batteria", "scarica"]
38605
+ },
38606
+ {
38607
+ key: "key",
38608
+ group: "device",
38609
+ synonyms: ["chiave"]
38610
+ },
38611
+ {
38612
+ key: "key-round",
38613
+ group: "device",
38614
+ synonyms: ["chiave", "tonda"]
38615
+ },
38616
+ {
38617
+ key: "mailbox",
38618
+ group: "device",
38619
+ synonyms: [
38620
+ "cassetta",
38621
+ "postale",
38622
+ "posta"
38623
+ ]
38624
+ },
38625
+ {
38626
+ key: "package",
38627
+ group: "device",
38628
+ synonyms: ["pacco", "consegna"]
38629
+ },
38630
+ {
38631
+ key: "shield",
38632
+ group: "device",
38633
+ synonyms: [
38634
+ "scudo",
38635
+ "protezione",
38636
+ "sicurezza",
38637
+ "allarme"
38638
+ ]
38639
+ },
38640
+ {
38641
+ key: "shield-check",
38642
+ group: "device",
38643
+ synonyms: [
38644
+ "protetto",
38645
+ "sicuro",
38646
+ "armato"
38647
+ ]
38648
+ },
38649
+ {
38650
+ key: "shield-off",
38651
+ group: "device",
38652
+ synonyms: [
38653
+ "protezione",
38654
+ "disattivata",
38655
+ "disarmato"
38656
+ ]
38657
+ },
38658
+ {
38659
+ key: "briefcase",
38660
+ group: "device",
38661
+ synonyms: [
38662
+ "ufficio",
38663
+ "lavoro",
38664
+ "valigetta",
38665
+ "studio"
38666
+ ]
38667
+ },
38668
+ {
38669
+ key: "lightbulb",
38670
+ group: "lighting",
38671
+ synonyms: [
38672
+ "luce",
38673
+ "lampadina",
38674
+ "lampada",
38675
+ "light"
38676
+ ]
38677
+ },
38678
+ {
38679
+ key: "lamp",
38680
+ group: "lighting",
38681
+ synonyms: ["lampada", "abatjour"]
38682
+ },
38683
+ {
38684
+ key: "lamp-ceiling",
38685
+ group: "lighting",
38686
+ synonyms: [
38687
+ "plafoniera",
38688
+ "lampadario",
38689
+ "luce",
38690
+ "soffitto"
38691
+ ]
38692
+ },
38693
+ {
38694
+ key: "lamp-desk",
38695
+ group: "lighting",
38696
+ synonyms: [
38697
+ "lampada",
38698
+ "scrivania",
38699
+ "abatjour"
38700
+ ]
38701
+ },
38702
+ {
38703
+ key: "lamp-floor",
38704
+ group: "lighting",
38705
+ synonyms: [
38706
+ "piantana",
38707
+ "lampada",
38708
+ "terra"
38709
+ ]
38710
+ },
38711
+ {
38712
+ key: "flashlight",
38713
+ group: "lighting",
38714
+ synonyms: [
38715
+ "faretto",
38716
+ "torcia",
38717
+ "spot",
38718
+ "faro"
38719
+ ]
38720
+ },
38721
+ {
38722
+ key: "heater",
38723
+ group: "lighting",
38724
+ synonyms: [
38725
+ "riscaldamento",
38726
+ "termosifone",
38727
+ "stufa",
38728
+ "calore"
38729
+ ]
38730
+ },
38731
+ {
38732
+ key: "snowflake",
38733
+ group: "lighting",
38734
+ synonyms: [
38735
+ "freddo",
38736
+ "raffrescamento",
38737
+ "aria",
38738
+ "condizionata",
38739
+ "neve"
38740
+ ]
38741
+ },
38742
+ {
38743
+ key: "thermometer-sun",
38744
+ group: "lighting",
38745
+ synonyms: [
38746
+ "caldo",
38747
+ "temperatura",
38748
+ "alta"
38749
+ ]
38750
+ },
38751
+ {
38752
+ key: "thermometer-snowflake",
38753
+ group: "lighting",
38754
+ synonyms: [
38755
+ "freddo",
38756
+ "temperatura",
38757
+ "bassa"
38758
+ ]
38759
+ },
38760
+ {
38761
+ key: "refrigerator",
38762
+ group: "lighting",
38763
+ synonyms: ["frigorifero", "frigo"]
38764
+ },
38765
+ {
38766
+ key: "microwave",
38767
+ group: "lighting",
38768
+ synonyms: ["microonde", "forno"]
38769
+ },
38770
+ {
38771
+ key: "washing-machine",
38772
+ group: "lighting",
38773
+ synonyms: [
38774
+ "lavatrice",
38775
+ "lavastoviglie",
38776
+ "asciugatrice"
38777
+ ]
38778
+ },
38779
+ {
38780
+ key: "cooking-pot",
38781
+ group: "lighting",
38782
+ synonyms: [
38783
+ "pentola",
38784
+ "cucina",
38785
+ "fornello"
38786
+ ]
38787
+ },
38788
+ {
38789
+ key: "utensils",
38790
+ group: "lighting",
38791
+ synonyms: [
38792
+ "cucina",
38793
+ "posate",
38794
+ "sala",
38795
+ "pranzo"
38796
+ ]
38797
+ },
38798
+ {
38799
+ key: "thermometer",
38800
+ group: "sensing",
38801
+ synonyms: [
38802
+ "temperatura",
38803
+ "termometro",
38804
+ "termostato"
38805
+ ]
38806
+ },
38807
+ {
38808
+ key: "droplets",
38809
+ group: "sensing",
38810
+ synonyms: [
38811
+ "umidita",
38812
+ "acqua",
38813
+ "allagamento",
38814
+ "perdita"
38815
+ ]
38816
+ },
38817
+ {
38818
+ key: "droplet",
38819
+ group: "sensing",
38820
+ synonyms: [
38821
+ "goccia",
38822
+ "acqua",
38823
+ "perdita",
38824
+ "allagamento"
38825
+ ]
38826
+ },
38827
+ {
38828
+ key: "gauge",
38829
+ group: "sensing",
38830
+ synonyms: [
38831
+ "misura",
38832
+ "livello",
38833
+ "pressione",
38834
+ "indicatore"
38835
+ ]
38836
+ },
38837
+ {
38838
+ key: "gauge2",
38839
+ group: "sensing",
38840
+ synonyms: [
38841
+ "misura",
38842
+ "livello",
38843
+ "pressione",
38844
+ "indicatore"
38845
+ ],
38846
+ aliasOf: "gauge"
38847
+ },
38848
+ {
38849
+ key: "sun",
38850
+ group: "sensing",
38851
+ synonyms: [
38852
+ "sole",
38853
+ "luminosita",
38854
+ "giorno",
38855
+ "esterno"
38856
+ ]
38857
+ },
38858
+ {
38859
+ key: "sun-medium",
38860
+ group: "sensing",
38861
+ synonyms: ["sole", "luminosita"]
38862
+ },
38863
+ {
38864
+ key: "sunrise",
38865
+ group: "sensing",
38866
+ synonyms: ["alba"]
38867
+ },
38868
+ {
38869
+ key: "moon",
38870
+ group: "sensing",
38871
+ synonyms: [
38872
+ "notte",
38873
+ "luna",
38874
+ "notturna",
38875
+ "nightvision"
38876
+ ]
38877
+ },
38878
+ {
38879
+ key: "wind",
38880
+ group: "sensing",
38881
+ synonyms: [
38882
+ "vento",
38883
+ "aria",
38884
+ "gas",
38885
+ "monossido",
38886
+ "co"
38887
+ ]
38888
+ },
38889
+ {
38890
+ key: "cloud",
38891
+ group: "sensing",
38892
+ synonyms: ["nuvola", "nuvoloso"]
38893
+ },
38894
+ {
38895
+ key: "cloud-rain",
38896
+ group: "sensing",
38897
+ synonyms: ["pioggia"]
38898
+ },
38899
+ {
38900
+ key: "cloud-sun",
38901
+ group: "sensing",
38902
+ synonyms: [
38903
+ "variabile",
38904
+ "sole",
38905
+ "nuvole"
38906
+ ]
38907
+ },
38908
+ {
38909
+ key: "waves",
38910
+ group: "sensing",
38911
+ synonyms: [
38912
+ "onde",
38913
+ "acqua",
38914
+ "vibrazione"
38915
+ ]
38916
+ },
38917
+ {
38918
+ key: "sprout",
38919
+ group: "sensing",
38920
+ synonyms: [
38921
+ "irrigazione",
38922
+ "pianta",
38923
+ "germoglio",
38924
+ "orto"
38925
+ ]
38926
+ },
38927
+ {
38928
+ key: "leaf",
38929
+ group: "sensing",
38930
+ synonyms: [
38931
+ "foglia",
38932
+ "pianta",
38933
+ "verde"
38934
+ ]
38935
+ },
38936
+ {
38937
+ key: "flower",
38938
+ group: "sensing",
38939
+ synonyms: ["fiore", "pianta"]
38940
+ },
38941
+ {
38942
+ key: "zap",
38943
+ group: "sensing",
38944
+ synonyms: [
38945
+ "energia",
38946
+ "corrente",
38947
+ "consumo",
38948
+ "watt",
38949
+ "elettricita"
38950
+ ]
38951
+ },
38952
+ {
38953
+ key: "flame",
38954
+ group: "sensing",
38955
+ synonyms: [
38956
+ "fiamma",
38957
+ "gas",
38958
+ "fuoco",
38959
+ "incendio"
38960
+ ]
38961
+ },
38962
+ {
38963
+ key: "bell",
38964
+ group: "sensing",
38965
+ synonyms: [
38966
+ "campanello",
38967
+ "suoneria",
38968
+ "notifica",
38969
+ "bell",
38970
+ "chime"
38971
+ ]
38972
+ },
38973
+ {
38974
+ key: "bell-ring",
38975
+ group: "sensing",
38976
+ synonyms: [
38977
+ "campanello",
38978
+ "suona",
38979
+ "doorbell",
38980
+ "notifica"
38981
+ ]
38982
+ },
38983
+ {
38984
+ key: "bell-off",
38985
+ group: "sensing",
38986
+ synonyms: [
38987
+ "silenzioso",
38988
+ "notifiche",
38989
+ "disattivate",
38990
+ "muto"
38991
+ ]
38992
+ },
38993
+ {
38994
+ key: "siren",
38995
+ group: "sensing",
38996
+ synonyms: ["sirena", "allarme"]
38997
+ },
38998
+ {
38999
+ key: "radar",
39000
+ group: "sensing",
39001
+ synonyms: [
39002
+ "movimento",
39003
+ "presenza",
39004
+ "pir",
39005
+ "rilevamento",
39006
+ "radar"
39007
+ ]
39008
+ },
39009
+ {
39010
+ key: "activity",
39011
+ group: "sensing",
39012
+ synonyms: [
39013
+ "attivita",
39014
+ "sensore",
39015
+ "generico"
39016
+ ]
39017
+ },
39018
+ {
39019
+ key: "hash",
39020
+ group: "sensing",
39021
+ synonyms: [
39022
+ "numero",
39023
+ "contatore",
39024
+ "valore"
39025
+ ]
39026
+ },
39027
+ {
39028
+ key: "clock",
39029
+ group: "sensing",
39030
+ synonyms: [
39031
+ "orologio",
39032
+ "ora",
39033
+ "tempo"
39034
+ ]
39035
+ },
39036
+ {
39037
+ key: "timer",
39038
+ group: "sensing",
39039
+ synonyms: [
39040
+ "timer",
39041
+ "conto",
39042
+ "rovescia"
39043
+ ]
39044
+ },
39045
+ {
39046
+ key: "calendar",
39047
+ group: "sensing",
39048
+ synonyms: [
39049
+ "calendario",
39050
+ "data",
39051
+ "pianificazione"
39052
+ ]
39053
+ },
39054
+ {
39055
+ key: "pipette",
39056
+ group: "sensing",
39057
+ synonyms: [
39058
+ "qualita",
39059
+ "aria",
39060
+ "misura",
39061
+ "campione"
39062
+ ]
39063
+ },
39064
+ {
39065
+ key: "fan",
39066
+ group: "sensing",
39067
+ synonyms: [
39068
+ "ventilatore",
39069
+ "ventola",
39070
+ "aria"
39071
+ ]
39072
+ },
39073
+ {
39074
+ key: "bath",
39075
+ group: "sensing",
39076
+ synonyms: [
39077
+ "bagno",
39078
+ "vasca",
39079
+ "doccia"
39080
+ ]
39081
+ },
39082
+ {
39083
+ key: "footprints",
39084
+ group: "sensing",
39085
+ synonyms: [
39086
+ "movimento",
39087
+ "passi",
39088
+ "presenza",
39089
+ "motion"
39090
+ ]
39091
+ },
39092
+ {
39093
+ key: "alarm-smoke",
39094
+ group: "sensing",
39095
+ synonyms: [
39096
+ "fumo",
39097
+ "antincendio",
39098
+ "rilevatore"
39099
+ ]
39100
+ },
39101
+ {
39102
+ key: "shield-alert",
39103
+ group: "sensing",
39104
+ synonyms: [
39105
+ "manomissione",
39106
+ "allarme",
39107
+ "tamper",
39108
+ "avviso"
39109
+ ]
39110
+ },
39111
+ {
39112
+ key: "vibrate",
39113
+ group: "sensing",
39114
+ synonyms: [
39115
+ "vibrazione",
39116
+ "urto",
39117
+ "tamper"
39118
+ ]
39119
+ },
39120
+ {
39121
+ key: "wifi",
39122
+ group: "sensing",
39123
+ synonyms: [
39124
+ "wifi",
39125
+ "rete",
39126
+ "segnale"
39127
+ ]
39128
+ },
39129
+ {
39130
+ key: "wifi-off",
39131
+ group: "sensing",
39132
+ synonyms: [
39133
+ "wifi",
39134
+ "assente",
39135
+ "offline",
39136
+ "disconnesso"
39137
+ ]
39138
+ },
39139
+ {
39140
+ key: "volume-2",
39141
+ group: "sensing",
39142
+ synonyms: [
39143
+ "volume",
39144
+ "audio",
39145
+ "altoparlante",
39146
+ "suono"
39147
+ ]
39148
+ },
39149
+ {
39150
+ key: "volume-x",
39151
+ group: "sensing",
39152
+ synonyms: [
39153
+ "muto",
39154
+ "silenziato",
39155
+ "volume",
39156
+ "spento"
39157
+ ]
39158
+ },
39159
+ {
39160
+ key: "heart",
39161
+ group: "sensing",
39162
+ synonyms: [
39163
+ "cuore",
39164
+ "preferito",
39165
+ "salute"
39166
+ ]
39167
+ },
39168
+ {
39169
+ key: "heart-pulse",
39170
+ group: "sensing",
39171
+ synonyms: [
39172
+ "battito",
39173
+ "salute",
39174
+ "monitoraggio"
39175
+ ]
39176
+ },
39177
+ {
39178
+ key: "star",
39179
+ group: "sensing",
39180
+ synonyms: [
39181
+ "preferito",
39182
+ "stella",
39183
+ "importante"
39184
+ ]
39185
+ },
39186
+ {
39187
+ key: "sparkles",
39188
+ group: "sensing",
39189
+ synonyms: [
39190
+ "automatico",
39191
+ "intelligente",
39192
+ "ai",
39193
+ "scena"
39194
+ ]
39195
+ },
39196
+ {
39197
+ key: "house",
39198
+ group: "place",
39199
+ synonyms: [
39200
+ "casa",
39201
+ "abitazione",
39202
+ "home"
39203
+ ]
39204
+ },
39205
+ {
39206
+ key: "sofa",
39207
+ group: "place",
39208
+ synonyms: [
39209
+ "salone",
39210
+ "soggiorno",
39211
+ "divano",
39212
+ "living"
39213
+ ]
39214
+ },
39215
+ {
39216
+ key: "bed-double",
39217
+ group: "place",
39218
+ synonyms: [
39219
+ "camera",
39220
+ "letto",
39221
+ "matrimoniale"
39222
+ ]
39223
+ },
39224
+ {
39225
+ key: "bed",
39226
+ group: "place",
39227
+ synonyms: [
39228
+ "cameretta",
39229
+ "letto",
39230
+ "singolo"
39231
+ ]
39232
+ },
39233
+ {
39234
+ key: "warehouse",
39235
+ group: "place",
39236
+ synonyms: [
39237
+ "magazzino",
39238
+ "capanno",
39239
+ "deposito",
39240
+ "cantina"
39241
+ ]
39242
+ },
39243
+ {
39244
+ key: "trees",
39245
+ group: "place",
39246
+ synonyms: [
39247
+ "giardino",
39248
+ "alberi",
39249
+ "esterno",
39250
+ "parco"
39251
+ ]
39252
+ },
39253
+ {
39254
+ key: "tree-pine",
39255
+ group: "place",
39256
+ synonyms: [
39257
+ "albero",
39258
+ "pino",
39259
+ "giardino"
39260
+ ]
39261
+ },
39262
+ {
39263
+ key: "fence",
39264
+ group: "place",
39265
+ synonyms: [
39266
+ "recinto",
39267
+ "cancello",
39268
+ "staccionata",
39269
+ "perimetro"
39270
+ ]
39271
+ },
39272
+ {
39273
+ key: "store",
39274
+ group: "place",
39275
+ synonyms: [
39276
+ "negozio",
39277
+ "ingresso",
39278
+ "vetrina"
39279
+ ]
39280
+ },
39281
+ {
39282
+ key: "building",
39283
+ group: "place",
39284
+ synonyms: ["edificio", "palazzo"]
39285
+ },
39286
+ {
39287
+ key: "building-2",
39288
+ group: "place",
39289
+ synonyms: [
39290
+ "edificio",
39291
+ "uffici",
39292
+ "condominio"
39293
+ ]
39294
+ },
39295
+ {
39296
+ key: "mountain",
39297
+ group: "place",
39298
+ synonyms: [
39299
+ "montagna",
39300
+ "esterno",
39301
+ "panorama"
39302
+ ]
39303
+ },
39304
+ {
39305
+ key: "umbrella",
39306
+ group: "place",
39307
+ synonyms: [
39308
+ "tenda",
39309
+ "markise",
39310
+ "ombrellone",
39311
+ "pergola",
39312
+ "veranda"
39313
+ ]
39314
+ },
39315
+ {
39316
+ key: "dumbbell",
39317
+ group: "place",
39318
+ synonyms: ["palestra", "attrezzi"]
39319
+ },
39320
+ {
39321
+ key: "wrench",
39322
+ group: "place",
39323
+ synonyms: [
39324
+ "officina",
39325
+ "manutenzione",
39326
+ "attrezzi",
39327
+ "utenze"
39328
+ ]
39329
+ },
39330
+ {
39331
+ key: "hammer",
39332
+ group: "place",
39333
+ synonyms: [
39334
+ "officina",
39335
+ "martello",
39336
+ "lavori"
39337
+ ]
39338
+ },
39339
+ {
39340
+ key: "drill",
39341
+ group: "place",
39342
+ synonyms: [
39343
+ "officina",
39344
+ "trapano",
39345
+ "lavori"
39346
+ ]
39347
+ },
39348
+ {
39349
+ key: "waves-ladder",
39350
+ group: "place",
39351
+ synonyms: ["piscina", "vasca"]
39352
+ },
39353
+ {
39354
+ key: "circle-parking",
39355
+ group: "place",
39356
+ synonyms: [
39357
+ "parcheggio",
39358
+ "posto",
39359
+ "auto",
39360
+ "garage"
39361
+ ]
39362
+ },
39363
+ {
39364
+ key: "route",
39365
+ group: "place",
39366
+ synonyms: [
39367
+ "percorso",
39368
+ "strada",
39369
+ "vialetto",
39370
+ "tragitto"
39371
+ ]
39372
+ },
39373
+ {
39374
+ key: "car",
39375
+ group: "life",
39376
+ synonyms: [
39377
+ "auto",
39378
+ "macchina",
39379
+ "veicolo",
39380
+ "parcheggio"
39381
+ ]
39382
+ },
39383
+ {
39384
+ key: "bike",
39385
+ group: "life",
39386
+ synonyms: [
39387
+ "bicicletta",
39388
+ "bici",
39389
+ "moto"
39390
+ ]
39391
+ },
39392
+ {
39393
+ key: "bus",
39394
+ group: "life",
39395
+ synonyms: ["autobus", "pullman"]
39396
+ },
39397
+ {
39398
+ key: "truck",
39399
+ group: "life",
39400
+ synonyms: [
39401
+ "camion",
39402
+ "furgone",
39403
+ "consegna"
39404
+ ]
39405
+ },
39406
+ {
39407
+ key: "plane",
39408
+ group: "life",
39409
+ synonyms: ["aereo"]
39410
+ },
39411
+ {
39412
+ key: "train-front",
39413
+ group: "life",
39414
+ synonyms: ["treno"]
39415
+ },
39416
+ {
39417
+ key: "sailboat",
39418
+ group: "life",
39419
+ synonyms: ["barca", "imbarcazione"]
39420
+ },
39421
+ {
39422
+ key: "dog",
39423
+ group: "life",
39424
+ synonyms: ["cane", "animale"]
39425
+ },
39426
+ {
39427
+ key: "cat",
39428
+ group: "life",
39429
+ synonyms: ["gatto", "animale"]
39430
+ },
39431
+ {
39432
+ key: "bird",
39433
+ group: "life",
39434
+ synonyms: ["uccello", "animale"]
39435
+ },
39436
+ {
39437
+ key: "paw-print",
39438
+ group: "life",
39439
+ synonyms: [
39440
+ "animale",
39441
+ "zampa",
39442
+ "pet"
39443
+ ]
39444
+ },
39445
+ {
39446
+ key: "baby",
39447
+ group: "life",
39448
+ synonyms: [
39449
+ "neonato",
39450
+ "bimbo",
39451
+ "cameretta",
39452
+ "babymonitor"
39453
+ ]
39454
+ },
39455
+ {
39456
+ key: "user",
39457
+ group: "life",
39458
+ synonyms: ["persona", "utente"]
39459
+ },
39460
+ {
39461
+ key: "users",
39462
+ group: "life",
39463
+ synonyms: [
39464
+ "persone",
39465
+ "gruppo",
39466
+ "famiglia"
39467
+ ]
39468
+ },
39469
+ {
39470
+ key: "person-standing",
39471
+ group: "life",
39472
+ synonyms: [
39473
+ "persona",
39474
+ "piedi",
39475
+ "presenza"
39476
+ ]
39477
+ }
39478
+ ];
39479
+ DISPLAY_ICON_VOCABULARY.map((e) => e.key);
39480
+ DISPLAY_ICON_VOCABULARY.filter((e) => e.aliasOf === void 0).map((e) => e.key);
39481
+ new Map(DISPLAY_ICON_VOCABULARY.map((e) => [e.key, e]));
38020
39482
  var LAST_FETCHED_FIELD = "lastFetchedAt";
38021
39483
  /**
38022
39484
  * How long a bridge stops re-attempting a refresh that did not land.
@@ -44962,6 +46424,12 @@ Object.freeze({
44962
46424
  addonId: null,
44963
46425
  access: "view"
44964
46426
  },
46427
+ "videoclips.dialClipStream": {
46428
+ capName: "videoclips",
46429
+ capScope: "device",
46430
+ addonId: null,
46431
+ access: "view"
46432
+ },
44965
46433
  "videoclips.getClipPlayback": {
44966
46434
  capName: "videoclips",
44967
46435
  capScope: "device",
@@ -47001,6 +48469,11 @@ Object.freeze({
47001
48469
  form: "single",
47002
48470
  optional: false
47003
48471
  }],
48472
+ "videoclips.dialClipStream": [{
48473
+ name: "deviceId",
48474
+ form: "single",
48475
+ optional: false
48476
+ }],
47004
48477
  "videoclips.getClipPlayback": [{
47005
48478
  name: "deviceId",
47006
48479
  form: "single",
@@ -76815,7 +78288,7 @@ var require_lz4 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
76815
78288
  };
76816
78289
  }));
76817
78290
  //#endregion
76818
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-5PJ6XPBF.js
78291
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-GNC5U3YA.js
76819
78292
  var import_fxp = require_fxp();
76820
78293
  var import_lz4 = /* @__PURE__ */ require_chunk.__toESM(require_lz4(), 1);
76821
78294
  function encodeHeader(h) {
@@ -80980,6 +82453,7 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
80980
82453
  lockedChannelId = frame.header.channelId;
80981
82454
  lockedStreamType = frame.header.streamType;
80982
82455
  }
82456
+ params.onChunk?.(decrypted);
80983
82457
  chunks.push(decrypted);
80984
82458
  armIdleFinish();
80985
82459
  if (frame.header.responseCode === 201) finish(Buffer.concat(chunks));
@@ -92986,7 +94460,8 @@ ${stderr}`));
92986
94460
  payloadXml,
92987
94461
  streamType: 0,
92988
94462
  timeoutMs,
92989
- ...params.idleTimeoutMs != null ? { idleTimeoutMs: params.idleTimeoutMs } : {}
94463
+ ...params.idleTimeoutMs != null ? { idleTimeoutMs: params.idleTimeoutMs } : {},
94464
+ ...params.onChunk ? { onChunk: params.onChunk } : {}
92990
94465
  });
92991
94466
  } catch (e) {
92992
94467
  trace(`download failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -149050,6 +150525,161 @@ function createClipPlaybackHandler(deps) {
149050
150525
  }).pipe(res);
149051
150526
  };
149052
150527
  }
150528
+ /**
150529
+ * `/addon/provider-reolink/clip-stream/<deviceId>/<clipKey>.mp4` — the same
150530
+ * recording as `clip/…`, served as it ARRIVES from the camera.
150531
+ *
150532
+ * **Not `Range`-able, and it says so.** The response has no length: it is a
150533
+ * fragmented MP4 written by an ffmpeg that is still reading the camera when
150534
+ * the first byte leaves. So there is no `content-length`, `accept-ranges` is
150535
+ * `none`, a `Range` header is IGNORED (a 206 over something with no length
150536
+ * would be a lie), and the peer may not cache it (`no-store`) — a stream is
150537
+ * not an immutable artifact, the file route is. The status line is written
150538
+ * on the first muxed byte, never before: until then every refusal still owns
150539
+ * it, and a camera that serves nothing answers a status with a reason, not an
150540
+ * empty 200 (D391).
150541
+ *
150542
+ * `HEAD` answers the headers and touches no camera.
150543
+ */
150544
+ var CLIP_STREAM_PREFIX = "clip-stream";
150545
+ /** The data plane's own form: the clip is named on the path. */
150546
+ var resolveClipStreamPathTarget = (req) => {
150547
+ const match = MEDIA_PATH.exec(pathnameOf(req));
150548
+ if (match === null) return {
150549
+ kind: "refused",
150550
+ status: 400,
150551
+ code: "bad-path",
150552
+ detail: "not /<deviceId>/<clipKey>.mp4"
150553
+ };
150554
+ return {
150555
+ kind: "target",
150556
+ target: {
150557
+ deviceId: Number(match[1]),
150558
+ clipKey: match[2] ?? "",
150559
+ twin: "sub",
150560
+ audio: "none"
150561
+ }
150562
+ };
150563
+ };
150564
+ /**
150565
+ * Everything but the content type, which the MUX decides: a stream carrying
150566
+ * µ-law is a QuickTime container (measured — ffmpeg's MP4 muxer refuses
150567
+ * `pcm_mulaw`), and a header that said `video/mp4` for it would be a small
150568
+ * lie on a plane whose whole point is saying what it really serves.
150569
+ */
150570
+ var STREAM_HEADERS = {
150571
+ "accept-ranges": "none",
150572
+ "cache-control": "no-store",
150573
+ [CLIP_STREAM_TRANSPORT_HEADER]: "forward-only"
150574
+ };
150575
+ /** The status a refusal answers. `404` = nothing here to serve; `409` = a
150576
+ * clip that exists and cannot be served right now; `502` = the camera. */
150577
+ function streamRefusalStatus(code) {
150578
+ switch (code) {
150579
+ case "unknown-device":
150580
+ case "no-catalog-row": return 404;
150581
+ case "sleeping":
150582
+ case "uid-missing":
150583
+ case "clip-in-progress": return 409;
150584
+ case "camera-refused":
150585
+ case "fetch-failed": return 502;
150586
+ }
150587
+ }
150588
+ function createClipStreamHandler(deps) {
150589
+ const resolveTarget = deps.target ?? resolveClipStreamPathTarget;
150590
+ return async (req, res) => {
150591
+ if (req.method !== "GET" && req.method !== "HEAD") {
150592
+ res.writeHead(405, { allow: "GET, HEAD" });
150593
+ res.end();
150594
+ return;
150595
+ }
150596
+ const resolved = resolveTarget(req);
150597
+ if (resolved.kind === "refused") {
150598
+ deps.logger.warn("videoclips: clip stream request names no clip", { meta: {
150599
+ code: resolved.code,
150600
+ reason: resolved.detail,
150601
+ status: resolved.status
150602
+ } });
150603
+ res.writeHead(resolved.status, {
150604
+ [CLIP_STILL_REASON_HEADER]: resolved.detail.slice(0, 500),
150605
+ [CLIP_STILL_REASON_CODE_HEADER]: resolved.code
150606
+ });
150607
+ res.end();
150608
+ return;
150609
+ }
150610
+ const { deviceId, clipKey } = resolved.target;
150611
+ if (req.method === "HEAD") {
150612
+ res.writeHead(200, {
150613
+ "content-type": "video/mp4",
150614
+ ...STREAM_HEADERS
150615
+ });
150616
+ res.end();
150617
+ return;
150618
+ }
150619
+ if (req.headers.range !== void 0) deps.logger.debug("videoclips: a Range header on the clip stream route is ignored", {
150620
+ tags: { deviceId },
150621
+ meta: {
150622
+ clipKey,
150623
+ range: req.headers.range
150624
+ }
150625
+ });
150626
+ const sink = {
150627
+ begin: (contentType) => {
150628
+ res.writeHead(200, {
150629
+ "content-type": contentType,
150630
+ ...STREAM_HEADERS
150631
+ });
150632
+ },
150633
+ write: (chunk) => res.write(chunk),
150634
+ onceDrain: (listener) => {
150635
+ res.once("drain", listener);
150636
+ },
150637
+ onceClose: (listener) => {
150638
+ res.once("close", listener);
150639
+ },
150640
+ end: () => {
150641
+ res.end();
150642
+ },
150643
+ destroy: () => {
150644
+ res.destroy();
150645
+ }
150646
+ };
150647
+ let outcome;
150648
+ try {
150649
+ outcome = await deps.stream(resolved.target, sink);
150650
+ } catch (err) {
150651
+ deps.logger.warn("videoclips: the clip stream route threw", {
150652
+ tags: { deviceId },
150653
+ meta: {
150654
+ clipKey,
150655
+ branch: "stream-threw",
150656
+ error: err instanceof Error ? err.message : String(err)
150657
+ }
150658
+ });
150659
+ if (res.headersSent) res.destroy();
150660
+ else {
150661
+ res.writeHead(500);
150662
+ res.end();
150663
+ }
150664
+ return;
150665
+ }
150666
+ if (outcome.kind !== "refused") return;
150667
+ deps.logger.info("videoclips: clip stream not served", {
150668
+ tags: { deviceId },
150669
+ meta: {
150670
+ clipKey,
150671
+ code: outcome.code,
150672
+ reason: outcome.detail,
150673
+ status: streamRefusalStatus(outcome.code)
150674
+ }
150675
+ });
150676
+ res.writeHead(streamRefusalStatus(outcome.code), {
150677
+ [CLIP_STILL_REASON_HEADER]: outcome.detail.slice(0, 500),
150678
+ [CLIP_STILL_REASON_CODE_HEADER]: outcome.code
150679
+ });
150680
+ res.end();
150681
+ };
150682
+ }
149053
150683
  //#endregion
149054
150684
  //#region src/clips/clip-mux.ts
149055
150685
  /** A clip that has not transferred by here is not going to. */
@@ -149207,6 +150837,147 @@ function describeMuxFailure(run) {
149207
150837
  if (run.code !== 0) return `mux-failed (exit ${String(run.code)}): ${run.stderr.slice(0, 300)}`;
149208
150838
  return null;
149209
150839
  }
150840
+ /** The fragmented container a stream is muxed into — see the file header. */
150841
+ var CLIP_STREAM_MOVFLAGS = "frag_keyframe+empty_moov+default_base_moof";
150842
+ /**
150843
+ * The rate the probe measured, through the library's one derivation.
150844
+ *
150845
+ * `estimateVideoTiming` falls back to the info header when the timestamps
150846
+ * cannot say; that fallback is REFUSED here (`fpsSource: 'unmeasured'`), as
150847
+ * D567 says, so a guess is never handed to `-r` under a measurement's name.
150848
+ */
150849
+ function probeClipStreamRate(timestampsUs) {
150850
+ const timing = estimateVideoTiming({ timestampsUs });
150851
+ if (timing.fpsSource !== "timestamps" || timing.fps === null) return {
150852
+ fps: null,
150853
+ fpsSource: "unmeasured",
150854
+ accessUnits: timestampsUs.length
150855
+ };
150856
+ return {
150857
+ fps: timing.fps,
150858
+ fpsSource: "timestamps",
150859
+ accessUnits: timestampsUs.length
150860
+ };
150861
+ }
150862
+ /** Opus at 48 kHz — the only rate the codec has, and the session's RTP clock. */
150863
+ var CLIP_STREAM_OPUS_RATE_HZ = 48e3;
150864
+ /** µ-law at 8 kHz — the PCMU RTP clock. */
150865
+ var CLIP_STREAM_PCMU_RATE_HZ = 8e3;
150866
+ /**
150867
+ * The container a stream is muxed into, for a given audio ask.
150868
+ *
150869
+ * MEASURED, not chosen: ffmpeg's MP4 muxer refuses `pcm_mulaw`
150870
+ * (`Could not find tag for codec pcm_mulaw in stream #1`), and its MOV muxer
150871
+ * takes it as a `ulaw` sample entry. The boxes a consumer walks are the same
150872
+ * either way — `ftyp`, an empty `moov`, then `moof`+`mdat` — so this changes
150873
+ * the content type and nothing else.
150874
+ */
150875
+ function clipStreamContainerFor(audio) {
150876
+ return audio === "pcmu" ? "mov" : "mp4";
150877
+ }
150878
+ /** What the route puts on `content-type` for {@link clipStreamContainerFor}. */
150879
+ function clipStreamContentTypeFor(audio) {
150880
+ return clipStreamContainerFor(audio) === "mov" ? "video/quicktime" : "video/mp4";
150881
+ }
150882
+ /**
150883
+ * One ffmpeg, stdin to stdout, fragmented, with an optional second input.
150884
+ *
150885
+ * Mirrors `buildClipMuxArgs` where a stream can: the demuxer is named from
150886
+ * the DETECTED codec (the BcMedia header lies on a hub child), `-r` is an
150887
+ * input option before its own `-i`, and the video is copied. Where it
150888
+ * cannot — `+faststart` — the file header says why; where it now can — the
150889
+ * audio input — it is an ENCODE rather than the file path's copy, because
150890
+ * this output is consumed by a WebRTC sender and not by a player.
150891
+ */
150892
+ function buildClipStreamMuxArgs(input) {
150893
+ const demuxer = input.videoType === "H265" ? "hevc" : "h264";
150894
+ const audio = input.audio;
150895
+ return [
150896
+ "-hide_banner",
150897
+ "-loglevel",
150898
+ "error",
150899
+ "-nostdin",
150900
+ ...input.fps !== null && Number.isFinite(input.fps) && input.fps > 0 ? ["-r", String(input.fps)] : [],
150901
+ "-f",
150902
+ demuxer,
150903
+ "-i",
150904
+ "pipe:0",
150905
+ ...audio !== null ? [
150906
+ "-f",
150907
+ "aac",
150908
+ "-i",
150909
+ `pipe:${String(3)}`
150910
+ ] : [],
150911
+ ...audio !== null ? [
150912
+ "-map",
150913
+ "0:v",
150914
+ "-map",
150915
+ "1:a"
150916
+ ] : [],
150917
+ "-c:v",
150918
+ "copy",
150919
+ ...audio === "opus" ? [
150920
+ "-c:a",
150921
+ "libopus",
150922
+ "-ar",
150923
+ String(CLIP_STREAM_OPUS_RATE_HZ),
150924
+ "-ac",
150925
+ String(1),
150926
+ "-b:a",
150927
+ "24k"
150928
+ ] : [],
150929
+ ...audio === "pcmu" ? [
150930
+ "-c:a",
150931
+ "pcm_mulaw",
150932
+ "-ar",
150933
+ String(CLIP_STREAM_PCMU_RATE_HZ),
150934
+ "-ac",
150935
+ String(1)
150936
+ ] : [],
150937
+ "-movflags",
150938
+ CLIP_STREAM_MOVFLAGS,
150939
+ "-f",
150940
+ clipStreamContainerFor(audio),
150941
+ "pipe:1"
150942
+ ];
150943
+ }
150944
+ /**
150945
+ * The D447 rule as a state machine, so it can be proved without a socket.
150946
+ *
150947
+ * A `false` write ARMS the timer; further `false` writes while it is armed do
150948
+ * not push the deadline out. `drain` — the hop's own word that it took what
150949
+ * it held — disarms it. Stall fires at most once, and a disposed gate is
150950
+ * silent for ever.
150951
+ */
150952
+ function createDrainGate(deps) {
150953
+ let handle = null;
150954
+ let stalled = false;
150955
+ const disarm = () => {
150956
+ if (handle !== null) clearTimeout(handle);
150957
+ handle = null;
150958
+ };
150959
+ return {
150960
+ wrote(accepted) {
150961
+ if (accepted || handle !== null || stalled) return;
150962
+ handle = setTimeout(() => {
150963
+ handle = null;
150964
+ if (stalled) return;
150965
+ stalled = true;
150966
+ deps.onStall();
150967
+ }, deps.timeoutMs);
150968
+ },
150969
+ drained() {
150970
+ disarm();
150971
+ },
150972
+ waiting() {
150973
+ return handle !== null;
150974
+ },
150975
+ dispose() {
150976
+ disarm();
150977
+ stalled = true;
150978
+ }
150979
+ };
150980
+ }
149210
150981
  //#endregion
149211
150982
  //#region src/clips/clip-truncation.ts
149212
150983
  /**
@@ -149253,6 +151024,885 @@ function judgeClipDuration(input) {
149253
151024
  };
149254
151025
  }
149255
151026
  //#endregion
151027
+ //#region src/clips/clip-stream.ts
151028
+ function isWritable(stream) {
151029
+ return "write" in stream && typeof stream.write === "function";
151030
+ }
151031
+ /** The stream at `index`, if it is one this process can be WRITTEN to. */
151032
+ function writableAt(stdio, index) {
151033
+ const candidate = stdio[index];
151034
+ if (candidate === null || candidate === void 0) return null;
151035
+ return isWritable(candidate) ? candidate : null;
151036
+ }
151037
+ var defaultDecoder = (handlers) => new require_chunk_ZBACPERX.BcMediaAnnexBDecoder({
151038
+ strict: false,
151039
+ onVideoAccessUnit: handlers.onVideoAccessUnit,
151040
+ onAudioFrame: (frame) => handlers.onAudioFrame({
151041
+ audioType: frame.audioType,
151042
+ data: frame.data
151043
+ })
151044
+ });
151045
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
151046
+ function createClipStreamProducer(deps) {
151047
+ const sleep = deps.sleep ?? defaultSleep;
151048
+ const createDecoder = deps.createDecoder ?? defaultDecoder;
151049
+ const drainTimeoutMs = deps.drainTimeoutMs ?? 1e4;
151050
+ const flushTimeoutMs = deps.flushTimeoutMs ?? 15e3;
151051
+ const lanes = /* @__PURE__ */ new Map();
151052
+ const activeByHost = /* @__PURE__ */ new Map();
151053
+ const runAttempt = (input, attempt) => new ClipStreamRun({
151054
+ logger: deps.logger,
151055
+ spawnMux: deps.spawnMux,
151056
+ createDecoder,
151057
+ drainTimeoutMs,
151058
+ flushTimeoutMs
151059
+ }, input, attempt).run();
151060
+ const runAttempts = async (input) => {
151061
+ const { deviceId } = input.device;
151062
+ let lastDetail = "no reason given";
151063
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
151064
+ if (attempt > 1) await sleep(500 * 2 ** (attempt - 2));
151065
+ const result = await runAttempt(input, attempt);
151066
+ if (result.kind !== "no-media") return result;
151067
+ lastDetail = result.detail;
151068
+ deps.logger.warn("videoclips: a clip stream produced no media — retrying", {
151069
+ tags: { deviceId },
151070
+ meta: {
151071
+ clipKey: input.row.clipKey,
151072
+ twin: input.twin,
151073
+ branch: "stream-no-media",
151074
+ attempt,
151075
+ error: lastDetail
151076
+ }
151077
+ });
151078
+ }
151079
+ deps.logger.warn("videoclips: the camera never delivered this clip stream", {
151080
+ tags: { deviceId },
151081
+ meta: {
151082
+ clipKey: input.row.clipKey,
151083
+ twin: input.twin,
151084
+ branch: "camera-refused",
151085
+ attempts: 3,
151086
+ error: lastDetail
151087
+ }
151088
+ });
151089
+ return {
151090
+ kind: "refused",
151091
+ code: "camera-refused",
151092
+ detail: `clip stream never landed after 3 attempts — ${lastDetail}`,
151093
+ attempts: 3
151094
+ };
151095
+ };
151096
+ return {
151097
+ active: (hostKey) => activeByHost.get(hostKey) ?? 0,
151098
+ stream: (input) => {
151099
+ const { hostKey, deviceId } = input.device;
151100
+ const ahead = activeByHost.get(hostKey) ?? 0;
151101
+ if (ahead > 0) deps.logger.info("videoclips: a clip stream is queued behind another on this host", {
151102
+ tags: { deviceId },
151103
+ meta: {
151104
+ clipKey: input.row.clipKey,
151105
+ twin: input.twin,
151106
+ branch: "stream-queued",
151107
+ ahead
151108
+ }
151109
+ });
151110
+ activeByHost.set(hostKey, ahead + 1);
151111
+ const run = (lanes.get(hostKey) ?? Promise.resolve()).then(() => runAttempts(input)).finally(() => {
151112
+ const left = (activeByHost.get(hostKey) ?? 1) - 1;
151113
+ if (left <= 0) activeByHost.delete(hostKey);
151114
+ else activeByHost.set(hostKey, left);
151115
+ });
151116
+ lanes.set(hostKey, run.then(() => void 0, () => void 0));
151117
+ return run;
151118
+ }
151119
+ };
151120
+ }
151121
+ /**
151122
+ * One transfer: camera → decoder → ffmpeg → peer, with a cut on every hop.
151123
+ *
151124
+ * A class because the run is a state machine with a dozen fields the chunk
151125
+ * callback and the download promise both touch; passing them around as
151126
+ * closures over `let`s was less readable, not more immutable.
151127
+ */
151128
+ var ClipStreamRun = class {
151129
+ deps;
151130
+ input;
151131
+ attempt;
151132
+ accessUnits = 0;
151133
+ /** Every audio frame the decoder handed over, of any codec. */
151134
+ audioFrames = 0;
151135
+ audioFramesDropped = 0;
151136
+ audioFramesAfterMux = 0;
151137
+ /** ADTS frames held until the mux exists — the audio half of the probe. */
151138
+ audioProbe = [];
151139
+ audioProbeBytes = 0;
151140
+ audioMuxed = null;
151141
+ audioSilentReason = null;
151142
+ audioIn = null;
151143
+ timestampsUs = [];
151144
+ /** Access units held until the rate is probed — the frames in flight. */
151145
+ probe = [];
151146
+ mux = null;
151147
+ muxStderr = "";
151148
+ muxClosed = null;
151149
+ rate = {
151150
+ fps: null,
151151
+ fpsSource: "unmeasured",
151152
+ accessUnits: 0
151153
+ };
151154
+ videoType = null;
151155
+ begun = false;
151156
+ ended = false;
151157
+ bytesOut = 0;
151158
+ cut = null;
151159
+ stdinGate;
151160
+ audioStdinGate;
151161
+ peerGate;
151162
+ tags;
151163
+ constructor(deps, input, attempt) {
151164
+ this.deps = deps;
151165
+ this.input = input;
151166
+ this.attempt = attempt;
151167
+ this.tags = { deviceId: input.device.deviceId };
151168
+ this.stdinGate = createDrainGate({
151169
+ timeoutMs: deps.drainTimeoutMs,
151170
+ onStall: () => this.sever("mux-stalled", "ffmpeg stopped taking access units")
151171
+ });
151172
+ this.audioStdinGate = createDrainGate({
151173
+ timeoutMs: deps.drainTimeoutMs,
151174
+ onStall: () => this.sever("mux-audio-stalled", "ffmpeg stopped taking audio frames")
151175
+ });
151176
+ this.peerGate = createDrainGate({
151177
+ timeoutMs: deps.drainTimeoutMs,
151178
+ onStall: () => this.sever("peer-stalled", "the peer stopped taking bytes")
151179
+ });
151180
+ }
151181
+ async run() {
151182
+ const { device, row, fileName, twin, sink } = this.input;
151183
+ sink.onceClose(() => {
151184
+ if (!this.ended) this.sever("peer-closed", "the peer closed the connection");
151185
+ });
151186
+ const decoder = this.deps.createDecoder({
151187
+ onVideoAccessUnit: (unit) => this.onAccessUnit(unit),
151188
+ onAudioFrame: (frame) => this.onAudioFrame(frame)
151189
+ });
151190
+ this.deps.logger.info("videoclips: streaming a clip file from the camera", {
151191
+ tags: this.tags,
151192
+ meta: {
151193
+ clipKey: row.clipKey,
151194
+ twin,
151195
+ fileName,
151196
+ startMs: row.startMs,
151197
+ endMs: row.endMs,
151198
+ attempt: this.attempt,
151199
+ audioAsked: this.input.audio,
151200
+ idleTimeoutMs: 300
151201
+ }
151202
+ });
151203
+ let api;
151204
+ try {
151205
+ api = await device.getApi();
151206
+ } catch (err) {
151207
+ return {
151208
+ kind: "no-media",
151209
+ detail: describe(err)
151210
+ };
151211
+ }
151212
+ try {
151213
+ await api.fileInfoListReplayBinaryDownload({
151214
+ channel: device.channel,
151215
+ ...device.uid !== void 0 ? { uid: device.uid } : {},
151216
+ fileName,
151217
+ idleTimeoutMs: 300,
151218
+ onChunk: (chunk) => this.onChunk(decoder, chunk)
151219
+ });
151220
+ } catch (err) {
151221
+ return this.onTransferFailed(err);
151222
+ }
151223
+ return this.onTransferEnded();
151224
+ }
151225
+ /** The camera's bytes, as they arrive. A cut run THROWS here: the only stop. */
151226
+ onChunk(decoder, chunk) {
151227
+ if (this.cut !== null) throw new Error(`clip stream cut: ${this.cut.reason}`);
151228
+ try {
151229
+ decoder.push(chunk);
151230
+ } catch (err) {
151231
+ this.sever("decode-failed", describe(err));
151232
+ throw err;
151233
+ }
151234
+ }
151235
+ onAccessUnit(unit) {
151236
+ if (this.cut !== null) return;
151237
+ this.accessUnits += 1;
151238
+ this.timestampsUs.push(unit.microseconds);
151239
+ if (this.mux === null) {
151240
+ this.probe = [...this.probe, unit];
151241
+ if (this.probe.length >= 16) this.startMux();
151242
+ return;
151243
+ }
151244
+ this.feed(unit.annexB);
151245
+ }
151246
+ /**
151247
+ * One audio frame from the decoder.
151248
+ *
151249
+ * Before the mux exists the ADTS frames are HELD, exactly as the video
151250
+ * probe holds access units, because the spawn cannot gain an input later
151251
+ * and ffmpeg will not write a byte until every input it was given has been
151252
+ * probed. After it exists they are either fed or counted as dropped — and
151253
+ * a frame that arrives after a SILENT spawn is counted apart, because that
151254
+ * one is a recording whose sound was there and was decided away.
151255
+ */
151256
+ onAudioFrame(frame) {
151257
+ if (this.cut !== null) return;
151258
+ this.audioFrames += 1;
151259
+ if (this.input.audio === "none" || frame.audioType !== "Aac" || frame.data.byteLength === 0) {
151260
+ this.audioFramesDropped += 1;
151261
+ return;
151262
+ }
151263
+ if (this.mux === null) {
151264
+ if (this.audioProbe.length >= 4096 || this.audioProbeBytes + frame.data.byteLength > 4194304) {
151265
+ this.audioFramesDropped += 1;
151266
+ return;
151267
+ }
151268
+ this.audioProbe = [...this.audioProbe, frame.data];
151269
+ this.audioProbeBytes += frame.data.byteLength;
151270
+ return;
151271
+ }
151272
+ if (this.audioMuxed === null) {
151273
+ this.audioFramesDropped += 1;
151274
+ this.audioFramesAfterMux += 1;
151275
+ return;
151276
+ }
151277
+ this.feedAudio(frame.data);
151278
+ }
151279
+ /**
151280
+ * What the mux will carry as sound, decided ONCE, before the spawn.
151281
+ *
151282
+ * The gate is `an ADTS frame is already in hand`, not `the ask was not
151283
+ * none`: ffmpeg probes `pipe:3` before it writes output, so declaring an
151284
+ * input that never delivers would hold the VIDEO hostage for a recording
151285
+ * that simply has no microphone. An ADPCM-only camera is named separately
151286
+ * from a silent one — the file path draws the same distinction
151287
+ * (`decideClipAudio`) and for the same reason.
151288
+ */
151289
+ decideAudio() {
151290
+ const asked = this.input.audio;
151291
+ if (asked === "none") {
151292
+ this.audioSilentReason = "not-requested";
151293
+ return;
151294
+ }
151295
+ if (this.audioProbe.length > 0) {
151296
+ this.audioMuxed = asked;
151297
+ return;
151298
+ }
151299
+ this.audioSilentReason = this.audioFrames > 0 ? "audio-not-copyable" : "no-audio-track";
151300
+ }
151301
+ /** Spawn ffmpeg with the probed rate and hand it what was held. */
151302
+ startMux() {
151303
+ const first = this.probe[0];
151304
+ if (first === void 0 || this.mux !== null) return;
151305
+ this.videoType = first.videoType;
151306
+ this.rate = probeClipStreamRate(this.timestampsUs);
151307
+ this.decideAudio();
151308
+ const { row, twin } = this.input;
151309
+ if (this.rate.fpsSource !== "timestamps") this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
151310
+ tags: this.tags,
151311
+ meta: {
151312
+ clipKey: row.clipKey,
151313
+ twin,
151314
+ branch: "fps-unmeasured",
151315
+ probeAccessUnits: this.rate.accessUnits
151316
+ }
151317
+ });
151318
+ let mux;
151319
+ try {
151320
+ mux = this.deps.spawnMux(buildClipStreamMuxArgs({
151321
+ videoType: this.videoType,
151322
+ fps: this.rate.fps,
151323
+ audio: this.audioMuxed
151324
+ }));
151325
+ } catch (err) {
151326
+ this.sever("mux-failed", `ffmpeg could not be spawned: ${describe(err)}`);
151327
+ return;
151328
+ }
151329
+ this.mux = mux;
151330
+ if (this.audioMuxed !== null) {
151331
+ this.audioIn = writableAt(mux.stdio, 3);
151332
+ if (this.audioIn === null) {
151333
+ this.audioMuxed = null;
151334
+ this.audioSilentReason = "no-audio-track";
151335
+ this.deps.logger.warn("videoclips: a clip stream mux exposed no audio pipe", {
151336
+ tags: this.tags,
151337
+ meta: {
151338
+ clipKey: row.clipKey,
151339
+ twin,
151340
+ branch: "audio-pipe-missing"
151341
+ }
151342
+ });
151343
+ this.sever("mux-failed", "the mux was spawned with an audio input it does not expose");
151344
+ return;
151345
+ }
151346
+ this.audioIn.on("error", (err) => this.sever("mux-failed", `ffmpeg audio stdin: ${err.message}`));
151347
+ this.audioIn.on("drain", () => this.audioStdinGate.drained());
151348
+ }
151349
+ this.muxClosed = new Promise((resolve) => {
151350
+ mux.on("close", (code) => resolve(code));
151351
+ mux.on("error", (err) => {
151352
+ this.sever("mux-failed", `ffmpeg failed: ${err.message}`);
151353
+ resolve(null);
151354
+ });
151355
+ });
151356
+ mux.stderr?.on("data", (chunk) => {
151357
+ if (this.muxStderr.length < 2e3) this.muxStderr += chunk.toString("utf8");
151358
+ });
151359
+ mux.stdin?.on("error", (err) => this.sever("mux-failed", `ffmpeg stdin: ${err.message}`));
151360
+ mux.stdin?.on("drain", () => this.stdinGate.drained());
151361
+ mux.stdout?.on("data", (chunk) => this.onMuxOut(chunk));
151362
+ mux.stdout?.on("end", () => this.finishSink());
151363
+ this.deps.logger.info("videoclips: a clip stream is muxing", {
151364
+ tags: this.tags,
151365
+ meta: {
151366
+ clipKey: row.clipKey,
151367
+ twin,
151368
+ videoType: this.videoType,
151369
+ fps: this.rate.fps,
151370
+ fpsSource: this.rate.fpsSource,
151371
+ probeAccessUnits: this.rate.accessUnits,
151372
+ audioAsked: this.input.audio,
151373
+ audioMuxed: this.audioMuxed,
151374
+ audioSilentReason: this.audioSilentReason,
151375
+ probeAudioFrames: this.audioProbe.length
151376
+ }
151377
+ });
151378
+ const held = this.probe;
151379
+ this.probe = [];
151380
+ const heldAudio = this.audioProbe;
151381
+ this.audioProbe = [];
151382
+ this.audioProbeBytes = 0;
151383
+ for (const unit of held) this.feed(unit.annexB);
151384
+ if (this.audioMuxed === null) this.audioFramesDropped += heldAudio.length;
151385
+ else for (const frame of heldAudio) this.feedAudio(frame);
151386
+ }
151387
+ feed(annexB) {
151388
+ if (this.cut !== null || this.mux?.stdin === null || this.mux?.stdin === void 0) return;
151389
+ this.stdinGate.wrote(this.mux.stdin.write(annexB));
151390
+ }
151391
+ feedAudio(adts) {
151392
+ const audioIn = this.audioIn;
151393
+ if (this.cut !== null || audioIn === null) return;
151394
+ this.audioStdinGate.wrote(audioIn.write(adts));
151395
+ }
151396
+ /** ffmpeg's output, to the peer, honouring `write()`'s return (D447). */
151397
+ onMuxOut(chunk) {
151398
+ if (this.cut !== null) return;
151399
+ const { sink } = this.input;
151400
+ if (!this.begun) {
151401
+ this.begun = true;
151402
+ sink.begin(clipStreamContentTypeFor(this.audioMuxed));
151403
+ }
151404
+ const accepted = sink.write(chunk);
151405
+ this.bytesOut += chunk.byteLength;
151406
+ this.peerGate.wrote(accepted);
151407
+ if (accepted) return;
151408
+ const stdout = this.mux?.stdout;
151409
+ stdout?.pause();
151410
+ sink.onceDrain(() => {
151411
+ this.peerGate.drained();
151412
+ stdout?.resume();
151413
+ });
151414
+ }
151415
+ finishSink() {
151416
+ if (this.cut !== null || this.ended) return;
151417
+ this.ended = true;
151418
+ this.input.sink.end();
151419
+ }
151420
+ /** Sever every hop, once, and say why. */
151421
+ sever(reason, detail) {
151422
+ if (this.cut !== null) return;
151423
+ this.cut = {
151424
+ reason,
151425
+ detail
151426
+ };
151427
+ this.stdinGate.dispose();
151428
+ this.audioStdinGate.dispose();
151429
+ this.peerGate.dispose();
151430
+ this.mux?.kill("SIGKILL");
151431
+ this.deps.logger.warn("videoclips: a clip stream was cut", {
151432
+ tags: this.tags,
151433
+ meta: {
151434
+ clipKey: this.input.row.clipKey,
151435
+ twin: this.input.twin,
151436
+ branch: reason,
151437
+ detail,
151438
+ accessUnits: this.accessUnits,
151439
+ bytesOut: this.bytesOut,
151440
+ begun: this.begun,
151441
+ audioMuxed: this.audioMuxed,
151442
+ audioFramesIn: this.audioFrames,
151443
+ audioFramesDropped: this.audioFramesDropped
151444
+ }
151445
+ });
151446
+ if (this.begun) this.input.sink.destroy();
151447
+ }
151448
+ cutOutcome(cut) {
151449
+ if (this.begun) return {
151450
+ kind: "cut",
151451
+ reason: cut.reason,
151452
+ detail: cut.detail,
151453
+ bytesOut: this.bytesOut
151454
+ };
151455
+ return {
151456
+ kind: "refused",
151457
+ code: "fetch-failed",
151458
+ detail: `${cut.reason}: ${cut.detail}`,
151459
+ attempts: this.attempt
151460
+ };
151461
+ }
151462
+ onTransferFailed(err) {
151463
+ if (this.cut !== null) return this.cutOutcome(this.cut);
151464
+ if (this.accessUnits === 0) return {
151465
+ kind: "no-media",
151466
+ detail: describe(err)
151467
+ };
151468
+ this.sever("camera-failed", describe(err));
151469
+ return this.cutOutcome({
151470
+ reason: "camera-failed",
151471
+ detail: describe(err)
151472
+ });
151473
+ }
151474
+ async onTransferEnded() {
151475
+ if (this.cut !== null) return this.cutOutcome(this.cut);
151476
+ if (this.accessUnits === 0) return {
151477
+ kind: "no-media",
151478
+ detail: "the camera answered the transfer with no access unit"
151479
+ };
151480
+ if (this.mux === null) this.startMux();
151481
+ const cutAtStart = this.cut;
151482
+ if (cutAtStart !== null) return this.cutOutcome(cutAtStart);
151483
+ const mux = this.mux;
151484
+ if (mux === null || this.muxClosed === null) return this.cutOutcome({
151485
+ reason: "mux-failed",
151486
+ detail: "no mux after the transfer ended"
151487
+ });
151488
+ mux.stdin?.end();
151489
+ this.audioIn?.end();
151490
+ const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
151491
+ const code = await this.muxClosed;
151492
+ clearTimeout(flush);
151493
+ const cutAfter = this.cut;
151494
+ if (cutAfter !== null) return this.cutOutcome(cutAfter);
151495
+ if (code !== 0) {
151496
+ this.sever("mux-failed", `ffmpeg exited ${String(code)}: ${this.muxStderr.slice(0, 300)}`);
151497
+ return this.cutOutcome({
151498
+ reason: "mux-failed",
151499
+ detail: this.muxStderr.slice(0, 300)
151500
+ });
151501
+ }
151502
+ this.finishSink();
151503
+ return this.served();
151504
+ }
151505
+ /** The stream landed: judge it against the row, out loud, and answer. */
151506
+ served() {
151507
+ const { row, twin } = this.input;
151508
+ const timing = estimateVideoTiming({ timestampsUs: this.timestampsUs });
151509
+ const durationSeconds = timing.fpsSource === "timestamps" ? timing.durationSeconds : null;
151510
+ const verdict = judgeClipDuration({
151511
+ expectedMs: row.endMs - row.startMs,
151512
+ decodedSeconds: durationSeconds,
151513
+ ...row.inProgress === true ? { inProgress: true } : {}
151514
+ });
151515
+ const audio = this.audioReport();
151516
+ const meta = {
151517
+ clipKey: row.clipKey,
151518
+ twin,
151519
+ attempt: this.attempt,
151520
+ accessUnits: this.accessUnits,
151521
+ audioMuxed: audio.muxed,
151522
+ bytesOut: this.bytesOut,
151523
+ durationSeconds,
151524
+ expectedMs: row.endMs - row.startMs,
151525
+ fps: this.rate.fps,
151526
+ fpsSource: this.rate.fpsSource,
151527
+ videoType: this.videoType
151528
+ };
151529
+ if (verdict.kind === "short") this.deps.logger.warn("videoclips: a clip stream came up short against its row", {
151530
+ tags: this.tags,
151531
+ meta: {
151532
+ ...meta,
151533
+ branch: "stream-short",
151534
+ shortfallSeconds: verdict.shortfallSeconds,
151535
+ retried: false
151536
+ }
151537
+ });
151538
+ else if (verdict.kind === "unknown") this.deps.logger.warn("videoclips: a clip stream could not be checked for truncation", {
151539
+ tags: this.tags,
151540
+ meta: {
151541
+ ...meta,
151542
+ branch: "completeness-unverified",
151543
+ reason: verdict.reason
151544
+ }
151545
+ });
151546
+ else this.deps.logger.info("videoclips: a clip stream landed whole", {
151547
+ tags: this.tags,
151548
+ meta: {
151549
+ ...meta,
151550
+ branch: "stream-served"
151551
+ }
151552
+ });
151553
+ this.reportAudio(audio);
151554
+ return {
151555
+ kind: "served",
151556
+ accessUnits: this.accessUnits,
151557
+ audio,
151558
+ bytesOut: this.bytesOut,
151559
+ durationSeconds,
151560
+ verdict,
151561
+ rate: this.rate,
151562
+ attempts: this.attempt
151563
+ };
151564
+ }
151565
+ audioReport() {
151566
+ return {
151567
+ asked: this.input.audio,
151568
+ muxed: this.audioMuxed,
151569
+ reason: this.audioSilentReason,
151570
+ framesIn: this.audioFrames,
151571
+ framesDropped: this.audioFramesDropped,
151572
+ framesAfterMux: this.audioFramesAfterMux
151573
+ };
151574
+ }
151575
+ /**
151576
+ * Say what became of the sound, on EVERY served stream — including the one
151577
+ * that carried it.
151578
+ *
151579
+ * A branch that accepts work and produces nothing is the branch that has to
151580
+ * speak (D391), and silence is exactly what that looks like here. But a
151581
+ * stream that DID carry audio says so too, with the frame count, because
151582
+ * "the clip was silent" and "the clip had sound and you did not hear it"
151583
+ * are answered from this line and nowhere else.
151584
+ */
151585
+ reportAudio(audio) {
151586
+ const { row, twin } = this.input;
151587
+ const meta = {
151588
+ clipKey: row.clipKey,
151589
+ twin,
151590
+ audioAsked: audio.asked,
151591
+ audioMuxed: audio.muxed,
151592
+ audioFramesIn: audio.framesIn,
151593
+ audioFramesDropped: audio.framesDropped,
151594
+ audioFramesAfterMux: audio.framesAfterMux
151595
+ };
151596
+ if (audio.muxed !== null) {
151597
+ this.deps.logger.info("videoclips: a clip stream carried sound", {
151598
+ tags: this.tags,
151599
+ meta: {
151600
+ ...meta,
151601
+ branch: "stream-with-audio"
151602
+ }
151603
+ });
151604
+ return;
151605
+ }
151606
+ if (audio.framesAfterMux > 0) {
151607
+ this.deps.logger.warn("videoclips: a clip stream dropped sound that arrived too late", {
151608
+ tags: this.tags,
151609
+ meta: {
151610
+ ...meta,
151611
+ branch: "stream-audio-after-mux"
151612
+ }
151613
+ });
151614
+ return;
151615
+ }
151616
+ if (audio.asked === "none") {
151617
+ this.deps.logger.info("videoclips: a clip stream was served without sound", {
151618
+ tags: this.tags,
151619
+ meta: {
151620
+ ...meta,
151621
+ branch: "stream-video-only"
151622
+ }
151623
+ });
151624
+ return;
151625
+ }
151626
+ this.deps.logger.warn("videoclips: a clip stream was asked for sound and had none", {
151627
+ tags: this.tags,
151628
+ meta: {
151629
+ ...meta,
151630
+ branch: `stream-silent:${audio.reason ?? "unknown"}`
151631
+ }
151632
+ });
151633
+ }
151634
+ };
151635
+ function describe(err) {
151636
+ return err instanceof Error ? err.message : String(err);
151637
+ }
151638
+ function pathToken(req) {
151639
+ const raw = req.url ?? "/";
151640
+ const query = raw.indexOf("?");
151641
+ return (query === -1 ? raw : raw.slice(0, query)).replace(/^\/+/, "");
151642
+ }
151643
+ function createClipStreamDial(deps) {
151644
+ const now = deps.now ?? (() => Date.now());
151645
+ const ttlMs = deps.ttlMs ?? 3e4;
151646
+ const maxOutstanding = deps.maxOutstanding ?? 64;
151647
+ const host = deps.host ?? "127.0.0.1";
151648
+ const tokens = /* @__PURE__ */ new Map();
151649
+ /** Tokens already dialled, kept for their TTL so a replay is named `dial-used`, not `dial-unknown`. */
151650
+ const used = /* @__PURE__ */ new Map();
151651
+ let server = null;
151652
+ let baseUrl = null;
151653
+ let listening = null;
151654
+ const sweep = () => {
151655
+ const at = now();
151656
+ for (const [token, minted] of tokens) if (minted.expiresAtMs <= at) tokens.delete(token);
151657
+ for (const [token, expiresAtMs] of used) if (expiresAtMs <= at) used.delete(token);
151658
+ };
151659
+ const resolveTarget = (req) => {
151660
+ const token = pathToken(req);
151661
+ const minted = tokens.get(token);
151662
+ if (minted === void 0) {
151663
+ if (used.get(token) !== void 0) return {
151664
+ kind: "refused",
151665
+ status: 410,
151666
+ code: "dial-used",
151667
+ detail: "this dial was already taken"
151668
+ };
151669
+ return {
151670
+ kind: "refused",
151671
+ status: 404,
151672
+ code: "dial-unknown",
151673
+ detail: "no such dial"
151674
+ };
151675
+ }
151676
+ if (minted.expiresAtMs <= now()) {
151677
+ tokens.delete(token);
151678
+ return {
151679
+ kind: "refused",
151680
+ status: 410,
151681
+ code: "dial-expired",
151682
+ detail: "this dial has expired"
151683
+ };
151684
+ }
151685
+ if (req.method === "GET") {
151686
+ tokens.delete(token);
151687
+ used.set(token, minted.expiresAtMs);
151688
+ deps.logger.info("videoclips: a clip stream dial was taken", {
151689
+ tags: { deviceId: minted.target.deviceId },
151690
+ meta: {
151691
+ clipKey: minted.target.clipKey,
151692
+ twin: minted.target.twin,
151693
+ audio: minted.target.audio,
151694
+ outstanding: tokens.size
151695
+ }
151696
+ });
151697
+ }
151698
+ return {
151699
+ kind: "target",
151700
+ target: minted.target
151701
+ };
151702
+ };
151703
+ const handler = createClipStreamHandler({
151704
+ stream: deps.stream,
151705
+ logger: deps.logger,
151706
+ target: resolveTarget
151707
+ });
151708
+ const listen = () => {
151709
+ if (listening !== null) return listening;
151710
+ listening = new Promise((resolve, reject) => {
151711
+ const created = (0, node_http.createServer)((req, res) => {
151712
+ handler(req, res).catch((err) => {
151713
+ deps.logger.warn("videoclips: the clip stream dial handler threw", { meta: { error: err instanceof Error ? err.message : String(err) } });
151714
+ if (!res.headersSent) res.writeHead(500);
151715
+ res.end();
151716
+ });
151717
+ });
151718
+ created.on("error", (err) => {
151719
+ listening = null;
151720
+ reject(err);
151721
+ });
151722
+ created.listen(0, host, () => {
151723
+ const address = created.address();
151724
+ if (address === null || typeof address === "string") {
151725
+ created.close();
151726
+ listening = null;
151727
+ reject(/* @__PURE__ */ new Error("the dial listener bound no TCP port"));
151728
+ return;
151729
+ }
151730
+ server = created;
151731
+ baseUrl = `http://${host}:${address.port}`;
151732
+ deps.logger.info("videoclips: clip stream dial listener bound", { meta: {
151733
+ host,
151734
+ port: address.port,
151735
+ ttlMs,
151736
+ maxOutstanding
151737
+ } });
151738
+ resolve(baseUrl);
151739
+ });
151740
+ });
151741
+ return listening;
151742
+ };
151743
+ return {
151744
+ get outstanding() {
151745
+ sweep();
151746
+ return tokens.size;
151747
+ },
151748
+ mint: async (input) => {
151749
+ sweep();
151750
+ if (tokens.size >= maxOutstanding) {
151751
+ deps.logger.warn("videoclips: clip stream dial refused — too many outstanding", {
151752
+ tags: { deviceId: input.deviceId },
151753
+ meta: {
151754
+ clipKey: input.clipKey,
151755
+ twin: input.twin,
151756
+ outstanding: tokens.size,
151757
+ maxOutstanding
151758
+ }
151759
+ });
151760
+ return {
151761
+ kind: "refused",
151762
+ code: "dial-exhausted",
151763
+ detail: `${tokens.size} dials outstanding against a bound of ${maxOutstanding}`
151764
+ };
151765
+ }
151766
+ let base;
151767
+ try {
151768
+ base = baseUrl ?? await listen();
151769
+ } catch (err) {
151770
+ const detail = err instanceof Error ? err.message : String(err);
151771
+ deps.logger.warn("videoclips: clip stream dial refused — no listener", {
151772
+ tags: { deviceId: input.deviceId },
151773
+ meta: {
151774
+ clipKey: input.clipKey,
151775
+ twin: input.twin,
151776
+ error: detail
151777
+ }
151778
+ });
151779
+ return {
151780
+ kind: "refused",
151781
+ code: "listener-failed",
151782
+ detail
151783
+ };
151784
+ }
151785
+ const token = (0, node_crypto.randomBytes)(24).toString("base64url");
151786
+ const expiresAtMs = now() + ttlMs;
151787
+ tokens.set(token, {
151788
+ target: input,
151789
+ expiresAtMs
151790
+ });
151791
+ deps.logger.info("videoclips: clip stream dial minted", {
151792
+ tags: { deviceId: input.deviceId },
151793
+ meta: {
151794
+ clipKey: input.clipKey,
151795
+ twin: input.twin,
151796
+ audio: input.audio,
151797
+ expiresInMs: ttlMs,
151798
+ outstanding: tokens.size
151799
+ }
151800
+ });
151801
+ return {
151802
+ kind: "ticket",
151803
+ url: `${base}/${token}`,
151804
+ expiresAtMs
151805
+ };
151806
+ },
151807
+ dispose: async () => {
151808
+ tokens.clear();
151809
+ used.clear();
151810
+ const open = server;
151811
+ server = null;
151812
+ baseUrl = null;
151813
+ listening = null;
151814
+ if (open === null) return;
151815
+ await new Promise((resolve) => {
151816
+ open.close(() => resolve());
151817
+ open.closeAllConnections();
151818
+ });
151819
+ deps.logger.info("videoclips: clip stream dial listener closed", {});
151820
+ }
151821
+ };
151822
+ }
151823
+ //#endregion
151824
+ //#region src/clips/clip-stream-route.ts
151825
+ function createClipStreamRoute(deps) {
151826
+ return async (target, sink) => {
151827
+ const { deviceId, clipKey, twin, audio } = target;
151828
+ const gate = await deps.gate({
151829
+ deviceId,
151830
+ clipKey,
151831
+ twin,
151832
+ path: "stream"
151833
+ });
151834
+ if (gate.kind === "refused") return {
151835
+ kind: "refused",
151836
+ code: gate.branch,
151837
+ detail: gate.detail
151838
+ };
151839
+ const row = deps.rowFor(deviceId, clipKey);
151840
+ if (row === null) {
151841
+ deps.logger.warn("videoclips: no catalog row for this clip stream", {
151842
+ tags: { deviceId },
151843
+ meta: {
151844
+ clipKey,
151845
+ twin,
151846
+ branch: "no-catalog-row"
151847
+ }
151848
+ });
151849
+ return {
151850
+ kind: "refused",
151851
+ code: "no-catalog-row",
151852
+ detail: "no-catalog-row: list this day before asking for its stream"
151853
+ };
151854
+ }
151855
+ if (row.inProgress === true) {
151856
+ deps.logger.warn("videoclips: refusing to stream a clip the camera is still writing", {
151857
+ tags: { deviceId },
151858
+ meta: {
151859
+ clipKey,
151860
+ twin,
151861
+ branch: "clip-in-progress"
151862
+ }
151863
+ });
151864
+ return {
151865
+ kind: "refused",
151866
+ code: "clip-in-progress",
151867
+ detail: "clip-in-progress: the camera is still writing this file; it has no end to stream to"
151868
+ };
151869
+ }
151870
+ const fileName = twin === "main" ? row.mainFileName ?? "" : row.subFileName;
151871
+ if (fileName === "") {
151872
+ deps.logger.warn("videoclips: no byte handle for the twin this clip stream names", {
151873
+ tags: { deviceId },
151874
+ meta: {
151875
+ clipKey,
151876
+ twin,
151877
+ branch: "no-catalog-row"
151878
+ }
151879
+ });
151880
+ return {
151881
+ kind: "refused",
151882
+ code: "no-catalog-row",
151883
+ detail: `no-catalog-row: this clip is listed with no ${twin} file behind it`
151884
+ };
151885
+ }
151886
+ const outcome = await deps.producer.stream({
151887
+ device: gate.device,
151888
+ row,
151889
+ fileName,
151890
+ twin,
151891
+ audio,
151892
+ sink
151893
+ });
151894
+ switch (outcome.kind) {
151895
+ case "served": return { kind: "served" };
151896
+ case "cut": return { kind: "cut" };
151897
+ case "refused": return {
151898
+ kind: "refused",
151899
+ code: outcome.code,
151900
+ detail: outcome.detail
151901
+ };
151902
+ }
151903
+ };
151904
+ }
151905
+ //#endregion
149256
151906
  //#region src/clips/clip-thumb-queue.ts
149257
151907
  /**
149258
151908
  * The provider's OWN CoverPreview lane — two queues, one in flight per host.
@@ -149432,210 +152082,58 @@ function decideClipPrefetch(input) {
149432
152082
  return { kind: "run" };
149433
152083
  }
149434
152084
  //#endregion
149435
- //#region src/clips/clip-identity.ts
152085
+ //#region src/clips/clip-window.ts
149436
152086
  /**
149437
- * A Reolink clip's IDENTITY — the id the operator's surface carries around and
149438
- * the key its cached bytes are filed under.
152087
+ * Does this row overlap the window?
149439
152088
  *
149440
- * `native:reolink:<source>:<channel>:<base64url(fileName)>` (D549 3). It is
149441
- * self-contained on purpose: the thumbnail route, the playback route and the
149442
- * collection dispatcher each resolve a clip from the id alone, so none of them
149443
- * re-lists the camera to answer a click. The source is INSIDE the id because
149444
- * D554 5 routes a scalar cap call by the longest source that prefixes it, and
149445
- * because an id naming a source this provider no longer serves must be
149446
- * REFUSED rather than routed by prefix luck.
152089
+ * Half-open on both ends (`start < until`, `end > since`), so a clip that ends
152090
+ * exactly when the window begins is OUT and a clip that begins exactly when it
152091
+ * ends is OUT — otherwise two adjacent windows both claim the same boundary
152092
+ * clip and a surface paging through them renders it twice.
149447
152093
  *
149448
- * base64url, not base64: a hub's `<Id>` is a real path
149449
- * (`/mnt/sda/U10<uid>-<name>/RecS02_…mp4`), and `/` and `+` in a URL path
149450
- * segment are a different path or a space.
152094
+ * A row whose `endMs` is not after its `startMs` is treated as an INSTANT at
152095
+ * `startMs`. That is not defensive padding: a clip still being written carries
152096
+ * a frozen `endMs` from the camera's index (D563), and on this fleet that has
152097
+ * been observed equal to — and once behind — its own start. Judging such a row
152098
+ * by a bad end would drop a recording that is happening right now.
149451
152099
  */
152100
+ function clipOverlapsWindow(row, window) {
152101
+ const end = row.endMs > row.startMs ? row.endMs : row.startMs;
152102
+ return row.startMs < window.until && end > window.since;
152103
+ }
149452
152104
  /**
149453
- * The camera's OWN recordings — the ONE Reolink source.
152105
+ * What the provider ANSWERS with — the one gate, so there is a single place
152106
+ * that decides what leaves this listing and a single line that reports it.
149454
152107
  *
149455
- * It was two (`onboard` for the files view, `hub` for the parent's event
149456
- * view, D549 11) for a few hours, on the premise that the two might hold
149457
- * DIFFERENT sets. Our own measurement disproved that premise: the hub's uid
149458
- * addresses no catalog at all (empty month over 24 months, zero files, zero
149459
- * rows) while the child's uid addresses all of it, cmd 5 serves identical
149460
- * bytes under either, and on 3628 the two views cover each other almost
149461
- * exactly — 29 events, 28 inside the 20 listed files, and all 20 files
149462
- * carrying an event. They are two VIEWS of one store.
152108
+ * ## Why a clip still being written is not listed
149463
152109
  *
149464
- * So the operator collapsed them (2026-09-20): one source, which picked the
149465
- * best API for what the camera IS, and REPORTED the pick on `ClipSource.via`.
149466
- * Later the same day the pick went too (D565): `FileInfoList` is the catalog
149467
- * on every topology, so `via` had one value and is gone. What is left is one
149468
- * source reading one API — and `clipKeyFor` below is still keyed off
149469
- * `(channel, fileName)` rather than the source, which is now simply true
149470
- * rather than a concession.
149471
- */
149472
- var CLIP_SOURCE_ONBOARD = "native:reolink:onboard";
149473
- /** Every source this provider can name. Order is the picker's order. */
149474
- var REOLINK_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
149475
- function encodeFileName(fileName) {
149476
- return Buffer.from(fileName, "utf8").toString("base64url");
149477
- }
149478
- function mintClipId(parts) {
149479
- const base = `${parts.source}:${parts.channel}:${encodeFileName(parts.fileName)}`;
149480
- return parts.window === void 0 ? base : `${base}:${parts.window.startMs}-${parts.window.endMs}`;
149481
- }
149482
- /**
149483
- * Parse an id this provider minted. `null` for anything else — including an id
149484
- * from the analytics or HKSV source, which is how the collection dispatcher's
149485
- * "nobody claimed this id" branch stays honest.
149486
- */
149487
- function parseClipId(clipId) {
149488
- const source = REOLINK_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
149489
- if (source === void 0) return null;
149490
- const rest = clipId.slice(source.length + 1);
149491
- const sep = rest.indexOf(":");
149492
- if (sep <= 0) return null;
149493
- const channel = Number(rest.slice(0, sep));
149494
- if (!Number.isInteger(channel) || channel < 0) return null;
149495
- const payload = rest.slice(sep + 1);
149496
- if (payload === "") return null;
149497
- const windowSep = payload.indexOf(":");
149498
- const encoded = windowSep === -1 ? payload : payload.slice(0, windowSep);
149499
- if (encoded === "") return null;
149500
- const fileName = Buffer.from(encoded, "base64url").toString("utf8");
149501
- if (fileName === "") return null;
149502
- if (windowSep === -1) return {
149503
- source,
149504
- channel,
149505
- fileName
149506
- };
149507
- const match = /^(\d+)-(\d+)$/.exec(payload.slice(windowSep + 1));
149508
- if (match === null) return null;
149509
- return {
149510
- source,
149511
- channel,
149512
- fileName,
149513
- window: {
149514
- startMs: Number(match[1]),
149515
- endMs: Number(match[2])
149516
- }
149517
- };
149518
- }
149519
- /**
149520
- * The on-disk key for a clip's derived artifacts (its thumbnail, its muxed
149521
- * MP4). Derived from `(channel, fileName)` and NOT from the source: a
149522
- * thumbnail belongs to the FILE, so any surface that names the same file
149523
- * finds the same picture and the camera is never asked for a second
149524
- * CoverPreview of it.
152110
+ * The operator, 2026-09-22: *"Niente clips in preparazione, non ha senso
152111
+ * visualizzarle."* He is right, and the reasons had been accumulating:
149525
152112
  *
149526
- * A hash rather than the encoded name because a hub `<Id>` is a full path and
149527
- * the encoded form is long enough to hit a file-name limit.
149528
- */
149529
- function clipKeyFor(channel, fileName) {
149530
- return (0, node_crypto.createHash)("sha1").update(`${channel}${fileName}`, "utf8").digest("hex");
149531
- }
149532
- //#endregion
149533
- //#region src/clips/clip-native-types.ts
149534
- /** Cap on `ClipSchema.labels`, mirrored from `@camstack/types` MAX_CLIP_LABELS. */
149535
- var MAX_LABELS = 3;
149536
- /** Native → object class. The same rule `native-detection-mapping.ts` applies
149537
- * to live boxes, so a clip and a live box never disagree about a dog. */
149538
- var CLASS_BY_NATIVE = {
149539
- people: "person",
149540
- face: "person",
149541
- visitor: "person",
149542
- vehicle: "vehicle",
149543
- nonmotorveh: "vehicle",
149544
- dog_cat: "animal",
149545
- package: "package"
149546
- };
149547
- /** Natives that state a KIND rather than a class. */
149548
- var KIND_BY_NATIVE = {
149549
- md: "motion",
149550
- pir: "motion",
149551
- cry: "audio",
149552
- other: "motion"
149553
- };
149554
- /** What the subject DID — carried beside a class, never as one. */
149555
- var BEHAVIOUR_BY_NATIVE = {
149556
- crossline: "crossline",
149557
- intrude: "intrude",
149558
- intrusion: "intrude",
149559
- loitering: "loitering",
149560
- visitor: "doorbell"
149561
- };
149562
- /**
149563
- * Recording REASONS — why the file exists, not what is in it. They get no
149564
- * label and no behaviour; the row still lists, showing the reason raw.
149565
- * `none` is the camera declining to classify, which is not the same as the
149566
- * camera saying "nothing". `other` used to live here and is now a motion KIND
149567
- * — see {@link KIND_BY_NATIVE}.
149568
- */
149569
- var RECORDING_REASONS = new Set([
149570
- "sched",
149571
- "manual",
149572
- "io",
149573
- "IPCInputOutput",
149574
- "legacy",
149575
- "loss",
149576
- "answer",
149577
- "tamper",
149578
- "lmsg",
149579
- "none"
149580
- ]);
149581
- /**
149582
- * Ordering for `labels` — "dominant first". A person outranks a vehicle
149583
- * outranks an animal outranks a package; anything unknown sorts last so a
149584
- * newer firmware's invention never displaces a class the operator filters on.
152113
+ * - its `endMs` is a frozen guess — the camera's index keeps whatever it held
152114
+ * when the file was opened, measured on 592 as the same `endTime` for eight
152115
+ * minutes (D563) — so the row's duration, its position on the axis and its
152116
+ * height are all wrong and all move;
152117
+ * - `readClipBytes` already refuses it by name (`clip-in-progress`): it has no
152118
+ * end to export against, so the completion rule has nothing to prove the
152119
+ * fetch finished (D568);
152120
+ * - it cannot be looped, and the surface has to explain why (`resolveClipLoop`);
152121
+ * - the player route would file a TRUNCATED copy under an immutable key, so
152122
+ * the first seconds become permanently what that clip is.
152123
+ *
152124
+ * Four different places bending around a row that cannot be played properly.
152125
+ * Not listing it deletes all four questions instead of answering them: the
152126
+ * clip appears, whole, on the next listing after the camera closes the file.
149585
152127
  */
149586
- var CLASS_RANK = [
149587
- "person",
149588
- "vehicle",
149589
- "animal",
149590
- "package"
149591
- ];
149592
- /** Split the `recordType` CSV a file carries into raw types, de-duplicated,
149593
- * in wire order. Case is NOT normalised: `IPCInputOutput` is what the camera
149594
- * said and what `nativeTypes` must carry back. */
149595
- function parseNativeTypes(recordType) {
149596
- if (recordType === void 0) return [];
149597
- const out = [];
149598
- for (const part of recordType.split(",")) {
149599
- const trimmed = part.trim();
149600
- if (trimmed === "" || out.includes(trimmed)) continue;
149601
- out.push(trimmed);
149602
- }
149603
- return out;
149604
- }
149605
- function classifyNativeTypes(nativeTypes) {
149606
- const labels = [];
149607
- const behaviours = [];
149608
- let kind = "native";
149609
- for (const raw of nativeTypes) {
149610
- const behaviour = BEHAVIOUR_BY_NATIVE[raw];
149611
- if (behaviour !== void 0 && !behaviours.includes(behaviour)) behaviours.push(behaviour);
149612
- const cls = CLASS_BY_NATIVE[raw];
149613
- if (cls !== void 0) {
149614
- if (!labels.includes(cls)) labels.push(cls);
149615
- continue;
149616
- }
149617
- const kindOf = KIND_BY_NATIVE[raw];
149618
- if (kindOf !== void 0) {
149619
- if (kind === "native") kind = kindOf;
149620
- continue;
149621
- }
149622
- if (RECORDING_REASONS.has(raw)) continue;
149623
- if (behaviour !== void 0) continue;
149624
- const unknown = `unknown:${raw}`;
149625
- if (!labels.includes(unknown)) labels.push(unknown);
149626
- }
149627
- if (labels.some((label) => !label.startsWith("unknown:"))) kind = "native";
152128
+ function listableClips(rows, window) {
152129
+ const inWindow = rows.filter((row) => clipOverlapsWindow(row, window));
152130
+ const kept = inWindow.filter((row) => row.inProgress !== true);
149628
152131
  return {
149629
- labels: labels.toSorted((a, b) => rankOf(a) - rankOf(b)).slice(0, MAX_LABELS),
149630
- behaviours,
149631
- nativeTypes: [...nativeTypes],
149632
- kind
152132
+ kept,
152133
+ outOfWindow: rows.length - inWindow.length,
152134
+ inProgress: inWindow.length - kept.length
149633
152135
  };
149634
152136
  }
149635
- function rankOf(label) {
149636
- const at = CLASS_RANK.indexOf(label);
149637
- return at === -1 ? CLASS_RANK.length : at;
149638
- }
149639
152137
  function pairTwins(subs, mains) {
149640
152138
  const datedSubs = subs.filter((f) => f.startTime instanceof Date);
149641
152139
  const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
@@ -149670,6 +152168,247 @@ function pairTwins(subs, mains) {
149670
152168
  function startOf(file) {
149671
152169
  return file.startTime?.getTime() ?? 0;
149672
152170
  }
152171
+ /**
152172
+ * The ONE place that turns a requested profile into a twin.
152173
+ *
152174
+ * Three call sites used to decide this independently — `getClipPlayback`,
152175
+ * `readClipBytes` and the stream dial — and they did not agree: the dial
152176
+ * REFUSED `high` outright while the other two served the sub copy with a
152177
+ * warning. Same question, three answers, and the one that refused made `high`
152178
+ * look unsupported rather than unavailable for that recording.
152179
+ *
152180
+ * The mapping is the profile ladder the rest of the system already uses:
152181
+ * `high` reaches for the main twin, everything else is the sub. A `high` with
152182
+ * no main twin degrades to the sub and SAYS SO through `served` and
152183
+ * `downgraded` — "there is no high-quality copy" is an answer (`pairTwins`
152184
+ * models it as `main: undefined`), and a silent substitution is what D576
152185
+ * forbids. Refusing was the other way of getting it wrong.
152186
+ *
152187
+ * An ABSENT profile is not `high`: a caller that stated no preference gets the
152188
+ * cheap copy, which is what all three call sites did before this function
152189
+ * existed (`profile === 'high'` on `undefined` is false).
152190
+ */
152191
+ function chooseClipTwin(profile, row) {
152192
+ const wantsMain = profile === "high";
152193
+ const mainFileName = wantsMain ? row.mainFileName : void 0;
152194
+ if (mainFileName !== void 0) return {
152195
+ twin: "main",
152196
+ fileName: mainFileName,
152197
+ served: "high",
152198
+ downgraded: false
152199
+ };
152200
+ return {
152201
+ twin: "sub",
152202
+ fileName: row.subFileName,
152203
+ served: "low",
152204
+ downgraded: wantsMain
152205
+ };
152206
+ }
152207
+ //#endregion
152208
+ //#region src/clips/clip-identity.ts
152209
+ /**
152210
+ * A Reolink clip's IDENTITY — the id the operator's surface carries around and
152211
+ * the key its cached bytes are filed under.
152212
+ *
152213
+ * `native:reolink:<source>:<channel>:<base64url(fileName)>` (D549 3). It is
152214
+ * self-contained on purpose: the thumbnail route, the playback route and the
152215
+ * collection dispatcher each resolve a clip from the id alone, so none of them
152216
+ * re-lists the camera to answer a click. The source is INSIDE the id because
152217
+ * D554 5 routes a scalar cap call by the longest source that prefixes it, and
152218
+ * because an id naming a source this provider no longer serves must be
152219
+ * REFUSED rather than routed by prefix luck.
152220
+ *
152221
+ * base64url, not base64: a hub's `<Id>` is a real path
152222
+ * (`/mnt/sda/U10<uid>-<name>/RecS02_…mp4`), and `/` and `+` in a URL path
152223
+ * segment are a different path or a space.
152224
+ */
152225
+ /**
152226
+ * The camera's OWN recordings — the ONE Reolink source.
152227
+ *
152228
+ * It was two (`onboard` for the files view, `hub` for the parent's event
152229
+ * view, D549 11) for a few hours, on the premise that the two might hold
152230
+ * DIFFERENT sets. Our own measurement disproved that premise: the hub's uid
152231
+ * addresses no catalog at all (empty month over 24 months, zero files, zero
152232
+ * rows) while the child's uid addresses all of it, cmd 5 serves identical
152233
+ * bytes under either, and on 3628 the two views cover each other almost
152234
+ * exactly — 29 events, 28 inside the 20 listed files, and all 20 files
152235
+ * carrying an event. They are two VIEWS of one store.
152236
+ *
152237
+ * So the operator collapsed them (2026-09-20): one source, which picked the
152238
+ * best API for what the camera IS, and REPORTED the pick on `ClipSource.via`.
152239
+ * Later the same day the pick went too (D565): `FileInfoList` is the catalog
152240
+ * on every topology, so `via` had one value and is gone. What is left is one
152241
+ * source reading one API — and `clipKeyFor` below is still keyed off
152242
+ * `(channel, fileName)` rather than the source, which is now simply true
152243
+ * rather than a concession.
152244
+ */
152245
+ var CLIP_SOURCE_ONBOARD = "native:reolink:onboard";
152246
+ /** Every source this provider can name. Order is the picker's order. */
152247
+ var REOLINK_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
152248
+ function encodeFileName(fileName) {
152249
+ return Buffer.from(fileName, "utf8").toString("base64url");
152250
+ }
152251
+ function mintClipId(parts) {
152252
+ const base = `${parts.source}:${parts.channel}:${encodeFileName(parts.fileName)}`;
152253
+ return parts.window === void 0 ? base : `${base}:${parts.window.startMs}-${parts.window.endMs}`;
152254
+ }
152255
+ /**
152256
+ * Parse an id this provider minted. `null` for anything else — including an id
152257
+ * from the analytics or HKSV source, which is how the collection dispatcher's
152258
+ * "nobody claimed this id" branch stays honest.
152259
+ */
152260
+ function parseClipId(clipId) {
152261
+ const source = REOLINK_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
152262
+ if (source === void 0) return null;
152263
+ const rest = clipId.slice(source.length + 1);
152264
+ const sep = rest.indexOf(":");
152265
+ if (sep <= 0) return null;
152266
+ const channel = Number(rest.slice(0, sep));
152267
+ if (!Number.isInteger(channel) || channel < 0) return null;
152268
+ const payload = rest.slice(sep + 1);
152269
+ if (payload === "") return null;
152270
+ const windowSep = payload.indexOf(":");
152271
+ const encoded = windowSep === -1 ? payload : payload.slice(0, windowSep);
152272
+ if (encoded === "") return null;
152273
+ const fileName = Buffer.from(encoded, "base64url").toString("utf8");
152274
+ if (fileName === "") return null;
152275
+ if (windowSep === -1) return {
152276
+ source,
152277
+ channel,
152278
+ fileName
152279
+ };
152280
+ const match = /^(\d+)-(\d+)$/.exec(payload.slice(windowSep + 1));
152281
+ if (match === null) return null;
152282
+ return {
152283
+ source,
152284
+ channel,
152285
+ fileName,
152286
+ window: {
152287
+ startMs: Number(match[1]),
152288
+ endMs: Number(match[2])
152289
+ }
152290
+ };
152291
+ }
152292
+ /**
152293
+ * The on-disk key for a clip's derived artifacts (its thumbnail, its muxed
152294
+ * MP4). Derived from `(channel, fileName)` and NOT from the source: a
152295
+ * thumbnail belongs to the FILE, so any surface that names the same file
152296
+ * finds the same picture and the camera is never asked for a second
152297
+ * CoverPreview of it.
152298
+ *
152299
+ * A hash rather than the encoded name because a hub `<Id>` is a full path and
152300
+ * the encoded form is long enough to hit a file-name limit.
152301
+ */
152302
+ function clipKeyFor(channel, fileName) {
152303
+ return (0, node_crypto.createHash)("sha1").update(`${channel}${fileName}`, "utf8").digest("hex");
152304
+ }
152305
+ //#endregion
152306
+ //#region src/clips/clip-native-types.ts
152307
+ /** Cap on `ClipSchema.labels`, mirrored from `@camstack/types` MAX_CLIP_LABELS. */
152308
+ var MAX_LABELS = 3;
152309
+ /** Native → object class. The same rule `native-detection-mapping.ts` applies
152310
+ * to live boxes, so a clip and a live box never disagree about a dog. */
152311
+ var CLASS_BY_NATIVE = {
152312
+ people: "person",
152313
+ face: "person",
152314
+ visitor: "person",
152315
+ vehicle: "vehicle",
152316
+ nonmotorveh: "vehicle",
152317
+ dog_cat: "animal",
152318
+ package: "package"
152319
+ };
152320
+ /** Natives that state a KIND rather than a class. */
152321
+ var KIND_BY_NATIVE = {
152322
+ md: "motion",
152323
+ pir: "motion",
152324
+ cry: "audio",
152325
+ other: "motion"
152326
+ };
152327
+ /** What the subject DID — carried beside a class, never as one. */
152328
+ var BEHAVIOUR_BY_NATIVE = {
152329
+ crossline: "crossline",
152330
+ intrude: "intrude",
152331
+ intrusion: "intrude",
152332
+ loitering: "loitering",
152333
+ visitor: "doorbell"
152334
+ };
152335
+ /**
152336
+ * Recording REASONS — why the file exists, not what is in it. They get no
152337
+ * label and no behaviour; the row still lists, showing the reason raw.
152338
+ * `none` is the camera declining to classify, which is not the same as the
152339
+ * camera saying "nothing". `other` used to live here and is now a motion KIND
152340
+ * — see {@link KIND_BY_NATIVE}.
152341
+ */
152342
+ var RECORDING_REASONS = new Set([
152343
+ "sched",
152344
+ "manual",
152345
+ "io",
152346
+ "IPCInputOutput",
152347
+ "legacy",
152348
+ "loss",
152349
+ "answer",
152350
+ "tamper",
152351
+ "lmsg",
152352
+ "none"
152353
+ ]);
152354
+ /**
152355
+ * Ordering for `labels` — "dominant first". A person outranks a vehicle
152356
+ * outranks an animal outranks a package; anything unknown sorts last so a
152357
+ * newer firmware's invention never displaces a class the operator filters on.
152358
+ */
152359
+ var CLASS_RANK = [
152360
+ "person",
152361
+ "vehicle",
152362
+ "animal",
152363
+ "package"
152364
+ ];
152365
+ /** Split the `recordType` CSV a file carries into raw types, de-duplicated,
152366
+ * in wire order. Case is NOT normalised: `IPCInputOutput` is what the camera
152367
+ * said and what `nativeTypes` must carry back. */
152368
+ function parseNativeTypes(recordType) {
152369
+ if (recordType === void 0) return [];
152370
+ const out = [];
152371
+ for (const part of recordType.split(",")) {
152372
+ const trimmed = part.trim();
152373
+ if (trimmed === "" || out.includes(trimmed)) continue;
152374
+ out.push(trimmed);
152375
+ }
152376
+ return out;
152377
+ }
152378
+ function classifyNativeTypes(nativeTypes) {
152379
+ const labels = [];
152380
+ const behaviours = [];
152381
+ let kind = "native";
152382
+ for (const raw of nativeTypes) {
152383
+ const behaviour = BEHAVIOUR_BY_NATIVE[raw];
152384
+ if (behaviour !== void 0 && !behaviours.includes(behaviour)) behaviours.push(behaviour);
152385
+ const cls = CLASS_BY_NATIVE[raw];
152386
+ if (cls !== void 0) {
152387
+ if (!labels.includes(cls)) labels.push(cls);
152388
+ continue;
152389
+ }
152390
+ const kindOf = KIND_BY_NATIVE[raw];
152391
+ if (kindOf !== void 0) {
152392
+ if (kind === "native") kind = kindOf;
152393
+ continue;
152394
+ }
152395
+ if (RECORDING_REASONS.has(raw)) continue;
152396
+ if (behaviour !== void 0) continue;
152397
+ const unknown = `unknown:${raw}`;
152398
+ if (!labels.includes(unknown)) labels.push(unknown);
152399
+ }
152400
+ if (labels.some((label) => !label.startsWith("unknown:"))) kind = "native";
152401
+ return {
152402
+ labels: labels.toSorted((a, b) => rankOf(a) - rankOf(b)).slice(0, MAX_LABELS),
152403
+ behaviours,
152404
+ nativeTypes: [...nativeTypes],
152405
+ kind
152406
+ };
152407
+ }
152408
+ function rankOf(label) {
152409
+ const at = CLASS_RANK.indexOf(label);
152410
+ return at === -1 ? CLASS_RANK.length : at;
152411
+ }
149673
152412
  var partsCache = /* @__PURE__ */ new Map();
149674
152413
  function formatterFor$1(timeZone) {
149675
152414
  const cached = partsCache.get(timeZone);
@@ -150204,7 +152943,24 @@ function createReolinkVideoclipsProvider(deps) {
150204
152943
  state: "ok",
150205
152944
  catalogAsOf: deps.now()
150206
152945
  });
150207
- return rows;
152946
+ for (const row of rows) rowsByKey.set(`${device.deviceId}|${row.clipKey}`, row);
152947
+ const narrowed = listableClips(rows, {
152948
+ since,
152949
+ until
152950
+ });
152951
+ if (narrowed.outOfWindow > 0 || narrowed.inProgress > 0) deps.logger.info("videoclips: narrowed a day listing to what it can answer", {
152952
+ tags,
152953
+ meta: {
152954
+ branch: "listing-narrowed",
152955
+ listed: rows.length,
152956
+ kept: narrowed.kept.length,
152957
+ outOfWindow: narrowed.outOfWindow,
152958
+ inProgress: narrowed.inProgress,
152959
+ since,
152960
+ until
152961
+ }
152962
+ });
152963
+ return narrowed.kept;
150208
152964
  };
150209
152965
  /**
150210
152966
  * Give a day's rows a class when the FILES carried none.
@@ -150354,14 +153110,17 @@ function createReolinkVideoclipsProvider(deps) {
150354
153110
  if (device === null) throw new Error(`videoclips: device ${deviceId} is not a Reolink camera`);
150355
153111
  if (device.isBattery && device.sleeping && !device.isHubChild) throw new Error(`videoclips: device ${deviceId} is a standalone battery camera and is sleeping — fetching a clip would wake it. Use "Wake and refresh" first.`);
150356
153112
  const row = rowsByKey.get(`${deviceId}|${clipKeyFor(parsed.channel, parsed.fileName)}`) ?? null;
150357
- const wantsMain = profile === "high" && row?.mainFileName !== void 0;
150358
- const fileName = wantsMain ? row?.mainFileName ?? parsed.fileName : parsed.fileName;
153113
+ const chosen = row === null ? null : chooseClipTwin(profile, {
153114
+ subFileName: row.subFileName,
153115
+ ...row.mainFileName !== void 0 ? { mainFileName: row.mainFileName } : {}
153116
+ });
153117
+ const fileName = chosen === null || chosen.fileName === "" ? parsed.fileName : chosen.fileName;
150359
153118
  const prepared = await deps.preparePlayback({
150360
153119
  device,
150361
153120
  row,
150362
153121
  fileName,
150363
153122
  clipKey: clipKeyFor(parsed.channel, parsed.fileName),
150364
- profile: wantsMain ? "high" : "low"
153123
+ profile: chosen?.served ?? "low"
150365
153124
  });
150366
153125
  return {
150367
153126
  playbackUrl: prepared.playbackUrl,
@@ -150422,8 +153181,8 @@ function createReolinkVideoclipsProvider(deps) {
150422
153181
  const row = rowsByKey.get(`${deviceId}|${clipKey}`);
150423
153182
  if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${deviceId} — list the day that holds it first`, { clipKey });
150424
153183
  if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written, so it has no end to export`, { clipKey });
150425
- const mainFileName = profile === "high" ? row.mainFileName : void 0;
150426
- if (profile === "high" && mainFileName === void 0) deps.logger.warn("videoclips: no main twin for this clip — serving the sub copy", {
153184
+ const chosen = chooseClipTwin(profile, row);
153185
+ if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — serving the sub copy", {
150427
153186
  tags,
150428
153187
  meta: {
150429
153188
  clipId,
@@ -150432,13 +153191,13 @@ function createReolinkVideoclipsProvider(deps) {
150432
153191
  asked: profile
150433
153192
  }
150434
153193
  });
150435
- const twin = mainFileName === void 0 ? "sub" : "main";
150436
- const fileName = mainFileName ?? row.subFileName;
153194
+ const twin = chosen.twin;
153195
+ const fileName = chosen.fileName;
150437
153196
  if (fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
150438
153197
  clipKey,
150439
153198
  twin
150440
153199
  });
150441
- const served = twin === "main" ? "high" : "low";
153200
+ const served = chosen.served;
150442
153201
  const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
150443
153202
  const declaredBytes = twin === "main" ? row.mainBytes : row.subBytes;
150444
153203
  if (declaredBytes !== void 0 && declaredBytes > bound) return refuse("too-large-to-transfer", `the camera lists the ${twin} file of clip "${clipId}" as ${declaredBytes} bytes, over the ${bound}-byte bound for an inline read — play it from its playback URL instead`, {
@@ -150482,6 +153241,75 @@ function createReolinkVideoclipsProvider(deps) {
150482
153241
  served,
150483
153242
  ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
150484
153243
  };
153244
+ },
153245
+ /**
153246
+ * Where this clip's STREAM is dialled (D597) — the same questions
153247
+ * `readClipBytes` asks before the camera is touched (the id, the row, a
153248
+ * file still being written), the same vocabulary, and NO fetch: the URL
153249
+ * is one shot on the provider's own listener and the camera is opened
153250
+ * when the broker dials it.
153251
+ *
153252
+ * It serves EVERY profile, through the same {@link chooseClipTwin} the
153253
+ * byte read asks (D599). It used to refuse `high` by name, on the grounds
153254
+ * that handing a sub copy to a `high` ask is the silent 33× substitution
153255
+ * D576 forbids — true, but the refusal was not the cure. The cure is
153256
+ * `served`, which is on this answer and states which twin the stream
153257
+ * actually carries. Refusing left the operator with `high` looking
153258
+ * unsupported, and left two transports behind one pointer.
153259
+ */
153260
+ dialClipStream: async ({ deviceId, clipId, profile, audio }) => {
153261
+ const tags = { deviceId };
153262
+ const refuse = (code, detail, meta) => {
153263
+ deps.logger.warn("videoclips: refusing a clip stream dial", {
153264
+ tags,
153265
+ meta: {
153266
+ clipId,
153267
+ branch: code,
153268
+ ...meta
153269
+ }
153270
+ });
153271
+ throw new Error(clipExportFailure(code, detail));
153272
+ };
153273
+ const parsed = parseClipId(clipId);
153274
+ if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Reolink provider`, {});
153275
+ const clipKey = clipKeyFor(parsed.channel, parsed.fileName);
153276
+ const row = rowsByKey.get(`${deviceId}|${clipKey}`);
153277
+ if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${deviceId} — list the day that holds it first`, { clipKey });
153278
+ if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written; it has no end to stream to`, { clipKey });
153279
+ const chosen = chooseClipTwin(profile, row);
153280
+ if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — streaming the sub copy", {
153281
+ tags,
153282
+ meta: {
153283
+ clipId,
153284
+ clipKey,
153285
+ branch: "no-main-twin",
153286
+ asked: profile
153287
+ }
153288
+ });
153289
+ if (chosen.fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
153290
+ clipKey,
153291
+ twin: chosen.twin
153292
+ });
153293
+ const askedAudio = audio ?? "none";
153294
+ const audioReason = askedAudio === "none" ? "not-requested" : null;
153295
+ const outcome = await deps.prepareStreamDial({
153296
+ deviceId,
153297
+ row,
153298
+ twin: chosen.twin,
153299
+ audio: askedAudio
153300
+ });
153301
+ if (outcome.kind === "refused") return refuse(outcome.code, outcome.detail, {
153302
+ clipKey,
153303
+ twin: chosen.twin
153304
+ });
153305
+ return {
153306
+ url: outcome.url,
153307
+ expiresAtMs: outcome.expiresAtMs,
153308
+ transport: "forward-only",
153309
+ served: chosen.served,
153310
+ servedAudio: askedAudio,
153311
+ ...audioReason !== null ? { audioReason } : {}
153312
+ };
150485
153313
  }
150486
153314
  };
150487
153315
  /**
@@ -151112,9 +153940,15 @@ function createClipService(deps) {
151112
153940
  * clip-export vocabulary, so the provider can hand the engine a token it
151113
153941
  * believes rather than prose it has to guess at (D572).
151114
153942
  */
151115
- const produceTwin = async (input) => {
151116
- const { deviceId, row, fileName, twin } = input;
151117
- const clipKey = row.clipKey;
153943
+ /**
153944
+ * The gates every clip byte path runs, in ONE place: the device, the
153945
+ * sleeping standalone, the uid. The file producer and the stream producer
153946
+ * both call this and neither decides any of it again — a second copy of
153947
+ * the sleeping gate is a second chance to wake a camera nobody authorised.
153948
+ * Every refusal is logged HERE, with its branch, so a caller cannot forget.
153949
+ */
153950
+ const gateClipDevice = async (input) => {
153951
+ const { deviceId, clipKey, twin, path } = input;
151118
153952
  const tags = { deviceId };
151119
153953
  const device = await deps.resolveDevice(deviceId);
151120
153954
  if (device === null) {
@@ -151123,11 +153957,13 @@ function createClipService(deps) {
151123
153957
  meta: {
151124
153958
  clipKey,
151125
153959
  twin,
153960
+ path,
151126
153961
  branch: "unknown-device"
151127
153962
  }
151128
153963
  });
151129
153964
  return {
151130
153965
  kind: "refused",
153966
+ branch: "unknown-device",
151131
153967
  code: "catalog-unreachable",
151132
153968
  detail: `device ${deviceId} is not a Reolink camera on this node`
151133
153969
  };
@@ -151139,11 +153975,13 @@ function createClipService(deps) {
151139
153975
  meta: {
151140
153976
  clipKey,
151141
153977
  twin,
153978
+ path,
151142
153979
  branch: "sleeping"
151143
153980
  }
151144
153981
  });
151145
153982
  return {
151146
153983
  kind: "refused",
153984
+ branch: "sleeping",
151147
153985
  code: "sleeping",
151148
153986
  detail: `device ${deviceId} is a standalone battery camera and is sleeping — fetching a clip would wake it, and no operator authorisation was carried`
151149
153987
  };
@@ -151153,6 +153991,7 @@ function createClipService(deps) {
151153
153991
  meta: {
151154
153992
  clipKey,
151155
153993
  twin,
153994
+ path,
151156
153995
  branch: "wake-authorised"
151157
153996
  }
151158
153997
  });
@@ -151163,15 +154002,39 @@ function createClipService(deps) {
151163
154002
  meta: {
151164
154003
  clipKey,
151165
154004
  twin,
154005
+ path,
151166
154006
  branch: "uid-missing"
151167
154007
  }
151168
154008
  });
151169
154009
  return {
151170
154010
  kind: "refused",
154011
+ branch: "uid-missing",
151171
154012
  code: "catalog-unreachable",
151172
154013
  detail: UID_MISSING_REASON
151173
154014
  };
151174
154015
  }
154016
+ return {
154017
+ kind: "ok",
154018
+ device
154019
+ };
154020
+ };
154021
+ const produceTwin = async (input) => {
154022
+ const { deviceId, row, fileName, twin } = input;
154023
+ const clipKey = row.clipKey;
154024
+ const tags = { deviceId };
154025
+ const gate = await gateClipDevice({
154026
+ deviceId,
154027
+ clipKey,
154028
+ twin,
154029
+ path: "file",
154030
+ ...input.wake !== void 0 ? { wake: input.wake } : {}
154031
+ });
154032
+ if (gate.kind === "refused") return {
154033
+ kind: "refused",
154034
+ code: gate.code,
154035
+ detail: gate.detail
154036
+ };
154037
+ const { device } = gate;
151175
154038
  let downloaded;
151176
154039
  deps.logger.info("videoclips: fetching a clip file from the camera", {
151177
154040
  tags,
@@ -151349,6 +154212,34 @@ function createClipService(deps) {
151349
154212
  if (outcome.kind === "ok" && twin === "sub") await media.put(deviceId, row.clipKey, outcome.bytes);
151350
154213
  return outcome;
151351
154214
  };
154215
+ /**
154216
+ * The STREAM's path (`clip-stream-route.ts`): the same gates, the same row,
154217
+ * nothing written. Beside the file path, not in its place.
154218
+ */
154219
+ const streamClip = createClipStreamRoute({
154220
+ logger: deps.logger,
154221
+ gate: gateClipDevice,
154222
+ rowFor,
154223
+ producer: createClipStreamProducer({
154224
+ logger: deps.logger,
154225
+ spawnMux: (args) => (0, node_child_process.spawn)(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
154226
+ "pipe",
154227
+ "pipe",
154228
+ "pipe",
154229
+ "pipe"
154230
+ ] })
154231
+ })
154232
+ });
154233
+ /**
154234
+ * The stream's DIAL (D597): the same route behind a one-shot token on a
154235
+ * loopback listener, because the broker holds no credential the data
154236
+ * plane or the hub proxy would take. See `clip-stream-dial.ts`.
154237
+ */
154238
+ const streamDial = createClipStreamDial({
154239
+ logger: deps.logger,
154240
+ stream: streamClip,
154241
+ now
154242
+ });
151352
154243
  const provider = createReolinkVideoclipsProvider({
151353
154244
  now,
151354
154245
  cache,
@@ -151361,11 +154252,41 @@ function createClipService(deps) {
151361
154252
  served: profile === "high" ? "high" : "low"
151362
154253
  }),
151363
154254
  prepareBytes,
154255
+ prepareStreamDial: async ({ deviceId, row, twin, audio }) => {
154256
+ const gate = await gateClipDevice({
154257
+ deviceId,
154258
+ clipKey: row.clipKey,
154259
+ twin,
154260
+ path: "stream"
154261
+ });
154262
+ if (gate.kind === "refused") return {
154263
+ kind: "refused",
154264
+ code: gate.code,
154265
+ detail: gate.detail
154266
+ };
154267
+ const minted = await streamDial.mint({
154268
+ deviceId,
154269
+ clipKey: row.clipKey,
154270
+ twin,
154271
+ audio
154272
+ });
154273
+ if (minted.kind === "refused") return {
154274
+ kind: "refused",
154275
+ code: "fetch-failed",
154276
+ detail: `${minted.code}: ${minted.detail}`
154277
+ };
154278
+ return {
154279
+ kind: "ok",
154280
+ url: minted.url,
154281
+ expiresAtMs: minted.expiresAtMs
154282
+ };
154283
+ },
151364
154284
  logger: deps.logger
151365
154285
  });
151366
154286
  return {
151367
154287
  provider,
151368
154288
  cache,
154289
+ disposeClipStreamDial: () => streamDial.dispose(),
151369
154290
  refreshCatalog: async (deviceId) => {
151370
154291
  const until = now();
151371
154292
  await provider.listClips({
@@ -151378,6 +154299,11 @@ function createClipService(deps) {
151378
154299
  hostBusy: (hostKey) => queue.busy(hostKey),
151379
154300
  thumbPrefix: CLIP_THUMB_PREFIX,
151380
154301
  mediaPrefix: CLIP_MEDIA_PREFIX,
154302
+ streamPrefix: CLIP_STREAM_PREFIX,
154303
+ streamHandler: createClipStreamHandler({
154304
+ stream: streamClip,
154305
+ logger: deps.logger
154306
+ }),
151381
154307
  thumbHandler: createClipThumbHandler({
151382
154308
  readCached: (deviceId, clipKey) => thumbs.read(deviceId, clipKey),
151383
154309
  mint: (deviceId, clipKey) => mintThumbnail(deviceId, clipKey, "interactive"),
@@ -165396,6 +168322,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
165396
168322
  this.clipRefreshTimer = null;
165397
168323
  }
165398
168324
  this.logChannels?.stop();
168325
+ await this.clipService?.disposeClipStreamDial().catch(() => {});
165399
168326
  await this.emailPushServer?.stop().catch(() => {});
165400
168327
  await this.autodetectCache.dispose();
165401
168328
  }
@@ -165617,11 +168544,18 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
165617
168544
  access: "authenticated",
165618
168545
  handler: service.playbackHandler
165619
168546
  });
168547
+ const stream = await this.ctx.dataPlane?.serve({
168548
+ prefix: service.streamPrefix,
168549
+ access: "authenticated",
168550
+ handler: service.streamHandler
168551
+ });
165620
168552
  this.ctx.logger.info("Reolink clip data planes served", { meta: {
165621
168553
  thumbPath: `/addon/${REOLINK_ADDON_ID}/${service.thumbPrefix}`,
165622
168554
  mediaPath: `/addon/${REOLINK_ADDON_ID}/${service.mediaPrefix}`,
168555
+ streamPath: `/addon/${REOLINK_ADDON_ID}/${service.streamPrefix}`,
165623
168556
  thumbServed: thumb !== void 0,
165624
- mediaServed: media !== void 0
168557
+ mediaServed: media !== void 0,
168558
+ streamServed: stream !== void 0
165625
168559
  } });
165626
168560
  } catch (err) {
165627
168561
  this.ctx.logger.warn("Reolink clip data planes failed to serve — clips will not render", { meta: { error: err instanceof Error ? err.message : String(err) } });