@camstack/addon-provider-reolink 1.2.159 → 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 +2178 -79
  2. package/dist/addon.mjs +2178 -79
  3. package/package.json +1 -1
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-CUmLTud7.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",
@@ -149069,11 +150542,35 @@ function createClipPlaybackHandler(deps) {
149069
150542
  * `HEAD` answers the headers and touches no camera.
149070
150543
  */
149071
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
+ */
149072
150570
  var STREAM_HEADERS = {
149073
- "content-type": "video/mp4",
149074
150571
  "accept-ranges": "none",
149075
150572
  "cache-control": "no-store",
149076
- ["x-camstack-clip-transport"]: "forward-only"
150573
+ [CLIP_STREAM_TRANSPORT_HEADER]: "forward-only"
149077
150574
  };
149078
150575
  /** The status a refusal answers. `404` = nothing here to serve; `409` = a
149079
150576
  * clip that exists and cannot be served right now; `502` = the camera. */
@@ -149089,22 +150586,33 @@ function streamRefusalStatus(code) {
149089
150586
  }
149090
150587
  }
149091
150588
  function createClipStreamHandler(deps) {
150589
+ const resolveTarget = deps.target ?? resolveClipStreamPathTarget;
149092
150590
  return async (req, res) => {
149093
150591
  if (req.method !== "GET" && req.method !== "HEAD") {
149094
150592
  res.writeHead(405, { allow: "GET, HEAD" });
149095
150593
  res.end();
149096
150594
  return;
149097
150595
  }
149098
- const match = MEDIA_PATH.exec(pathnameOf(req));
149099
- if (match === null) {
149100
- res.writeHead(400);
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
+ });
149101
150607
  res.end();
149102
150608
  return;
149103
150609
  }
149104
- const deviceId = Number(match[1]);
149105
- const clipKey = match[2] ?? "";
150610
+ const { deviceId, clipKey } = resolved.target;
149106
150611
  if (req.method === "HEAD") {
149107
- res.writeHead(200, STREAM_HEADERS);
150612
+ res.writeHead(200, {
150613
+ "content-type": "video/mp4",
150614
+ ...STREAM_HEADERS
150615
+ });
149108
150616
  res.end();
149109
150617
  return;
149110
150618
  }
@@ -149116,8 +150624,11 @@ function createClipStreamHandler(deps) {
149116
150624
  }
149117
150625
  });
149118
150626
  const sink = {
149119
- begin: () => {
149120
- res.writeHead(200, STREAM_HEADERS);
150627
+ begin: (contentType) => {
150628
+ res.writeHead(200, {
150629
+ "content-type": contentType,
150630
+ ...STREAM_HEADERS
150631
+ });
149121
150632
  },
149122
150633
  write: (chunk) => res.write(chunk),
149123
150634
  onceDrain: (listener) => {
@@ -149135,7 +150646,7 @@ function createClipStreamHandler(deps) {
149135
150646
  };
149136
150647
  let outcome;
149137
150648
  try {
149138
- outcome = await deps.stream(deviceId, clipKey, sink);
150649
+ outcome = await deps.stream(resolved.target, sink);
149139
150650
  } catch (err) {
149140
150651
  deps.logger.warn("videoclips: the clip stream route threw", {
149141
150652
  tags: { deviceId },
@@ -149348,16 +150859,39 @@ function probeClipStreamRate(timestampsUs) {
149348
150859
  accessUnits: timestampsUs.length
149349
150860
  };
149350
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;
149351
150866
  /**
149352
- * One ffmpeg, stdin to stdout, video only, fragmented MP4.
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.
149353
150884
  *
149354
150885
  * Mirrors `buildClipMuxArgs` where a stream can: the demuxer is named from
149355
150886
  * the DETECTED codec (the BcMedia header lies on a hub child), `-r` is an
149356
150887
  * input option before its own `-i`, and the video is copied. Where it
149357
- * cannot — `+faststart`, the audio input — the file header says why.
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.
149358
150891
  */
149359
150892
  function buildClipStreamMuxArgs(input) {
149360
150893
  const demuxer = input.videoType === "H265" ? "hevc" : "h264";
150894
+ const audio = input.audio;
149361
150895
  return [
149362
150896
  "-hide_banner",
149363
150897
  "-loglevel",
@@ -149368,12 +150902,42 @@ function buildClipStreamMuxArgs(input) {
149368
150902
  demuxer,
149369
150903
  "-i",
149370
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
+ ] : [],
149371
150917
  "-c:v",
149372
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
+ ] : [],
149373
150937
  "-movflags",
149374
150938
  CLIP_STREAM_MOVFLAGS,
149375
150939
  "-f",
149376
- "mp4",
150940
+ clipStreamContainerFor(audio),
149377
150941
  "pipe:1"
149378
150942
  ];
149379
150943
  }
@@ -149461,10 +151025,22 @@ function judgeClipDuration(input) {
149461
151025
  }
149462
151026
  //#endregion
149463
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
+ }
149464
151037
  var defaultDecoder = (handlers) => new require_chunk_ZBACPERX.BcMediaAnnexBDecoder({
149465
151038
  strict: false,
149466
151039
  onVideoAccessUnit: handlers.onVideoAccessUnit,
149467
- onAudioFrame: () => handlers.onAudioFrame()
151040
+ onAudioFrame: (frame) => handlers.onAudioFrame({
151041
+ audioType: frame.audioType,
151042
+ data: frame.data
151043
+ })
149468
151044
  });
149469
151045
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
149470
151046
  function createClipStreamProducer(deps) {
@@ -149554,7 +151130,16 @@ var ClipStreamRun = class {
149554
151130
  input;
149555
151131
  attempt;
149556
151132
  accessUnits = 0;
151133
+ /** Every audio frame the decoder handed over, of any codec. */
149557
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;
149558
151143
  timestampsUs = [];
149559
151144
  /** Access units held until the rate is probed — the frames in flight. */
149560
151145
  probe = [];
@@ -149572,6 +151157,7 @@ var ClipStreamRun = class {
149572
151157
  bytesOut = 0;
149573
151158
  cut = null;
149574
151159
  stdinGate;
151160
+ audioStdinGate;
149575
151161
  peerGate;
149576
151162
  tags;
149577
151163
  constructor(deps, input, attempt) {
@@ -149583,6 +151169,10 @@ var ClipStreamRun = class {
149583
151169
  timeoutMs: deps.drainTimeoutMs,
149584
151170
  onStall: () => this.sever("mux-stalled", "ffmpeg stopped taking access units")
149585
151171
  });
151172
+ this.audioStdinGate = createDrainGate({
151173
+ timeoutMs: deps.drainTimeoutMs,
151174
+ onStall: () => this.sever("mux-audio-stalled", "ffmpeg stopped taking audio frames")
151175
+ });
149586
151176
  this.peerGate = createDrainGate({
149587
151177
  timeoutMs: deps.drainTimeoutMs,
149588
151178
  onStall: () => this.sever("peer-stalled", "the peer stopped taking bytes")
@@ -149595,9 +151185,7 @@ var ClipStreamRun = class {
149595
151185
  });
149596
151186
  const decoder = this.deps.createDecoder({
149597
151187
  onVideoAccessUnit: (unit) => this.onAccessUnit(unit),
149598
- onAudioFrame: () => {
149599
- this.audioFrames += 1;
149600
- }
151188
+ onAudioFrame: (frame) => this.onAudioFrame(frame)
149601
151189
  });
149602
151190
  this.deps.logger.info("videoclips: streaming a clip file from the camera", {
149603
151191
  tags: this.tags,
@@ -149608,6 +151196,7 @@ var ClipStreamRun = class {
149608
151196
  startMs: row.startMs,
149609
151197
  endMs: row.endMs,
149610
151198
  attempt: this.attempt,
151199
+ audioAsked: this.input.audio,
149611
151200
  idleTimeoutMs: 300
149612
151201
  }
149613
151202
  });
@@ -149654,12 +151243,68 @@ var ClipStreamRun = class {
149654
151243
  }
149655
151244
  this.feed(unit.annexB);
149656
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
+ }
149657
151301
  /** Spawn ffmpeg with the probed rate and hand it what was held. */
149658
151302
  startMux() {
149659
151303
  const first = this.probe[0];
149660
151304
  if (first === void 0 || this.mux !== null) return;
149661
151305
  this.videoType = first.videoType;
149662
151306
  this.rate = probeClipStreamRate(this.timestampsUs);
151307
+ this.decideAudio();
149663
151308
  const { row, twin } = this.input;
149664
151309
  if (this.rate.fpsSource !== "timestamps") this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
149665
151310
  tags: this.tags,
@@ -149674,13 +151319,33 @@ var ClipStreamRun = class {
149674
151319
  try {
149675
151320
  mux = this.deps.spawnMux(buildClipStreamMuxArgs({
149676
151321
  videoType: this.videoType,
149677
- fps: this.rate.fps
151322
+ fps: this.rate.fps,
151323
+ audio: this.audioMuxed
149678
151324
  }));
149679
151325
  } catch (err) {
149680
151326
  this.sever("mux-failed", `ffmpeg could not be spawned: ${describe(err)}`);
149681
151327
  return;
149682
151328
  }
149683
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
+ }
149684
151349
  this.muxClosed = new Promise((resolve) => {
149685
151350
  mux.on("close", (code) => resolve(code));
149686
151351
  mux.on("error", (err) => {
@@ -149703,24 +151368,38 @@ var ClipStreamRun = class {
149703
151368
  videoType: this.videoType,
149704
151369
  fps: this.rate.fps,
149705
151370
  fpsSource: this.rate.fpsSource,
149706
- probeAccessUnits: this.rate.accessUnits
151371
+ probeAccessUnits: this.rate.accessUnits,
151372
+ audioAsked: this.input.audio,
151373
+ audioMuxed: this.audioMuxed,
151374
+ audioSilentReason: this.audioSilentReason,
151375
+ probeAudioFrames: this.audioProbe.length
149707
151376
  }
149708
151377
  });
149709
151378
  const held = this.probe;
149710
151379
  this.probe = [];
151380
+ const heldAudio = this.audioProbe;
151381
+ this.audioProbe = [];
151382
+ this.audioProbeBytes = 0;
149711
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);
149712
151386
  }
149713
151387
  feed(annexB) {
149714
151388
  if (this.cut !== null || this.mux?.stdin === null || this.mux?.stdin === void 0) return;
149715
151389
  this.stdinGate.wrote(this.mux.stdin.write(annexB));
149716
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
+ }
149717
151396
  /** ffmpeg's output, to the peer, honouring `write()`'s return (D447). */
149718
151397
  onMuxOut(chunk) {
149719
151398
  if (this.cut !== null) return;
149720
151399
  const { sink } = this.input;
149721
151400
  if (!this.begun) {
149722
151401
  this.begun = true;
149723
- sink.begin();
151402
+ sink.begin(clipStreamContentTypeFor(this.audioMuxed));
149724
151403
  }
149725
151404
  const accepted = sink.write(chunk);
149726
151405
  this.bytesOut += chunk.byteLength;
@@ -149746,6 +151425,7 @@ var ClipStreamRun = class {
149746
151425
  detail
149747
151426
  };
149748
151427
  this.stdinGate.dispose();
151428
+ this.audioStdinGate.dispose();
149749
151429
  this.peerGate.dispose();
149750
151430
  this.mux?.kill("SIGKILL");
149751
151431
  this.deps.logger.warn("videoclips: a clip stream was cut", {
@@ -149757,7 +151437,10 @@ var ClipStreamRun = class {
149757
151437
  detail,
149758
151438
  accessUnits: this.accessUnits,
149759
151439
  bytesOut: this.bytesOut,
149760
- begun: this.begun
151440
+ begun: this.begun,
151441
+ audioMuxed: this.audioMuxed,
151442
+ audioFramesIn: this.audioFrames,
151443
+ audioFramesDropped: this.audioFramesDropped
149761
151444
  }
149762
151445
  });
149763
151446
  if (this.begun) this.input.sink.destroy();
@@ -149803,6 +151486,7 @@ var ClipStreamRun = class {
149803
151486
  detail: "no mux after the transfer ended"
149804
151487
  });
149805
151488
  mux.stdin?.end();
151489
+ this.audioIn?.end();
149806
151490
  const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
149807
151491
  const code = await this.muxClosed;
149808
151492
  clearTimeout(flush);
@@ -149828,11 +151512,13 @@ var ClipStreamRun = class {
149828
151512
  decodedSeconds: durationSeconds,
149829
151513
  ...row.inProgress === true ? { inProgress: true } : {}
149830
151514
  });
151515
+ const audio = this.audioReport();
149831
151516
  const meta = {
149832
151517
  clipKey: row.clipKey,
149833
151518
  twin,
149834
151519
  attempt: this.attempt,
149835
151520
  accessUnits: this.accessUnits,
151521
+ audioMuxed: audio.muxed,
149836
151522
  bytesOut: this.bytesOut,
149837
151523
  durationSeconds,
149838
151524
  expectedMs: row.endMs - row.startMs,
@@ -149864,19 +151550,11 @@ var ClipStreamRun = class {
149864
151550
  branch: "stream-served"
149865
151551
  }
149866
151552
  });
149867
- if (this.audioFrames > 0) this.deps.logger.warn("videoclips: a clip stream was served without sound", {
149868
- tags: this.tags,
149869
- meta: {
149870
- clipKey: row.clipKey,
149871
- twin,
149872
- branch: "stream-video-only",
149873
- audioFramesDropped: this.audioFrames
149874
- }
149875
- });
151553
+ this.reportAudio(audio);
149876
151554
  return {
149877
151555
  kind: "served",
149878
151556
  accessUnits: this.accessUnits,
149879
- audioFramesDropped: this.audioFrames,
151557
+ audio,
149880
151558
  bytesOut: this.bytesOut,
149881
151559
  durationSeconds,
149882
151560
  verdict,
@@ -149884,15 +151562,269 @@ var ClipStreamRun = class {
149884
151562
  attempts: this.attempt
149885
151563
  };
149886
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
+ }
149887
151634
  };
149888
151635
  function describe(err) {
149889
151636
  return err instanceof Error ? err.message : String(err);
149890
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
+ }
149891
151823
  //#endregion
149892
151824
  //#region src/clips/clip-stream-route.ts
149893
151825
  function createClipStreamRoute(deps) {
149894
- return async (deviceId, clipKey, sink) => {
149895
- const twin = "sub";
151826
+ return async (target, sink) => {
151827
+ const { deviceId, clipKey, twin, audio } = target;
149896
151828
  const gate = await deps.gate({
149897
151829
  deviceId,
149898
151830
  clipKey,
@@ -149935,11 +151867,28 @@ function createClipStreamRoute(deps) {
149935
151867
  detail: "clip-in-progress: the camera is still writing this file; it has no end to stream to"
149936
151868
  };
149937
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
+ }
149938
151886
  const outcome = await deps.producer.stream({
149939
151887
  device: gate.device,
149940
151888
  row,
149941
- fileName: row.subFileName,
151889
+ fileName,
149942
151890
  twin,
151891
+ audio,
149943
151892
  sink
149944
151893
  });
149945
151894
  switch (outcome.kind) {
@@ -150185,6 +152134,76 @@ function listableClips(rows, window) {
150185
152134
  inProgress: inWindow.length - kept.length
150186
152135
  };
150187
152136
  }
152137
+ function pairTwins(subs, mains) {
152138
+ const datedSubs = subs.filter((f) => f.startTime instanceof Date);
152139
+ const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
152140
+ const claimed = /* @__PURE__ */ new Set();
152141
+ const pairs = [];
152142
+ for (const sub of datedSubs.toSorted((a, b) => startOf(a) - startOf(b))) {
152143
+ const subStart = startOf(sub);
152144
+ let bestIndex = -1;
152145
+ let bestDelta = Number.POSITIVE_INFINITY;
152146
+ for (const [index, candidate] of datedMains.entries()) {
152147
+ if (claimed.has(index)) continue;
152148
+ const delta = Math.abs(startOf(candidate) - subStart);
152149
+ if (delta > 3e3) continue;
152150
+ if (delta < bestDelta) {
152151
+ bestDelta = delta;
152152
+ bestIndex = index;
152153
+ }
152154
+ }
152155
+ if (bestIndex === -1) {
152156
+ pairs.push({ sub });
152157
+ continue;
152158
+ }
152159
+ claimed.add(bestIndex);
152160
+ const main = datedMains[bestIndex];
152161
+ pairs.push(main === void 0 ? { sub } : {
152162
+ sub,
152163
+ main
152164
+ });
152165
+ }
152166
+ return pairs.toSorted((a, b) => startOf(b.sub) - startOf(a.sub));
152167
+ }
152168
+ function startOf(file) {
152169
+ return file.startTime?.getTime() ?? 0;
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
+ }
150188
152207
  //#endregion
150189
152208
  //#region src/clips/clip-identity.ts
150190
152209
  /**
@@ -150390,40 +152409,6 @@ function rankOf(label) {
150390
152409
  const at = CLASS_RANK.indexOf(label);
150391
152410
  return at === -1 ? CLASS_RANK.length : at;
150392
152411
  }
150393
- function pairTwins(subs, mains) {
150394
- const datedSubs = subs.filter((f) => f.startTime instanceof Date);
150395
- const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
150396
- const claimed = /* @__PURE__ */ new Set();
150397
- const pairs = [];
150398
- for (const sub of datedSubs.toSorted((a, b) => startOf(a) - startOf(b))) {
150399
- const subStart = startOf(sub);
150400
- let bestIndex = -1;
150401
- let bestDelta = Number.POSITIVE_INFINITY;
150402
- for (const [index, candidate] of datedMains.entries()) {
150403
- if (claimed.has(index)) continue;
150404
- const delta = Math.abs(startOf(candidate) - subStart);
150405
- if (delta > 3e3) continue;
150406
- if (delta < bestDelta) {
150407
- bestDelta = delta;
150408
- bestIndex = index;
150409
- }
150410
- }
150411
- if (bestIndex === -1) {
150412
- pairs.push({ sub });
150413
- continue;
150414
- }
150415
- claimed.add(bestIndex);
150416
- const main = datedMains[bestIndex];
150417
- pairs.push(main === void 0 ? { sub } : {
150418
- sub,
150419
- main
150420
- });
150421
- }
150422
- return pairs.toSorted((a, b) => startOf(b.sub) - startOf(a.sub));
150423
- }
150424
- function startOf(file) {
150425
- return file.startTime?.getTime() ?? 0;
150426
- }
150427
152412
  var partsCache = /* @__PURE__ */ new Map();
150428
152413
  function formatterFor$1(timeZone) {
150429
152414
  const cached = partsCache.get(timeZone);
@@ -151125,14 +153110,17 @@ function createReolinkVideoclipsProvider(deps) {
151125
153110
  if (device === null) throw new Error(`videoclips: device ${deviceId} is not a Reolink camera`);
151126
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.`);
151127
153112
  const row = rowsByKey.get(`${deviceId}|${clipKeyFor(parsed.channel, parsed.fileName)}`) ?? null;
151128
- const wantsMain = profile === "high" && row?.mainFileName !== void 0;
151129
- 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;
151130
153118
  const prepared = await deps.preparePlayback({
151131
153119
  device,
151132
153120
  row,
151133
153121
  fileName,
151134
153122
  clipKey: clipKeyFor(parsed.channel, parsed.fileName),
151135
- profile: wantsMain ? "high" : "low"
153123
+ profile: chosen?.served ?? "low"
151136
153124
  });
151137
153125
  return {
151138
153126
  playbackUrl: prepared.playbackUrl,
@@ -151193,8 +153181,8 @@ function createReolinkVideoclipsProvider(deps) {
151193
153181
  const row = rowsByKey.get(`${deviceId}|${clipKey}`);
151194
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 });
151195
153183
  if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written, so it has no end to export`, { clipKey });
151196
- const mainFileName = profile === "high" ? row.mainFileName : void 0;
151197
- 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", {
151198
153186
  tags,
151199
153187
  meta: {
151200
153188
  clipId,
@@ -151203,13 +153191,13 @@ function createReolinkVideoclipsProvider(deps) {
151203
153191
  asked: profile
151204
153192
  }
151205
153193
  });
151206
- const twin = mainFileName === void 0 ? "sub" : "main";
151207
- const fileName = mainFileName ?? row.subFileName;
153194
+ const twin = chosen.twin;
153195
+ const fileName = chosen.fileName;
151208
153196
  if (fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
151209
153197
  clipKey,
151210
153198
  twin
151211
153199
  });
151212
- const served = twin === "main" ? "high" : "low";
153200
+ const served = chosen.served;
151213
153201
  const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
151214
153202
  const declaredBytes = twin === "main" ? row.mainBytes : row.subBytes;
151215
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`, {
@@ -151253,6 +153241,75 @@ function createReolinkVideoclipsProvider(deps) {
151253
153241
  served,
151254
153242
  ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
151255
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
+ };
151256
153313
  }
151257
153314
  };
151258
153315
  /**
@@ -152166,12 +154223,23 @@ function createClipService(deps) {
152166
154223
  producer: createClipStreamProducer({
152167
154224
  logger: deps.logger,
152168
154225
  spawnMux: (args) => (0, node_child_process.spawn)(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
154226
+ "pipe",
152169
154227
  "pipe",
152170
154228
  "pipe",
152171
154229
  "pipe"
152172
154230
  ] })
152173
154231
  })
152174
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
+ });
152175
154243
  const provider = createReolinkVideoclipsProvider({
152176
154244
  now,
152177
154245
  cache,
@@ -152184,11 +154252,41 @@ function createClipService(deps) {
152184
154252
  served: profile === "high" ? "high" : "low"
152185
154253
  }),
152186
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
+ },
152187
154284
  logger: deps.logger
152188
154285
  });
152189
154286
  return {
152190
154287
  provider,
152191
154288
  cache,
154289
+ disposeClipStreamDial: () => streamDial.dispose(),
152192
154290
  refreshCatalog: async (deviceId) => {
152193
154291
  const until = now();
152194
154292
  await provider.listClips({
@@ -166224,6 +168322,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
166224
168322
  this.clipRefreshTimer = null;
166225
168323
  }
166226
168324
  this.logChannels?.stop();
168325
+ await this.clipService?.disposeClipStreamDial().catch(() => {});
166227
168326
  await this.emailPushServer?.stop().catch(() => {});
166228
168327
  await this.autodetectCache.dispose();
166229
168328
  }