@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.mjs CHANGED
@@ -9,6 +9,7 @@ import path, { dirname, join } from "path";
9
9
  import * as crypto$7 from "crypto";
10
10
  import crypto$1, { createHash as createHash$1, randomUUID } from "crypto";
11
11
  import { execFile, spawn } from "child_process";
12
+ import { createServer } from "node:http";
12
13
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
13
14
  import { join as join$1 } from "node:path";
14
15
  import { PassThrough } from "stream";
@@ -5385,7 +5386,7 @@ var ZodIssueCode = {
5385
5386
  var ZodFirstPartyTypeKind;
5386
5387
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5387
5388
  //#endregion
5388
- //#region ../types/dist/sleep-CUmLTud7.mjs
5389
+ //#region ../types/dist/sleep-De1BvmWo.mjs
5389
5390
  /**
5390
5391
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5391
5392
  * window to float samples (D455).
@@ -7854,10 +7855,20 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7854
7855
  var CameraSwitchUnavailableReasonSchema = _enum([
7855
7856
  "no-provider",
7856
7857
  "source-unreachable",
7857
- "not-configured",
7858
7858
  "device-disabled"
7859
7859
  ]);
7860
7860
  /**
7861
+ * Something true about a switch that is nonetheless OFFERED.
7862
+ *
7863
+ * The distinction from {@link CameraSwitchUnavailableReasonSchema} is the
7864
+ * whole point: a reason replaces the control, a warning travels WITH it. The
7865
+ * privacy mask taught us the difference — greying the one button that starts
7866
+ * the feature teaches the operator the feature is broken, while a live toggle
7867
+ * that flips nothing teaches them the switch is broken. Neither is true; the
7868
+ * honest surface is a working toggle that says what is missing.
7869
+ */
7870
+ var CameraSwitchWarningSchema = _enum(["no-privacy-zones"]);
7871
+ /**
7861
7872
  * One switch, resolved for one camera.
7862
7873
  *
7863
7874
  * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
@@ -7876,6 +7887,11 @@ var CameraSwitchSchema = object({
7876
7887
  /** False = do not render a control. `unavailableReason` says why. */
7877
7888
  available: boolean(),
7878
7889
  unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7890
+ /**
7891
+ * Present on an AVAILABLE switch only. Rendered beside the live control;
7892
+ * never a substitute for one.
7893
+ */
7894
+ warning: CameraSwitchWarningSchema.optional(),
7879
7895
  /** Current state. Meaningless when `available` is false — read it as `true`. */
7880
7896
  enabled: boolean(),
7881
7897
  authority: CameraSwitchAuthoritySchema
@@ -25139,6 +25155,15 @@ var CLIP_STILL_REASON_HEADER = "x-camstack-reason";
25139
25155
  var CLIP_STILL_REASON_CODE_HEADER = "x-camstack-reason-code";
25140
25156
  /** One {@link ClipStillDispositionSchema} token, alone. */
25141
25157
  var CLIP_STILL_DISPOSITION_HEADER = "x-camstack-disposition";
25158
+ /**
25159
+ * The clip STREAM route's own statement of what it is (D597): the value is
25160
+ * `forward-only`. A producer writes it on the 200; a consumer that dials a
25161
+ * URL and does not read it back refuses to play, because a progressive file
25162
+ * through a forward-only feeder looks like it worked until the first seek.
25163
+ * Named here because BOTH the provider and the broker spell it, and two
25164
+ * copies of a header name is how one of them stops being checked.
25165
+ */
25166
+ var CLIP_STREAM_TRANSPORT_HEADER = "x-camstack-clip-transport";
25142
25167
  var ClipSchema = object({
25143
25168
  /** Opaque, provider-namespaced id. The default provider encodes the time
25144
25169
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -25424,6 +25449,82 @@ var ClipBytesSchema = object({
25424
25449
  */
25425
25450
  durationMs: number().positive().optional()
25426
25451
  });
25452
+ /**
25453
+ * Where a clip's STREAM can be dialled (D597) — the answer to
25454
+ * {@link videoclipsCapability.methods.dialClipStream}.
25455
+ *
25456
+ * `url` is plain `http://` on the provider's own token-gated listener, one
25457
+ * shot, short-lived. It is NOT the hub-proxied `/addon/<id>/clip-stream/…`
25458
+ * path: that plane takes only a user credential, which an addon does not
25459
+ * hold, and the framework offers no addon→addon byte transport — the one
25460
+ * precedent is the broker's own restream URLs (`getStreamWithCodec`), which
25461
+ * a consumer dials directly, and this is that precedent one addon over.
25462
+ */
25463
+ /**
25464
+ * The audio a clip stream can be asked for, and the audio it carries.
25465
+ *
25466
+ * The camera stores AAC and no WebRTC session speaks it, so a stream that
25467
+ * carries sound carries it TRANSCODED, muxed as a second track beside the
25468
+ * video by the one ffmpeg the provider already runs (D600). `opus` is
25469
+ * 48 kHz mono, `pcmu` is 8 kHz µ-law — the two codecs a broker session
25470
+ * negotiates — and `none` is a stream asked for, or served, without sound.
25471
+ */
25472
+ var ClipStreamAudioSchema = _enum([
25473
+ "opus",
25474
+ "pcmu",
25475
+ "none"
25476
+ ]);
25477
+ /**
25478
+ * Why a stream that was ASKED for sound is not carrying it (D600).
25479
+ *
25480
+ * Only ever set beside `servedAudio: 'none'` after a non-`none` ask. Absent
25481
+ * means the ask was honoured, or there was no ask.
25482
+ *
25483
+ * - `not-requested` — the caller asked for `none`.
25484
+ * - `unsupported-ask` — the provider cannot produce that codec.
25485
+ *
25486
+ * Whether the RECORDING has sound at all is not knowable here: the camera
25487
+ * declares no audio track before its first frame arrives. That answer is in
25488
+ * the stream itself — an fMP4 with no audio track — and the broker reads it
25489
+ * there. A dial that says `opus` promises the codec, never the content.
25490
+ */
25491
+ var ClipStreamAudioReasonSchema = _enum(["not-requested", "unsupported-ask"]);
25492
+ var ClipStreamDialSchema = object({
25493
+ url: string().min(1),
25494
+ /** After this the token is gone and the URL answers 404 `dial-expired`. */
25495
+ expiresAtMs: number(),
25496
+ /** What the stream IS — a consumer that cannot honour it must not dial. */
25497
+ transport: literal("forward-only"),
25498
+ /**
25499
+ * Which twin the stream ACTUALLY carries — not what was asked for.
25500
+ *
25501
+ * The stream serves every profile (D599), and a recording whose main twin
25502
+ * does not exist is streamed as its sub copy, which this field reports as
25503
+ * `low`. That is the whole reason the dial no longer refuses `high`:
25504
+ * stating what was served is what makes accepting honest, where a silent
25505
+ * substitution would be the 33x lie D576 forbids. Stated rather than
25506
+ * assumed, the way {@link ClipPlaybackSchema.served} is (D549 15).
25507
+ */
25508
+ served: CamProfileSchema,
25509
+ /**
25510
+ * Which audio the stream will ACTUALLY carry — `served`'s companion for
25511
+ * sound, and the same reasoning (D599, D600).
25512
+ *
25513
+ * The broker asks for the codec its WebRTC session negotiated, and the
25514
+ * provider either muxes that codec or carries none. Stating it is what
25515
+ * lets the broker wire the matching sink before the first byte, and what
25516
+ * keeps a session from being handed a codec it cannot send — the audio
25517
+ * shape of the substitution `served` exists to prevent.
25518
+ *
25519
+ * It is a promise about the CODEC, not about the content: a recording with
25520
+ * no microphone track answers `opus` here and then arrives with no audio
25521
+ * track at all. That second answer is in the stream, and the broker reads
25522
+ * it from the `moov`.
25523
+ */
25524
+ servedAudio: ClipStreamAudioSchema,
25525
+ /** Why `servedAudio` is `none` although sound was asked for. */
25526
+ audioReason: ClipStreamAudioReasonSchema.optional()
25527
+ });
25427
25528
  var ClipSourceAvailabilitySchema = object({
25428
25529
  state: _enum([
25429
25530
  "ok",
@@ -25674,6 +25775,44 @@ var videoclipsCapability = {
25674
25775
  }), ClipBytesSchema, {
25675
25776
  kind: "query",
25676
25777
  auth: "protected"
25778
+ }),
25779
+ /**
25780
+ * Where this clip's STREAM can be dialled (D597) — the forward-only
25781
+ * fMP4 the provider writes from the first muxed byte, for the broker to
25782
+ * play through the same WebRTC session as recorded footage, with the
25783
+ * first frame at the first fragment instead of after the last byte.
25784
+ *
25785
+ * Nothing is fetched here: the answer is a one-shot URL on the
25786
+ * provider's own listener, and the camera is touched when it is dialled.
25787
+ * The gates the file path runs (`readClipBytes`) run on the dial too —
25788
+ * the id, the row, a clip still being written — and refuse in the same
25789
+ * vocabulary, THROWN with `clipExportFailure(<token>, <prose>)`. Every
25790
+ * profile is served (D599) and the answer states which twin it carries;
25791
+ * `high` is not refused. Optional on the provider for the same reason
25792
+ * `readClipBytes` is: a source with no camera socket behind it (the
25793
+ * recorder's analytics window, a HomeKit tee) has no stream.
25794
+ */
25795
+ dialClipStream: optionalMethod(object({
25796
+ deviceId: number(),
25797
+ clipId: string().min(1),
25798
+ /** WHICH provider holds the camera — see `readClipBytes.provider`. */
25799
+ provider: string().min(1),
25800
+ /** Which twin — `high` → the main file, everything else the sub (D599). */
25801
+ profile: CamProfileSchema.optional(),
25802
+ /**
25803
+ * Which audio codec the caller's session negotiated, so the provider
25804
+ * can mux it (D600). The ask travels on the DIAL because the mux is
25805
+ * spawned once, before the first byte, and cannot gain a track later.
25806
+ *
25807
+ * ABSENT IS `none`, never a guess: a caller that does not state an
25808
+ * ask is served the video-only stream that shipped first, so a broker
25809
+ * older than this field keeps working unchanged (D382 — an unstated
25810
+ * ask is zero).
25811
+ */
25812
+ audio: ClipStreamAudioSchema.optional()
25813
+ }), ClipStreamDialSchema, {
25814
+ kind: "query",
25815
+ auth: "protected"
25677
25816
  })
25678
25817
  }
25679
25818
  };
@@ -38012,6 +38151,1329 @@ var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
38012
38151
  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;
38013
38152
  new Set(Object.values(DeviceType));
38014
38153
  DeviceFeature.BatteryOperated;
38154
+ /**
38155
+ * The vocabulary, in the order a picker offers it.
38156
+ *
38157
+ * Adding a key here is not enough to make it pickable: both glyph tables must
38158
+ * gain it too, and their tests fail until they do. That is the intended
38159
+ * friction — it is the only thing that keeps the two surfaces honest.
38160
+ */
38161
+ var DISPLAY_ICON_VOCABULARY = [
38162
+ {
38163
+ key: "camera",
38164
+ group: "device",
38165
+ synonyms: [
38166
+ "telecamera",
38167
+ "videocamera",
38168
+ "camera",
38169
+ "cam"
38170
+ ]
38171
+ },
38172
+ {
38173
+ key: "network",
38174
+ group: "device",
38175
+ synonyms: [
38176
+ "rete",
38177
+ "network",
38178
+ "lan"
38179
+ ]
38180
+ },
38181
+ {
38182
+ key: "toggle-left",
38183
+ group: "device",
38184
+ synonyms: [
38185
+ "interruttore",
38186
+ "switch",
38187
+ "toggle"
38188
+ ]
38189
+ },
38190
+ {
38191
+ key: "toggle-right",
38192
+ group: "device",
38193
+ synonyms: [
38194
+ "interruttore",
38195
+ "switch",
38196
+ "toggle",
38197
+ "acceso"
38198
+ ]
38199
+ },
38200
+ {
38201
+ key: "circle-dot",
38202
+ group: "device",
38203
+ synonyms: [
38204
+ "pulsante",
38205
+ "bottone",
38206
+ "button",
38207
+ "dot"
38208
+ ]
38209
+ },
38210
+ {
38211
+ key: "refresh-cw",
38212
+ group: "device",
38213
+ synonyms: [
38214
+ "aggiorna",
38215
+ "ricarica",
38216
+ "refresh",
38217
+ "reload",
38218
+ "ciclo"
38219
+ ]
38220
+ },
38221
+ {
38222
+ key: "box",
38223
+ group: "device",
38224
+ synonyms: [
38225
+ "scatola",
38226
+ "box",
38227
+ "contenitore"
38228
+ ]
38229
+ },
38230
+ {
38231
+ key: "scroll-text",
38232
+ group: "device",
38233
+ synonyms: [
38234
+ "registro",
38235
+ "log",
38236
+ "testo"
38237
+ ]
38238
+ },
38239
+ {
38240
+ key: "workflow",
38241
+ group: "device",
38242
+ synonyms: [
38243
+ "automazione",
38244
+ "flusso",
38245
+ "workflow",
38246
+ "scenario"
38247
+ ]
38248
+ },
38249
+ {
38250
+ key: "lock",
38251
+ group: "device",
38252
+ synonyms: [
38253
+ "serratura",
38254
+ "chiave",
38255
+ "lucchetto",
38256
+ "chiuso",
38257
+ "locked"
38258
+ ]
38259
+ },
38260
+ {
38261
+ key: "lock-open",
38262
+ group: "device",
38263
+ synonyms: [
38264
+ "serratura",
38265
+ "aperta",
38266
+ "sbloccato",
38267
+ "unlocked"
38268
+ ]
38269
+ },
38270
+ {
38271
+ key: "blinds",
38272
+ group: "device",
38273
+ synonyms: [
38274
+ "tapparella",
38275
+ "persiana",
38276
+ "serranda",
38277
+ "avvolgibile",
38278
+ "shutter",
38279
+ "blind",
38280
+ "veneziana"
38281
+ ]
38282
+ },
38283
+ {
38284
+ key: "air-vent",
38285
+ group: "device",
38286
+ synonyms: [
38287
+ "bocchetta",
38288
+ "aria",
38289
+ "ventilazione",
38290
+ "condizionatore",
38291
+ "vent"
38292
+ ]
38293
+ },
38294
+ {
38295
+ key: "monitor-play",
38296
+ group: "device",
38297
+ synonyms: [
38298
+ "schermo",
38299
+ "riproduzione",
38300
+ "player",
38301
+ "display"
38302
+ ]
38303
+ },
38304
+ {
38305
+ key: "sliders-horizontal",
38306
+ group: "device",
38307
+ synonyms: [
38308
+ "regolazione",
38309
+ "cursori",
38310
+ "impostazioni",
38311
+ "slider"
38312
+ ]
38313
+ },
38314
+ {
38315
+ key: "map-pin",
38316
+ group: "device",
38317
+ synonyms: [
38318
+ "posizione",
38319
+ "luogo",
38320
+ "mappa",
38321
+ "pin"
38322
+ ]
38323
+ },
38324
+ {
38325
+ key: "bot",
38326
+ group: "device",
38327
+ synonyms: [
38328
+ "robot",
38329
+ "aspirapolvere",
38330
+ "bot"
38331
+ ]
38332
+ },
38333
+ {
38334
+ key: "tractor",
38335
+ group: "device",
38336
+ synonyms: [
38337
+ "trattore",
38338
+ "tosaerba",
38339
+ "giardino"
38340
+ ]
38341
+ },
38342
+ {
38343
+ key: "image",
38344
+ group: "device",
38345
+ synonyms: [
38346
+ "immagine",
38347
+ "foto",
38348
+ "snapshot"
38349
+ ]
38350
+ },
38351
+ {
38352
+ key: "bone",
38353
+ group: "device",
38354
+ synonyms: ["osso", "cane"]
38355
+ },
38356
+ {
38357
+ key: "door-closed",
38358
+ group: "device",
38359
+ synonyms: [
38360
+ "porta",
38361
+ "chiusa",
38362
+ "contatto"
38363
+ ]
38364
+ },
38365
+ {
38366
+ key: "door-open",
38367
+ group: "device",
38368
+ synonyms: [
38369
+ "porta",
38370
+ "aperta",
38371
+ "contatto",
38372
+ "ingresso",
38373
+ "uscita"
38374
+ ]
38375
+ },
38376
+ {
38377
+ key: "crosshair",
38378
+ group: "device",
38379
+ synonyms: [
38380
+ "mirino",
38381
+ "puntamento",
38382
+ "centro"
38383
+ ]
38384
+ },
38385
+ {
38386
+ key: "eye",
38387
+ group: "device",
38388
+ synonyms: [
38389
+ "occhio",
38390
+ "visibile",
38391
+ "visto"
38392
+ ]
38393
+ },
38394
+ {
38395
+ key: "eye-off",
38396
+ group: "device",
38397
+ synonyms: [
38398
+ "privacy",
38399
+ "nascosto",
38400
+ "oscurato",
38401
+ "mascherato",
38402
+ "invisibile"
38403
+ ]
38404
+ },
38405
+ {
38406
+ key: "smartphone",
38407
+ group: "device",
38408
+ synonyms: [
38409
+ "telefono",
38410
+ "cellulare",
38411
+ "smartphone"
38412
+ ]
38413
+ },
38414
+ {
38415
+ key: "phone",
38416
+ group: "device",
38417
+ synonyms: ["telefono", "chiamata"]
38418
+ },
38419
+ {
38420
+ key: "tablet",
38421
+ group: "device",
38422
+ synonyms: ["tablet", "ipad"]
38423
+ },
38424
+ {
38425
+ key: "laptop",
38426
+ group: "device",
38427
+ synonyms: [
38428
+ "portatile",
38429
+ "laptop",
38430
+ "notebook"
38431
+ ]
38432
+ },
38433
+ {
38434
+ key: "monitor",
38435
+ group: "device",
38436
+ synonyms: ["monitor", "schermo"]
38437
+ },
38438
+ {
38439
+ key: "tv",
38440
+ group: "device",
38441
+ synonyms: ["televisore", "tv"]
38442
+ },
38443
+ {
38444
+ key: "speaker",
38445
+ group: "device",
38446
+ synonyms: [
38447
+ "altoparlante",
38448
+ "cassa",
38449
+ "speaker"
38450
+ ]
38451
+ },
38452
+ {
38453
+ key: "printer",
38454
+ group: "device",
38455
+ synonyms: ["stampante"]
38456
+ },
38457
+ {
38458
+ key: "projector",
38459
+ group: "device",
38460
+ synonyms: ["proiettore"]
38461
+ },
38462
+ {
38463
+ key: "gamepad-2",
38464
+ group: "device",
38465
+ synonyms: [
38466
+ "console",
38467
+ "gioco",
38468
+ "joystick"
38469
+ ]
38470
+ },
38471
+ {
38472
+ key: "radio",
38473
+ group: "device",
38474
+ synonyms: ["radio"]
38475
+ },
38476
+ {
38477
+ key: "router",
38478
+ group: "device",
38479
+ synonyms: ["router", "modem"]
38480
+ },
38481
+ {
38482
+ key: "bluetooth",
38483
+ group: "device",
38484
+ synonyms: ["bluetooth"]
38485
+ },
38486
+ {
38487
+ key: "cpu",
38488
+ group: "device",
38489
+ synonyms: [
38490
+ "processore",
38491
+ "cpu",
38492
+ "chip",
38493
+ "dispositivo"
38494
+ ]
38495
+ },
38496
+ {
38497
+ key: "hard-drive",
38498
+ group: "device",
38499
+ synonyms: [
38500
+ "disco",
38501
+ "hdd",
38502
+ "archiviazione",
38503
+ "storage"
38504
+ ]
38505
+ },
38506
+ {
38507
+ key: "server",
38508
+ group: "device",
38509
+ synonyms: ["server", "nas"]
38510
+ },
38511
+ {
38512
+ key: "mic",
38513
+ group: "device",
38514
+ synonyms: ["microfono", "audio"]
38515
+ },
38516
+ {
38517
+ key: "mic-off",
38518
+ group: "device",
38519
+ synonyms: [
38520
+ "microfono",
38521
+ "spento",
38522
+ "muto"
38523
+ ]
38524
+ },
38525
+ {
38526
+ key: "video",
38527
+ group: "device",
38528
+ synonyms: ["video"]
38529
+ },
38530
+ {
38531
+ key: "video-off",
38532
+ group: "device",
38533
+ synonyms: ["video", "spento"]
38534
+ },
38535
+ {
38536
+ key: "music",
38537
+ group: "device",
38538
+ synonyms: ["musica"]
38539
+ },
38540
+ {
38541
+ key: "plug",
38542
+ group: "device",
38543
+ synonyms: [
38544
+ "presa",
38545
+ "spina",
38546
+ "plug"
38547
+ ]
38548
+ },
38549
+ {
38550
+ key: "plug-zap",
38551
+ group: "device",
38552
+ synonyms: ["presa", "alimentata"]
38553
+ },
38554
+ {
38555
+ key: "power",
38556
+ group: "device",
38557
+ synonyms: [
38558
+ "accensione",
38559
+ "alimentazione",
38560
+ "power"
38561
+ ]
38562
+ },
38563
+ {
38564
+ key: "power-off",
38565
+ group: "device",
38566
+ synonyms: ["spegnimento", "spento"]
38567
+ },
38568
+ {
38569
+ key: "cable",
38570
+ group: "device",
38571
+ synonyms: ["cavo"]
38572
+ },
38573
+ {
38574
+ key: "battery",
38575
+ group: "device",
38576
+ synonyms: ["batteria"]
38577
+ },
38578
+ {
38579
+ key: "battery-charging",
38580
+ group: "device",
38581
+ synonyms: [
38582
+ "batteria",
38583
+ "carica",
38584
+ "ricarica"
38585
+ ]
38586
+ },
38587
+ {
38588
+ key: "battery-full",
38589
+ group: "device",
38590
+ synonyms: [
38591
+ "batteria",
38592
+ "carica",
38593
+ "piena"
38594
+ ]
38595
+ },
38596
+ {
38597
+ key: "battery-low",
38598
+ group: "device",
38599
+ synonyms: ["batteria", "scarica"]
38600
+ },
38601
+ {
38602
+ key: "key",
38603
+ group: "device",
38604
+ synonyms: ["chiave"]
38605
+ },
38606
+ {
38607
+ key: "key-round",
38608
+ group: "device",
38609
+ synonyms: ["chiave", "tonda"]
38610
+ },
38611
+ {
38612
+ key: "mailbox",
38613
+ group: "device",
38614
+ synonyms: [
38615
+ "cassetta",
38616
+ "postale",
38617
+ "posta"
38618
+ ]
38619
+ },
38620
+ {
38621
+ key: "package",
38622
+ group: "device",
38623
+ synonyms: ["pacco", "consegna"]
38624
+ },
38625
+ {
38626
+ key: "shield",
38627
+ group: "device",
38628
+ synonyms: [
38629
+ "scudo",
38630
+ "protezione",
38631
+ "sicurezza",
38632
+ "allarme"
38633
+ ]
38634
+ },
38635
+ {
38636
+ key: "shield-check",
38637
+ group: "device",
38638
+ synonyms: [
38639
+ "protetto",
38640
+ "sicuro",
38641
+ "armato"
38642
+ ]
38643
+ },
38644
+ {
38645
+ key: "shield-off",
38646
+ group: "device",
38647
+ synonyms: [
38648
+ "protezione",
38649
+ "disattivata",
38650
+ "disarmato"
38651
+ ]
38652
+ },
38653
+ {
38654
+ key: "briefcase",
38655
+ group: "device",
38656
+ synonyms: [
38657
+ "ufficio",
38658
+ "lavoro",
38659
+ "valigetta",
38660
+ "studio"
38661
+ ]
38662
+ },
38663
+ {
38664
+ key: "lightbulb",
38665
+ group: "lighting",
38666
+ synonyms: [
38667
+ "luce",
38668
+ "lampadina",
38669
+ "lampada",
38670
+ "light"
38671
+ ]
38672
+ },
38673
+ {
38674
+ key: "lamp",
38675
+ group: "lighting",
38676
+ synonyms: ["lampada", "abatjour"]
38677
+ },
38678
+ {
38679
+ key: "lamp-ceiling",
38680
+ group: "lighting",
38681
+ synonyms: [
38682
+ "plafoniera",
38683
+ "lampadario",
38684
+ "luce",
38685
+ "soffitto"
38686
+ ]
38687
+ },
38688
+ {
38689
+ key: "lamp-desk",
38690
+ group: "lighting",
38691
+ synonyms: [
38692
+ "lampada",
38693
+ "scrivania",
38694
+ "abatjour"
38695
+ ]
38696
+ },
38697
+ {
38698
+ key: "lamp-floor",
38699
+ group: "lighting",
38700
+ synonyms: [
38701
+ "piantana",
38702
+ "lampada",
38703
+ "terra"
38704
+ ]
38705
+ },
38706
+ {
38707
+ key: "flashlight",
38708
+ group: "lighting",
38709
+ synonyms: [
38710
+ "faretto",
38711
+ "torcia",
38712
+ "spot",
38713
+ "faro"
38714
+ ]
38715
+ },
38716
+ {
38717
+ key: "heater",
38718
+ group: "lighting",
38719
+ synonyms: [
38720
+ "riscaldamento",
38721
+ "termosifone",
38722
+ "stufa",
38723
+ "calore"
38724
+ ]
38725
+ },
38726
+ {
38727
+ key: "snowflake",
38728
+ group: "lighting",
38729
+ synonyms: [
38730
+ "freddo",
38731
+ "raffrescamento",
38732
+ "aria",
38733
+ "condizionata",
38734
+ "neve"
38735
+ ]
38736
+ },
38737
+ {
38738
+ key: "thermometer-sun",
38739
+ group: "lighting",
38740
+ synonyms: [
38741
+ "caldo",
38742
+ "temperatura",
38743
+ "alta"
38744
+ ]
38745
+ },
38746
+ {
38747
+ key: "thermometer-snowflake",
38748
+ group: "lighting",
38749
+ synonyms: [
38750
+ "freddo",
38751
+ "temperatura",
38752
+ "bassa"
38753
+ ]
38754
+ },
38755
+ {
38756
+ key: "refrigerator",
38757
+ group: "lighting",
38758
+ synonyms: ["frigorifero", "frigo"]
38759
+ },
38760
+ {
38761
+ key: "microwave",
38762
+ group: "lighting",
38763
+ synonyms: ["microonde", "forno"]
38764
+ },
38765
+ {
38766
+ key: "washing-machine",
38767
+ group: "lighting",
38768
+ synonyms: [
38769
+ "lavatrice",
38770
+ "lavastoviglie",
38771
+ "asciugatrice"
38772
+ ]
38773
+ },
38774
+ {
38775
+ key: "cooking-pot",
38776
+ group: "lighting",
38777
+ synonyms: [
38778
+ "pentola",
38779
+ "cucina",
38780
+ "fornello"
38781
+ ]
38782
+ },
38783
+ {
38784
+ key: "utensils",
38785
+ group: "lighting",
38786
+ synonyms: [
38787
+ "cucina",
38788
+ "posate",
38789
+ "sala",
38790
+ "pranzo"
38791
+ ]
38792
+ },
38793
+ {
38794
+ key: "thermometer",
38795
+ group: "sensing",
38796
+ synonyms: [
38797
+ "temperatura",
38798
+ "termometro",
38799
+ "termostato"
38800
+ ]
38801
+ },
38802
+ {
38803
+ key: "droplets",
38804
+ group: "sensing",
38805
+ synonyms: [
38806
+ "umidita",
38807
+ "acqua",
38808
+ "allagamento",
38809
+ "perdita"
38810
+ ]
38811
+ },
38812
+ {
38813
+ key: "droplet",
38814
+ group: "sensing",
38815
+ synonyms: [
38816
+ "goccia",
38817
+ "acqua",
38818
+ "perdita",
38819
+ "allagamento"
38820
+ ]
38821
+ },
38822
+ {
38823
+ key: "gauge",
38824
+ group: "sensing",
38825
+ synonyms: [
38826
+ "misura",
38827
+ "livello",
38828
+ "pressione",
38829
+ "indicatore"
38830
+ ]
38831
+ },
38832
+ {
38833
+ key: "gauge2",
38834
+ group: "sensing",
38835
+ synonyms: [
38836
+ "misura",
38837
+ "livello",
38838
+ "pressione",
38839
+ "indicatore"
38840
+ ],
38841
+ aliasOf: "gauge"
38842
+ },
38843
+ {
38844
+ key: "sun",
38845
+ group: "sensing",
38846
+ synonyms: [
38847
+ "sole",
38848
+ "luminosita",
38849
+ "giorno",
38850
+ "esterno"
38851
+ ]
38852
+ },
38853
+ {
38854
+ key: "sun-medium",
38855
+ group: "sensing",
38856
+ synonyms: ["sole", "luminosita"]
38857
+ },
38858
+ {
38859
+ key: "sunrise",
38860
+ group: "sensing",
38861
+ synonyms: ["alba"]
38862
+ },
38863
+ {
38864
+ key: "moon",
38865
+ group: "sensing",
38866
+ synonyms: [
38867
+ "notte",
38868
+ "luna",
38869
+ "notturna",
38870
+ "nightvision"
38871
+ ]
38872
+ },
38873
+ {
38874
+ key: "wind",
38875
+ group: "sensing",
38876
+ synonyms: [
38877
+ "vento",
38878
+ "aria",
38879
+ "gas",
38880
+ "monossido",
38881
+ "co"
38882
+ ]
38883
+ },
38884
+ {
38885
+ key: "cloud",
38886
+ group: "sensing",
38887
+ synonyms: ["nuvola", "nuvoloso"]
38888
+ },
38889
+ {
38890
+ key: "cloud-rain",
38891
+ group: "sensing",
38892
+ synonyms: ["pioggia"]
38893
+ },
38894
+ {
38895
+ key: "cloud-sun",
38896
+ group: "sensing",
38897
+ synonyms: [
38898
+ "variabile",
38899
+ "sole",
38900
+ "nuvole"
38901
+ ]
38902
+ },
38903
+ {
38904
+ key: "waves",
38905
+ group: "sensing",
38906
+ synonyms: [
38907
+ "onde",
38908
+ "acqua",
38909
+ "vibrazione"
38910
+ ]
38911
+ },
38912
+ {
38913
+ key: "sprout",
38914
+ group: "sensing",
38915
+ synonyms: [
38916
+ "irrigazione",
38917
+ "pianta",
38918
+ "germoglio",
38919
+ "orto"
38920
+ ]
38921
+ },
38922
+ {
38923
+ key: "leaf",
38924
+ group: "sensing",
38925
+ synonyms: [
38926
+ "foglia",
38927
+ "pianta",
38928
+ "verde"
38929
+ ]
38930
+ },
38931
+ {
38932
+ key: "flower",
38933
+ group: "sensing",
38934
+ synonyms: ["fiore", "pianta"]
38935
+ },
38936
+ {
38937
+ key: "zap",
38938
+ group: "sensing",
38939
+ synonyms: [
38940
+ "energia",
38941
+ "corrente",
38942
+ "consumo",
38943
+ "watt",
38944
+ "elettricita"
38945
+ ]
38946
+ },
38947
+ {
38948
+ key: "flame",
38949
+ group: "sensing",
38950
+ synonyms: [
38951
+ "fiamma",
38952
+ "gas",
38953
+ "fuoco",
38954
+ "incendio"
38955
+ ]
38956
+ },
38957
+ {
38958
+ key: "bell",
38959
+ group: "sensing",
38960
+ synonyms: [
38961
+ "campanello",
38962
+ "suoneria",
38963
+ "notifica",
38964
+ "bell",
38965
+ "chime"
38966
+ ]
38967
+ },
38968
+ {
38969
+ key: "bell-ring",
38970
+ group: "sensing",
38971
+ synonyms: [
38972
+ "campanello",
38973
+ "suona",
38974
+ "doorbell",
38975
+ "notifica"
38976
+ ]
38977
+ },
38978
+ {
38979
+ key: "bell-off",
38980
+ group: "sensing",
38981
+ synonyms: [
38982
+ "silenzioso",
38983
+ "notifiche",
38984
+ "disattivate",
38985
+ "muto"
38986
+ ]
38987
+ },
38988
+ {
38989
+ key: "siren",
38990
+ group: "sensing",
38991
+ synonyms: ["sirena", "allarme"]
38992
+ },
38993
+ {
38994
+ key: "radar",
38995
+ group: "sensing",
38996
+ synonyms: [
38997
+ "movimento",
38998
+ "presenza",
38999
+ "pir",
39000
+ "rilevamento",
39001
+ "radar"
39002
+ ]
39003
+ },
39004
+ {
39005
+ key: "activity",
39006
+ group: "sensing",
39007
+ synonyms: [
39008
+ "attivita",
39009
+ "sensore",
39010
+ "generico"
39011
+ ]
39012
+ },
39013
+ {
39014
+ key: "hash",
39015
+ group: "sensing",
39016
+ synonyms: [
39017
+ "numero",
39018
+ "contatore",
39019
+ "valore"
39020
+ ]
39021
+ },
39022
+ {
39023
+ key: "clock",
39024
+ group: "sensing",
39025
+ synonyms: [
39026
+ "orologio",
39027
+ "ora",
39028
+ "tempo"
39029
+ ]
39030
+ },
39031
+ {
39032
+ key: "timer",
39033
+ group: "sensing",
39034
+ synonyms: [
39035
+ "timer",
39036
+ "conto",
39037
+ "rovescia"
39038
+ ]
39039
+ },
39040
+ {
39041
+ key: "calendar",
39042
+ group: "sensing",
39043
+ synonyms: [
39044
+ "calendario",
39045
+ "data",
39046
+ "pianificazione"
39047
+ ]
39048
+ },
39049
+ {
39050
+ key: "pipette",
39051
+ group: "sensing",
39052
+ synonyms: [
39053
+ "qualita",
39054
+ "aria",
39055
+ "misura",
39056
+ "campione"
39057
+ ]
39058
+ },
39059
+ {
39060
+ key: "fan",
39061
+ group: "sensing",
39062
+ synonyms: [
39063
+ "ventilatore",
39064
+ "ventola",
39065
+ "aria"
39066
+ ]
39067
+ },
39068
+ {
39069
+ key: "bath",
39070
+ group: "sensing",
39071
+ synonyms: [
39072
+ "bagno",
39073
+ "vasca",
39074
+ "doccia"
39075
+ ]
39076
+ },
39077
+ {
39078
+ key: "footprints",
39079
+ group: "sensing",
39080
+ synonyms: [
39081
+ "movimento",
39082
+ "passi",
39083
+ "presenza",
39084
+ "motion"
39085
+ ]
39086
+ },
39087
+ {
39088
+ key: "alarm-smoke",
39089
+ group: "sensing",
39090
+ synonyms: [
39091
+ "fumo",
39092
+ "antincendio",
39093
+ "rilevatore"
39094
+ ]
39095
+ },
39096
+ {
39097
+ key: "shield-alert",
39098
+ group: "sensing",
39099
+ synonyms: [
39100
+ "manomissione",
39101
+ "allarme",
39102
+ "tamper",
39103
+ "avviso"
39104
+ ]
39105
+ },
39106
+ {
39107
+ key: "vibrate",
39108
+ group: "sensing",
39109
+ synonyms: [
39110
+ "vibrazione",
39111
+ "urto",
39112
+ "tamper"
39113
+ ]
39114
+ },
39115
+ {
39116
+ key: "wifi",
39117
+ group: "sensing",
39118
+ synonyms: [
39119
+ "wifi",
39120
+ "rete",
39121
+ "segnale"
39122
+ ]
39123
+ },
39124
+ {
39125
+ key: "wifi-off",
39126
+ group: "sensing",
39127
+ synonyms: [
39128
+ "wifi",
39129
+ "assente",
39130
+ "offline",
39131
+ "disconnesso"
39132
+ ]
39133
+ },
39134
+ {
39135
+ key: "volume-2",
39136
+ group: "sensing",
39137
+ synonyms: [
39138
+ "volume",
39139
+ "audio",
39140
+ "altoparlante",
39141
+ "suono"
39142
+ ]
39143
+ },
39144
+ {
39145
+ key: "volume-x",
39146
+ group: "sensing",
39147
+ synonyms: [
39148
+ "muto",
39149
+ "silenziato",
39150
+ "volume",
39151
+ "spento"
39152
+ ]
39153
+ },
39154
+ {
39155
+ key: "heart",
39156
+ group: "sensing",
39157
+ synonyms: [
39158
+ "cuore",
39159
+ "preferito",
39160
+ "salute"
39161
+ ]
39162
+ },
39163
+ {
39164
+ key: "heart-pulse",
39165
+ group: "sensing",
39166
+ synonyms: [
39167
+ "battito",
39168
+ "salute",
39169
+ "monitoraggio"
39170
+ ]
39171
+ },
39172
+ {
39173
+ key: "star",
39174
+ group: "sensing",
39175
+ synonyms: [
39176
+ "preferito",
39177
+ "stella",
39178
+ "importante"
39179
+ ]
39180
+ },
39181
+ {
39182
+ key: "sparkles",
39183
+ group: "sensing",
39184
+ synonyms: [
39185
+ "automatico",
39186
+ "intelligente",
39187
+ "ai",
39188
+ "scena"
39189
+ ]
39190
+ },
39191
+ {
39192
+ key: "house",
39193
+ group: "place",
39194
+ synonyms: [
39195
+ "casa",
39196
+ "abitazione",
39197
+ "home"
39198
+ ]
39199
+ },
39200
+ {
39201
+ key: "sofa",
39202
+ group: "place",
39203
+ synonyms: [
39204
+ "salone",
39205
+ "soggiorno",
39206
+ "divano",
39207
+ "living"
39208
+ ]
39209
+ },
39210
+ {
39211
+ key: "bed-double",
39212
+ group: "place",
39213
+ synonyms: [
39214
+ "camera",
39215
+ "letto",
39216
+ "matrimoniale"
39217
+ ]
39218
+ },
39219
+ {
39220
+ key: "bed",
39221
+ group: "place",
39222
+ synonyms: [
39223
+ "cameretta",
39224
+ "letto",
39225
+ "singolo"
39226
+ ]
39227
+ },
39228
+ {
39229
+ key: "warehouse",
39230
+ group: "place",
39231
+ synonyms: [
39232
+ "magazzino",
39233
+ "capanno",
39234
+ "deposito",
39235
+ "cantina"
39236
+ ]
39237
+ },
39238
+ {
39239
+ key: "trees",
39240
+ group: "place",
39241
+ synonyms: [
39242
+ "giardino",
39243
+ "alberi",
39244
+ "esterno",
39245
+ "parco"
39246
+ ]
39247
+ },
39248
+ {
39249
+ key: "tree-pine",
39250
+ group: "place",
39251
+ synonyms: [
39252
+ "albero",
39253
+ "pino",
39254
+ "giardino"
39255
+ ]
39256
+ },
39257
+ {
39258
+ key: "fence",
39259
+ group: "place",
39260
+ synonyms: [
39261
+ "recinto",
39262
+ "cancello",
39263
+ "staccionata",
39264
+ "perimetro"
39265
+ ]
39266
+ },
39267
+ {
39268
+ key: "store",
39269
+ group: "place",
39270
+ synonyms: [
39271
+ "negozio",
39272
+ "ingresso",
39273
+ "vetrina"
39274
+ ]
39275
+ },
39276
+ {
39277
+ key: "building",
39278
+ group: "place",
39279
+ synonyms: ["edificio", "palazzo"]
39280
+ },
39281
+ {
39282
+ key: "building-2",
39283
+ group: "place",
39284
+ synonyms: [
39285
+ "edificio",
39286
+ "uffici",
39287
+ "condominio"
39288
+ ]
39289
+ },
39290
+ {
39291
+ key: "mountain",
39292
+ group: "place",
39293
+ synonyms: [
39294
+ "montagna",
39295
+ "esterno",
39296
+ "panorama"
39297
+ ]
39298
+ },
39299
+ {
39300
+ key: "umbrella",
39301
+ group: "place",
39302
+ synonyms: [
39303
+ "tenda",
39304
+ "markise",
39305
+ "ombrellone",
39306
+ "pergola",
39307
+ "veranda"
39308
+ ]
39309
+ },
39310
+ {
39311
+ key: "dumbbell",
39312
+ group: "place",
39313
+ synonyms: ["palestra", "attrezzi"]
39314
+ },
39315
+ {
39316
+ key: "wrench",
39317
+ group: "place",
39318
+ synonyms: [
39319
+ "officina",
39320
+ "manutenzione",
39321
+ "attrezzi",
39322
+ "utenze"
39323
+ ]
39324
+ },
39325
+ {
39326
+ key: "hammer",
39327
+ group: "place",
39328
+ synonyms: [
39329
+ "officina",
39330
+ "martello",
39331
+ "lavori"
39332
+ ]
39333
+ },
39334
+ {
39335
+ key: "drill",
39336
+ group: "place",
39337
+ synonyms: [
39338
+ "officina",
39339
+ "trapano",
39340
+ "lavori"
39341
+ ]
39342
+ },
39343
+ {
39344
+ key: "waves-ladder",
39345
+ group: "place",
39346
+ synonyms: ["piscina", "vasca"]
39347
+ },
39348
+ {
39349
+ key: "circle-parking",
39350
+ group: "place",
39351
+ synonyms: [
39352
+ "parcheggio",
39353
+ "posto",
39354
+ "auto",
39355
+ "garage"
39356
+ ]
39357
+ },
39358
+ {
39359
+ key: "route",
39360
+ group: "place",
39361
+ synonyms: [
39362
+ "percorso",
39363
+ "strada",
39364
+ "vialetto",
39365
+ "tragitto"
39366
+ ]
39367
+ },
39368
+ {
39369
+ key: "car",
39370
+ group: "life",
39371
+ synonyms: [
39372
+ "auto",
39373
+ "macchina",
39374
+ "veicolo",
39375
+ "parcheggio"
39376
+ ]
39377
+ },
39378
+ {
39379
+ key: "bike",
39380
+ group: "life",
39381
+ synonyms: [
39382
+ "bicicletta",
39383
+ "bici",
39384
+ "moto"
39385
+ ]
39386
+ },
39387
+ {
39388
+ key: "bus",
39389
+ group: "life",
39390
+ synonyms: ["autobus", "pullman"]
39391
+ },
39392
+ {
39393
+ key: "truck",
39394
+ group: "life",
39395
+ synonyms: [
39396
+ "camion",
39397
+ "furgone",
39398
+ "consegna"
39399
+ ]
39400
+ },
39401
+ {
39402
+ key: "plane",
39403
+ group: "life",
39404
+ synonyms: ["aereo"]
39405
+ },
39406
+ {
39407
+ key: "train-front",
39408
+ group: "life",
39409
+ synonyms: ["treno"]
39410
+ },
39411
+ {
39412
+ key: "sailboat",
39413
+ group: "life",
39414
+ synonyms: ["barca", "imbarcazione"]
39415
+ },
39416
+ {
39417
+ key: "dog",
39418
+ group: "life",
39419
+ synonyms: ["cane", "animale"]
39420
+ },
39421
+ {
39422
+ key: "cat",
39423
+ group: "life",
39424
+ synonyms: ["gatto", "animale"]
39425
+ },
39426
+ {
39427
+ key: "bird",
39428
+ group: "life",
39429
+ synonyms: ["uccello", "animale"]
39430
+ },
39431
+ {
39432
+ key: "paw-print",
39433
+ group: "life",
39434
+ synonyms: [
39435
+ "animale",
39436
+ "zampa",
39437
+ "pet"
39438
+ ]
39439
+ },
39440
+ {
39441
+ key: "baby",
39442
+ group: "life",
39443
+ synonyms: [
39444
+ "neonato",
39445
+ "bimbo",
39446
+ "cameretta",
39447
+ "babymonitor"
39448
+ ]
39449
+ },
39450
+ {
39451
+ key: "user",
39452
+ group: "life",
39453
+ synonyms: ["persona", "utente"]
39454
+ },
39455
+ {
39456
+ key: "users",
39457
+ group: "life",
39458
+ synonyms: [
39459
+ "persone",
39460
+ "gruppo",
39461
+ "famiglia"
39462
+ ]
39463
+ },
39464
+ {
39465
+ key: "person-standing",
39466
+ group: "life",
39467
+ synonyms: [
39468
+ "persona",
39469
+ "piedi",
39470
+ "presenza"
39471
+ ]
39472
+ }
39473
+ ];
39474
+ DISPLAY_ICON_VOCABULARY.map((e) => e.key);
39475
+ DISPLAY_ICON_VOCABULARY.filter((e) => e.aliasOf === void 0).map((e) => e.key);
39476
+ new Map(DISPLAY_ICON_VOCABULARY.map((e) => [e.key, e]));
38015
39477
  var LAST_FETCHED_FIELD = "lastFetchedAt";
38016
39478
  /**
38017
39479
  * How long a bridge stops re-attempting a refresh that did not land.
@@ -44957,6 +46419,12 @@ Object.freeze({
44957
46419
  addonId: null,
44958
46420
  access: "view"
44959
46421
  },
46422
+ "videoclips.dialClipStream": {
46423
+ capName: "videoclips",
46424
+ capScope: "device",
46425
+ addonId: null,
46426
+ access: "view"
46427
+ },
44960
46428
  "videoclips.getClipPlayback": {
44961
46429
  capName: "videoclips",
44962
46430
  capScope: "device",
@@ -46996,6 +48464,11 @@ Object.freeze({
46996
48464
  form: "single",
46997
48465
  optional: false
46998
48466
  }],
48467
+ "videoclips.dialClipStream": [{
48468
+ name: "deviceId",
48469
+ form: "single",
48470
+ optional: false
48471
+ }],
46999
48472
  "videoclips.getClipPlayback": [{
47000
48473
  name: "deviceId",
47001
48474
  form: "single",
@@ -149064,11 +150537,35 @@ function createClipPlaybackHandler(deps) {
149064
150537
  * `HEAD` answers the headers and touches no camera.
149065
150538
  */
149066
150539
  var CLIP_STREAM_PREFIX = "clip-stream";
150540
+ /** The data plane's own form: the clip is named on the path. */
150541
+ var resolveClipStreamPathTarget = (req) => {
150542
+ const match = MEDIA_PATH.exec(pathnameOf(req));
150543
+ if (match === null) return {
150544
+ kind: "refused",
150545
+ status: 400,
150546
+ code: "bad-path",
150547
+ detail: "not /<deviceId>/<clipKey>.mp4"
150548
+ };
150549
+ return {
150550
+ kind: "target",
150551
+ target: {
150552
+ deviceId: Number(match[1]),
150553
+ clipKey: match[2] ?? "",
150554
+ twin: "sub",
150555
+ audio: "none"
150556
+ }
150557
+ };
150558
+ };
150559
+ /**
150560
+ * Everything but the content type, which the MUX decides: a stream carrying
150561
+ * µ-law is a QuickTime container (measured — ffmpeg's MP4 muxer refuses
150562
+ * `pcm_mulaw`), and a header that said `video/mp4` for it would be a small
150563
+ * lie on a plane whose whole point is saying what it really serves.
150564
+ */
149067
150565
  var STREAM_HEADERS = {
149068
- "content-type": "video/mp4",
149069
150566
  "accept-ranges": "none",
149070
150567
  "cache-control": "no-store",
149071
- ["x-camstack-clip-transport"]: "forward-only"
150568
+ [CLIP_STREAM_TRANSPORT_HEADER]: "forward-only"
149072
150569
  };
149073
150570
  /** The status a refusal answers. `404` = nothing here to serve; `409` = a
149074
150571
  * clip that exists and cannot be served right now; `502` = the camera. */
@@ -149084,22 +150581,33 @@ function streamRefusalStatus(code) {
149084
150581
  }
149085
150582
  }
149086
150583
  function createClipStreamHandler(deps) {
150584
+ const resolveTarget = deps.target ?? resolveClipStreamPathTarget;
149087
150585
  return async (req, res) => {
149088
150586
  if (req.method !== "GET" && req.method !== "HEAD") {
149089
150587
  res.writeHead(405, { allow: "GET, HEAD" });
149090
150588
  res.end();
149091
150589
  return;
149092
150590
  }
149093
- const match = MEDIA_PATH.exec(pathnameOf(req));
149094
- if (match === null) {
149095
- res.writeHead(400);
150591
+ const resolved = resolveTarget(req);
150592
+ if (resolved.kind === "refused") {
150593
+ deps.logger.warn("videoclips: clip stream request names no clip", { meta: {
150594
+ code: resolved.code,
150595
+ reason: resolved.detail,
150596
+ status: resolved.status
150597
+ } });
150598
+ res.writeHead(resolved.status, {
150599
+ [CLIP_STILL_REASON_HEADER]: resolved.detail.slice(0, 500),
150600
+ [CLIP_STILL_REASON_CODE_HEADER]: resolved.code
150601
+ });
149096
150602
  res.end();
149097
150603
  return;
149098
150604
  }
149099
- const deviceId = Number(match[1]);
149100
- const clipKey = match[2] ?? "";
150605
+ const { deviceId, clipKey } = resolved.target;
149101
150606
  if (req.method === "HEAD") {
149102
- res.writeHead(200, STREAM_HEADERS);
150607
+ res.writeHead(200, {
150608
+ "content-type": "video/mp4",
150609
+ ...STREAM_HEADERS
150610
+ });
149103
150611
  res.end();
149104
150612
  return;
149105
150613
  }
@@ -149111,8 +150619,11 @@ function createClipStreamHandler(deps) {
149111
150619
  }
149112
150620
  });
149113
150621
  const sink = {
149114
- begin: () => {
149115
- res.writeHead(200, STREAM_HEADERS);
150622
+ begin: (contentType) => {
150623
+ res.writeHead(200, {
150624
+ "content-type": contentType,
150625
+ ...STREAM_HEADERS
150626
+ });
149116
150627
  },
149117
150628
  write: (chunk) => res.write(chunk),
149118
150629
  onceDrain: (listener) => {
@@ -149130,7 +150641,7 @@ function createClipStreamHandler(deps) {
149130
150641
  };
149131
150642
  let outcome;
149132
150643
  try {
149133
- outcome = await deps.stream(deviceId, clipKey, sink);
150644
+ outcome = await deps.stream(resolved.target, sink);
149134
150645
  } catch (err) {
149135
150646
  deps.logger.warn("videoclips: the clip stream route threw", {
149136
150647
  tags: { deviceId },
@@ -149343,16 +150854,39 @@ function probeClipStreamRate(timestampsUs) {
149343
150854
  accessUnits: timestampsUs.length
149344
150855
  };
149345
150856
  }
150857
+ /** Opus at 48 kHz — the only rate the codec has, and the session's RTP clock. */
150858
+ var CLIP_STREAM_OPUS_RATE_HZ = 48e3;
150859
+ /** µ-law at 8 kHz — the PCMU RTP clock. */
150860
+ var CLIP_STREAM_PCMU_RATE_HZ = 8e3;
149346
150861
  /**
149347
- * One ffmpeg, stdin to stdout, video only, fragmented MP4.
150862
+ * The container a stream is muxed into, for a given audio ask.
150863
+ *
150864
+ * MEASURED, not chosen: ffmpeg's MP4 muxer refuses `pcm_mulaw`
150865
+ * (`Could not find tag for codec pcm_mulaw in stream #1`), and its MOV muxer
150866
+ * takes it as a `ulaw` sample entry. The boxes a consumer walks are the same
150867
+ * either way — `ftyp`, an empty `moov`, then `moof`+`mdat` — so this changes
150868
+ * the content type and nothing else.
150869
+ */
150870
+ function clipStreamContainerFor(audio) {
150871
+ return audio === "pcmu" ? "mov" : "mp4";
150872
+ }
150873
+ /** What the route puts on `content-type` for {@link clipStreamContainerFor}. */
150874
+ function clipStreamContentTypeFor(audio) {
150875
+ return clipStreamContainerFor(audio) === "mov" ? "video/quicktime" : "video/mp4";
150876
+ }
150877
+ /**
150878
+ * One ffmpeg, stdin to stdout, fragmented, with an optional second input.
149348
150879
  *
149349
150880
  * Mirrors `buildClipMuxArgs` where a stream can: the demuxer is named from
149350
150881
  * the DETECTED codec (the BcMedia header lies on a hub child), `-r` is an
149351
150882
  * input option before its own `-i`, and the video is copied. Where it
149352
- * cannot — `+faststart`, the audio input — the file header says why.
150883
+ * cannot — `+faststart` — the file header says why; where it now can — the
150884
+ * audio input — it is an ENCODE rather than the file path's copy, because
150885
+ * this output is consumed by a WebRTC sender and not by a player.
149353
150886
  */
149354
150887
  function buildClipStreamMuxArgs(input) {
149355
150888
  const demuxer = input.videoType === "H265" ? "hevc" : "h264";
150889
+ const audio = input.audio;
149356
150890
  return [
149357
150891
  "-hide_banner",
149358
150892
  "-loglevel",
@@ -149363,12 +150897,42 @@ function buildClipStreamMuxArgs(input) {
149363
150897
  demuxer,
149364
150898
  "-i",
149365
150899
  "pipe:0",
150900
+ ...audio !== null ? [
150901
+ "-f",
150902
+ "aac",
150903
+ "-i",
150904
+ `pipe:${String(3)}`
150905
+ ] : [],
150906
+ ...audio !== null ? [
150907
+ "-map",
150908
+ "0:v",
150909
+ "-map",
150910
+ "1:a"
150911
+ ] : [],
149366
150912
  "-c:v",
149367
150913
  "copy",
150914
+ ...audio === "opus" ? [
150915
+ "-c:a",
150916
+ "libopus",
150917
+ "-ar",
150918
+ String(CLIP_STREAM_OPUS_RATE_HZ),
150919
+ "-ac",
150920
+ String(1),
150921
+ "-b:a",
150922
+ "24k"
150923
+ ] : [],
150924
+ ...audio === "pcmu" ? [
150925
+ "-c:a",
150926
+ "pcm_mulaw",
150927
+ "-ar",
150928
+ String(CLIP_STREAM_PCMU_RATE_HZ),
150929
+ "-ac",
150930
+ String(1)
150931
+ ] : [],
149368
150932
  "-movflags",
149369
150933
  CLIP_STREAM_MOVFLAGS,
149370
150934
  "-f",
149371
- "mp4",
150935
+ clipStreamContainerFor(audio),
149372
150936
  "pipe:1"
149373
150937
  ];
149374
150938
  }
@@ -149456,10 +151020,22 @@ function judgeClipDuration(input) {
149456
151020
  }
149457
151021
  //#endregion
149458
151022
  //#region src/clips/clip-stream.ts
151023
+ function isWritable(stream) {
151024
+ return "write" in stream && typeof stream.write === "function";
151025
+ }
151026
+ /** The stream at `index`, if it is one this process can be WRITTEN to. */
151027
+ function writableAt(stdio, index) {
151028
+ const candidate = stdio[index];
151029
+ if (candidate === null || candidate === void 0) return null;
151030
+ return isWritable(candidate) ? candidate : null;
151031
+ }
149459
151032
  var defaultDecoder = (handlers) => new BcMediaAnnexBDecoder({
149460
151033
  strict: false,
149461
151034
  onVideoAccessUnit: handlers.onVideoAccessUnit,
149462
- onAudioFrame: () => handlers.onAudioFrame()
151035
+ onAudioFrame: (frame) => handlers.onAudioFrame({
151036
+ audioType: frame.audioType,
151037
+ data: frame.data
151038
+ })
149463
151039
  });
149464
151040
  var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
149465
151041
  function createClipStreamProducer(deps) {
@@ -149549,7 +151125,16 @@ var ClipStreamRun = class {
149549
151125
  input;
149550
151126
  attempt;
149551
151127
  accessUnits = 0;
151128
+ /** Every audio frame the decoder handed over, of any codec. */
149552
151129
  audioFrames = 0;
151130
+ audioFramesDropped = 0;
151131
+ audioFramesAfterMux = 0;
151132
+ /** ADTS frames held until the mux exists — the audio half of the probe. */
151133
+ audioProbe = [];
151134
+ audioProbeBytes = 0;
151135
+ audioMuxed = null;
151136
+ audioSilentReason = null;
151137
+ audioIn = null;
149553
151138
  timestampsUs = [];
149554
151139
  /** Access units held until the rate is probed — the frames in flight. */
149555
151140
  probe = [];
@@ -149567,6 +151152,7 @@ var ClipStreamRun = class {
149567
151152
  bytesOut = 0;
149568
151153
  cut = null;
149569
151154
  stdinGate;
151155
+ audioStdinGate;
149570
151156
  peerGate;
149571
151157
  tags;
149572
151158
  constructor(deps, input, attempt) {
@@ -149578,6 +151164,10 @@ var ClipStreamRun = class {
149578
151164
  timeoutMs: deps.drainTimeoutMs,
149579
151165
  onStall: () => this.sever("mux-stalled", "ffmpeg stopped taking access units")
149580
151166
  });
151167
+ this.audioStdinGate = createDrainGate({
151168
+ timeoutMs: deps.drainTimeoutMs,
151169
+ onStall: () => this.sever("mux-audio-stalled", "ffmpeg stopped taking audio frames")
151170
+ });
149581
151171
  this.peerGate = createDrainGate({
149582
151172
  timeoutMs: deps.drainTimeoutMs,
149583
151173
  onStall: () => this.sever("peer-stalled", "the peer stopped taking bytes")
@@ -149590,9 +151180,7 @@ var ClipStreamRun = class {
149590
151180
  });
149591
151181
  const decoder = this.deps.createDecoder({
149592
151182
  onVideoAccessUnit: (unit) => this.onAccessUnit(unit),
149593
- onAudioFrame: () => {
149594
- this.audioFrames += 1;
149595
- }
151183
+ onAudioFrame: (frame) => this.onAudioFrame(frame)
149596
151184
  });
149597
151185
  this.deps.logger.info("videoclips: streaming a clip file from the camera", {
149598
151186
  tags: this.tags,
@@ -149603,6 +151191,7 @@ var ClipStreamRun = class {
149603
151191
  startMs: row.startMs,
149604
151192
  endMs: row.endMs,
149605
151193
  attempt: this.attempt,
151194
+ audioAsked: this.input.audio,
149606
151195
  idleTimeoutMs: 300
149607
151196
  }
149608
151197
  });
@@ -149649,12 +151238,68 @@ var ClipStreamRun = class {
149649
151238
  }
149650
151239
  this.feed(unit.annexB);
149651
151240
  }
151241
+ /**
151242
+ * One audio frame from the decoder.
151243
+ *
151244
+ * Before the mux exists the ADTS frames are HELD, exactly as the video
151245
+ * probe holds access units, because the spawn cannot gain an input later
151246
+ * and ffmpeg will not write a byte until every input it was given has been
151247
+ * probed. After it exists they are either fed or counted as dropped — and
151248
+ * a frame that arrives after a SILENT spawn is counted apart, because that
151249
+ * one is a recording whose sound was there and was decided away.
151250
+ */
151251
+ onAudioFrame(frame) {
151252
+ if (this.cut !== null) return;
151253
+ this.audioFrames += 1;
151254
+ if (this.input.audio === "none" || frame.audioType !== "Aac" || frame.data.byteLength === 0) {
151255
+ this.audioFramesDropped += 1;
151256
+ return;
151257
+ }
151258
+ if (this.mux === null) {
151259
+ if (this.audioProbe.length >= 4096 || this.audioProbeBytes + frame.data.byteLength > 4194304) {
151260
+ this.audioFramesDropped += 1;
151261
+ return;
151262
+ }
151263
+ this.audioProbe = [...this.audioProbe, frame.data];
151264
+ this.audioProbeBytes += frame.data.byteLength;
151265
+ return;
151266
+ }
151267
+ if (this.audioMuxed === null) {
151268
+ this.audioFramesDropped += 1;
151269
+ this.audioFramesAfterMux += 1;
151270
+ return;
151271
+ }
151272
+ this.feedAudio(frame.data);
151273
+ }
151274
+ /**
151275
+ * What the mux will carry as sound, decided ONCE, before the spawn.
151276
+ *
151277
+ * The gate is `an ADTS frame is already in hand`, not `the ask was not
151278
+ * none`: ffmpeg probes `pipe:3` before it writes output, so declaring an
151279
+ * input that never delivers would hold the VIDEO hostage for a recording
151280
+ * that simply has no microphone. An ADPCM-only camera is named separately
151281
+ * from a silent one — the file path draws the same distinction
151282
+ * (`decideClipAudio`) and for the same reason.
151283
+ */
151284
+ decideAudio() {
151285
+ const asked = this.input.audio;
151286
+ if (asked === "none") {
151287
+ this.audioSilentReason = "not-requested";
151288
+ return;
151289
+ }
151290
+ if (this.audioProbe.length > 0) {
151291
+ this.audioMuxed = asked;
151292
+ return;
151293
+ }
151294
+ this.audioSilentReason = this.audioFrames > 0 ? "audio-not-copyable" : "no-audio-track";
151295
+ }
149652
151296
  /** Spawn ffmpeg with the probed rate and hand it what was held. */
149653
151297
  startMux() {
149654
151298
  const first = this.probe[0];
149655
151299
  if (first === void 0 || this.mux !== null) return;
149656
151300
  this.videoType = first.videoType;
149657
151301
  this.rate = probeClipStreamRate(this.timestampsUs);
151302
+ this.decideAudio();
149658
151303
  const { row, twin } = this.input;
149659
151304
  if (this.rate.fpsSource !== "timestamps") this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
149660
151305
  tags: this.tags,
@@ -149669,13 +151314,33 @@ var ClipStreamRun = class {
149669
151314
  try {
149670
151315
  mux = this.deps.spawnMux(buildClipStreamMuxArgs({
149671
151316
  videoType: this.videoType,
149672
- fps: this.rate.fps
151317
+ fps: this.rate.fps,
151318
+ audio: this.audioMuxed
149673
151319
  }));
149674
151320
  } catch (err) {
149675
151321
  this.sever("mux-failed", `ffmpeg could not be spawned: ${describe(err)}`);
149676
151322
  return;
149677
151323
  }
149678
151324
  this.mux = mux;
151325
+ if (this.audioMuxed !== null) {
151326
+ this.audioIn = writableAt(mux.stdio, 3);
151327
+ if (this.audioIn === null) {
151328
+ this.audioMuxed = null;
151329
+ this.audioSilentReason = "no-audio-track";
151330
+ this.deps.logger.warn("videoclips: a clip stream mux exposed no audio pipe", {
151331
+ tags: this.tags,
151332
+ meta: {
151333
+ clipKey: row.clipKey,
151334
+ twin,
151335
+ branch: "audio-pipe-missing"
151336
+ }
151337
+ });
151338
+ this.sever("mux-failed", "the mux was spawned with an audio input it does not expose");
151339
+ return;
151340
+ }
151341
+ this.audioIn.on("error", (err) => this.sever("mux-failed", `ffmpeg audio stdin: ${err.message}`));
151342
+ this.audioIn.on("drain", () => this.audioStdinGate.drained());
151343
+ }
149679
151344
  this.muxClosed = new Promise((resolve) => {
149680
151345
  mux.on("close", (code) => resolve(code));
149681
151346
  mux.on("error", (err) => {
@@ -149698,24 +151363,38 @@ var ClipStreamRun = class {
149698
151363
  videoType: this.videoType,
149699
151364
  fps: this.rate.fps,
149700
151365
  fpsSource: this.rate.fpsSource,
149701
- probeAccessUnits: this.rate.accessUnits
151366
+ probeAccessUnits: this.rate.accessUnits,
151367
+ audioAsked: this.input.audio,
151368
+ audioMuxed: this.audioMuxed,
151369
+ audioSilentReason: this.audioSilentReason,
151370
+ probeAudioFrames: this.audioProbe.length
149702
151371
  }
149703
151372
  });
149704
151373
  const held = this.probe;
149705
151374
  this.probe = [];
151375
+ const heldAudio = this.audioProbe;
151376
+ this.audioProbe = [];
151377
+ this.audioProbeBytes = 0;
149706
151378
  for (const unit of held) this.feed(unit.annexB);
151379
+ if (this.audioMuxed === null) this.audioFramesDropped += heldAudio.length;
151380
+ else for (const frame of heldAudio) this.feedAudio(frame);
149707
151381
  }
149708
151382
  feed(annexB) {
149709
151383
  if (this.cut !== null || this.mux?.stdin === null || this.mux?.stdin === void 0) return;
149710
151384
  this.stdinGate.wrote(this.mux.stdin.write(annexB));
149711
151385
  }
151386
+ feedAudio(adts) {
151387
+ const audioIn = this.audioIn;
151388
+ if (this.cut !== null || audioIn === null) return;
151389
+ this.audioStdinGate.wrote(audioIn.write(adts));
151390
+ }
149712
151391
  /** ffmpeg's output, to the peer, honouring `write()`'s return (D447). */
149713
151392
  onMuxOut(chunk) {
149714
151393
  if (this.cut !== null) return;
149715
151394
  const { sink } = this.input;
149716
151395
  if (!this.begun) {
149717
151396
  this.begun = true;
149718
- sink.begin();
151397
+ sink.begin(clipStreamContentTypeFor(this.audioMuxed));
149719
151398
  }
149720
151399
  const accepted = sink.write(chunk);
149721
151400
  this.bytesOut += chunk.byteLength;
@@ -149741,6 +151420,7 @@ var ClipStreamRun = class {
149741
151420
  detail
149742
151421
  };
149743
151422
  this.stdinGate.dispose();
151423
+ this.audioStdinGate.dispose();
149744
151424
  this.peerGate.dispose();
149745
151425
  this.mux?.kill("SIGKILL");
149746
151426
  this.deps.logger.warn("videoclips: a clip stream was cut", {
@@ -149752,7 +151432,10 @@ var ClipStreamRun = class {
149752
151432
  detail,
149753
151433
  accessUnits: this.accessUnits,
149754
151434
  bytesOut: this.bytesOut,
149755
- begun: this.begun
151435
+ begun: this.begun,
151436
+ audioMuxed: this.audioMuxed,
151437
+ audioFramesIn: this.audioFrames,
151438
+ audioFramesDropped: this.audioFramesDropped
149756
151439
  }
149757
151440
  });
149758
151441
  if (this.begun) this.input.sink.destroy();
@@ -149798,6 +151481,7 @@ var ClipStreamRun = class {
149798
151481
  detail: "no mux after the transfer ended"
149799
151482
  });
149800
151483
  mux.stdin?.end();
151484
+ this.audioIn?.end();
149801
151485
  const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
149802
151486
  const code = await this.muxClosed;
149803
151487
  clearTimeout(flush);
@@ -149823,11 +151507,13 @@ var ClipStreamRun = class {
149823
151507
  decodedSeconds: durationSeconds,
149824
151508
  ...row.inProgress === true ? { inProgress: true } : {}
149825
151509
  });
151510
+ const audio = this.audioReport();
149826
151511
  const meta = {
149827
151512
  clipKey: row.clipKey,
149828
151513
  twin,
149829
151514
  attempt: this.attempt,
149830
151515
  accessUnits: this.accessUnits,
151516
+ audioMuxed: audio.muxed,
149831
151517
  bytesOut: this.bytesOut,
149832
151518
  durationSeconds,
149833
151519
  expectedMs: row.endMs - row.startMs,
@@ -149859,19 +151545,11 @@ var ClipStreamRun = class {
149859
151545
  branch: "stream-served"
149860
151546
  }
149861
151547
  });
149862
- if (this.audioFrames > 0) this.deps.logger.warn("videoclips: a clip stream was served without sound", {
149863
- tags: this.tags,
149864
- meta: {
149865
- clipKey: row.clipKey,
149866
- twin,
149867
- branch: "stream-video-only",
149868
- audioFramesDropped: this.audioFrames
149869
- }
149870
- });
151548
+ this.reportAudio(audio);
149871
151549
  return {
149872
151550
  kind: "served",
149873
151551
  accessUnits: this.accessUnits,
149874
- audioFramesDropped: this.audioFrames,
151552
+ audio,
149875
151553
  bytesOut: this.bytesOut,
149876
151554
  durationSeconds,
149877
151555
  verdict,
@@ -149879,15 +151557,269 @@ var ClipStreamRun = class {
149879
151557
  attempts: this.attempt
149880
151558
  };
149881
151559
  }
151560
+ audioReport() {
151561
+ return {
151562
+ asked: this.input.audio,
151563
+ muxed: this.audioMuxed,
151564
+ reason: this.audioSilentReason,
151565
+ framesIn: this.audioFrames,
151566
+ framesDropped: this.audioFramesDropped,
151567
+ framesAfterMux: this.audioFramesAfterMux
151568
+ };
151569
+ }
151570
+ /**
151571
+ * Say what became of the sound, on EVERY served stream — including the one
151572
+ * that carried it.
151573
+ *
151574
+ * A branch that accepts work and produces nothing is the branch that has to
151575
+ * speak (D391), and silence is exactly what that looks like here. But a
151576
+ * stream that DID carry audio says so too, with the frame count, because
151577
+ * "the clip was silent" and "the clip had sound and you did not hear it"
151578
+ * are answered from this line and nowhere else.
151579
+ */
151580
+ reportAudio(audio) {
151581
+ const { row, twin } = this.input;
151582
+ const meta = {
151583
+ clipKey: row.clipKey,
151584
+ twin,
151585
+ audioAsked: audio.asked,
151586
+ audioMuxed: audio.muxed,
151587
+ audioFramesIn: audio.framesIn,
151588
+ audioFramesDropped: audio.framesDropped,
151589
+ audioFramesAfterMux: audio.framesAfterMux
151590
+ };
151591
+ if (audio.muxed !== null) {
151592
+ this.deps.logger.info("videoclips: a clip stream carried sound", {
151593
+ tags: this.tags,
151594
+ meta: {
151595
+ ...meta,
151596
+ branch: "stream-with-audio"
151597
+ }
151598
+ });
151599
+ return;
151600
+ }
151601
+ if (audio.framesAfterMux > 0) {
151602
+ this.deps.logger.warn("videoclips: a clip stream dropped sound that arrived too late", {
151603
+ tags: this.tags,
151604
+ meta: {
151605
+ ...meta,
151606
+ branch: "stream-audio-after-mux"
151607
+ }
151608
+ });
151609
+ return;
151610
+ }
151611
+ if (audio.asked === "none") {
151612
+ this.deps.logger.info("videoclips: a clip stream was served without sound", {
151613
+ tags: this.tags,
151614
+ meta: {
151615
+ ...meta,
151616
+ branch: "stream-video-only"
151617
+ }
151618
+ });
151619
+ return;
151620
+ }
151621
+ this.deps.logger.warn("videoclips: a clip stream was asked for sound and had none", {
151622
+ tags: this.tags,
151623
+ meta: {
151624
+ ...meta,
151625
+ branch: `stream-silent:${audio.reason ?? "unknown"}`
151626
+ }
151627
+ });
151628
+ }
149882
151629
  };
149883
151630
  function describe(err) {
149884
151631
  return err instanceof Error ? err.message : String(err);
149885
151632
  }
151633
+ function pathToken(req) {
151634
+ const raw = req.url ?? "/";
151635
+ const query = raw.indexOf("?");
151636
+ return (query === -1 ? raw : raw.slice(0, query)).replace(/^\/+/, "");
151637
+ }
151638
+ function createClipStreamDial(deps) {
151639
+ const now = deps.now ?? (() => Date.now());
151640
+ const ttlMs = deps.ttlMs ?? 3e4;
151641
+ const maxOutstanding = deps.maxOutstanding ?? 64;
151642
+ const host = deps.host ?? "127.0.0.1";
151643
+ const tokens = /* @__PURE__ */ new Map();
151644
+ /** Tokens already dialled, kept for their TTL so a replay is named `dial-used`, not `dial-unknown`. */
151645
+ const used = /* @__PURE__ */ new Map();
151646
+ let server = null;
151647
+ let baseUrl = null;
151648
+ let listening = null;
151649
+ const sweep = () => {
151650
+ const at = now();
151651
+ for (const [token, minted] of tokens) if (minted.expiresAtMs <= at) tokens.delete(token);
151652
+ for (const [token, expiresAtMs] of used) if (expiresAtMs <= at) used.delete(token);
151653
+ };
151654
+ const resolveTarget = (req) => {
151655
+ const token = pathToken(req);
151656
+ const minted = tokens.get(token);
151657
+ if (minted === void 0) {
151658
+ if (used.get(token) !== void 0) return {
151659
+ kind: "refused",
151660
+ status: 410,
151661
+ code: "dial-used",
151662
+ detail: "this dial was already taken"
151663
+ };
151664
+ return {
151665
+ kind: "refused",
151666
+ status: 404,
151667
+ code: "dial-unknown",
151668
+ detail: "no such dial"
151669
+ };
151670
+ }
151671
+ if (minted.expiresAtMs <= now()) {
151672
+ tokens.delete(token);
151673
+ return {
151674
+ kind: "refused",
151675
+ status: 410,
151676
+ code: "dial-expired",
151677
+ detail: "this dial has expired"
151678
+ };
151679
+ }
151680
+ if (req.method === "GET") {
151681
+ tokens.delete(token);
151682
+ used.set(token, minted.expiresAtMs);
151683
+ deps.logger.info("videoclips: a clip stream dial was taken", {
151684
+ tags: { deviceId: minted.target.deviceId },
151685
+ meta: {
151686
+ clipKey: minted.target.clipKey,
151687
+ twin: minted.target.twin,
151688
+ audio: minted.target.audio,
151689
+ outstanding: tokens.size
151690
+ }
151691
+ });
151692
+ }
151693
+ return {
151694
+ kind: "target",
151695
+ target: minted.target
151696
+ };
151697
+ };
151698
+ const handler = createClipStreamHandler({
151699
+ stream: deps.stream,
151700
+ logger: deps.logger,
151701
+ target: resolveTarget
151702
+ });
151703
+ const listen = () => {
151704
+ if (listening !== null) return listening;
151705
+ listening = new Promise((resolve, reject) => {
151706
+ const created = createServer((req, res) => {
151707
+ handler(req, res).catch((err) => {
151708
+ deps.logger.warn("videoclips: the clip stream dial handler threw", { meta: { error: err instanceof Error ? err.message : String(err) } });
151709
+ if (!res.headersSent) res.writeHead(500);
151710
+ res.end();
151711
+ });
151712
+ });
151713
+ created.on("error", (err) => {
151714
+ listening = null;
151715
+ reject(err);
151716
+ });
151717
+ created.listen(0, host, () => {
151718
+ const address = created.address();
151719
+ if (address === null || typeof address === "string") {
151720
+ created.close();
151721
+ listening = null;
151722
+ reject(/* @__PURE__ */ new Error("the dial listener bound no TCP port"));
151723
+ return;
151724
+ }
151725
+ server = created;
151726
+ baseUrl = `http://${host}:${address.port}`;
151727
+ deps.logger.info("videoclips: clip stream dial listener bound", { meta: {
151728
+ host,
151729
+ port: address.port,
151730
+ ttlMs,
151731
+ maxOutstanding
151732
+ } });
151733
+ resolve(baseUrl);
151734
+ });
151735
+ });
151736
+ return listening;
151737
+ };
151738
+ return {
151739
+ get outstanding() {
151740
+ sweep();
151741
+ return tokens.size;
151742
+ },
151743
+ mint: async (input) => {
151744
+ sweep();
151745
+ if (tokens.size >= maxOutstanding) {
151746
+ deps.logger.warn("videoclips: clip stream dial refused — too many outstanding", {
151747
+ tags: { deviceId: input.deviceId },
151748
+ meta: {
151749
+ clipKey: input.clipKey,
151750
+ twin: input.twin,
151751
+ outstanding: tokens.size,
151752
+ maxOutstanding
151753
+ }
151754
+ });
151755
+ return {
151756
+ kind: "refused",
151757
+ code: "dial-exhausted",
151758
+ detail: `${tokens.size} dials outstanding against a bound of ${maxOutstanding}`
151759
+ };
151760
+ }
151761
+ let base;
151762
+ try {
151763
+ base = baseUrl ?? await listen();
151764
+ } catch (err) {
151765
+ const detail = err instanceof Error ? err.message : String(err);
151766
+ deps.logger.warn("videoclips: clip stream dial refused — no listener", {
151767
+ tags: { deviceId: input.deviceId },
151768
+ meta: {
151769
+ clipKey: input.clipKey,
151770
+ twin: input.twin,
151771
+ error: detail
151772
+ }
151773
+ });
151774
+ return {
151775
+ kind: "refused",
151776
+ code: "listener-failed",
151777
+ detail
151778
+ };
151779
+ }
151780
+ const token = randomBytes(24).toString("base64url");
151781
+ const expiresAtMs = now() + ttlMs;
151782
+ tokens.set(token, {
151783
+ target: input,
151784
+ expiresAtMs
151785
+ });
151786
+ deps.logger.info("videoclips: clip stream dial minted", {
151787
+ tags: { deviceId: input.deviceId },
151788
+ meta: {
151789
+ clipKey: input.clipKey,
151790
+ twin: input.twin,
151791
+ audio: input.audio,
151792
+ expiresInMs: ttlMs,
151793
+ outstanding: tokens.size
151794
+ }
151795
+ });
151796
+ return {
151797
+ kind: "ticket",
151798
+ url: `${base}/${token}`,
151799
+ expiresAtMs
151800
+ };
151801
+ },
151802
+ dispose: async () => {
151803
+ tokens.clear();
151804
+ used.clear();
151805
+ const open = server;
151806
+ server = null;
151807
+ baseUrl = null;
151808
+ listening = null;
151809
+ if (open === null) return;
151810
+ await new Promise((resolve) => {
151811
+ open.close(() => resolve());
151812
+ open.closeAllConnections();
151813
+ });
151814
+ deps.logger.info("videoclips: clip stream dial listener closed", {});
151815
+ }
151816
+ };
151817
+ }
149886
151818
  //#endregion
149887
151819
  //#region src/clips/clip-stream-route.ts
149888
151820
  function createClipStreamRoute(deps) {
149889
- return async (deviceId, clipKey, sink) => {
149890
- const twin = "sub";
151821
+ return async (target, sink) => {
151822
+ const { deviceId, clipKey, twin, audio } = target;
149891
151823
  const gate = await deps.gate({
149892
151824
  deviceId,
149893
151825
  clipKey,
@@ -149930,11 +151862,28 @@ function createClipStreamRoute(deps) {
149930
151862
  detail: "clip-in-progress: the camera is still writing this file; it has no end to stream to"
149931
151863
  };
149932
151864
  }
151865
+ const fileName = twin === "main" ? row.mainFileName ?? "" : row.subFileName;
151866
+ if (fileName === "") {
151867
+ deps.logger.warn("videoclips: no byte handle for the twin this clip stream names", {
151868
+ tags: { deviceId },
151869
+ meta: {
151870
+ clipKey,
151871
+ twin,
151872
+ branch: "no-catalog-row"
151873
+ }
151874
+ });
151875
+ return {
151876
+ kind: "refused",
151877
+ code: "no-catalog-row",
151878
+ detail: `no-catalog-row: this clip is listed with no ${twin} file behind it`
151879
+ };
151880
+ }
149933
151881
  const outcome = await deps.producer.stream({
149934
151882
  device: gate.device,
149935
151883
  row,
149936
- fileName: row.subFileName,
151884
+ fileName,
149937
151885
  twin,
151886
+ audio,
149938
151887
  sink
149939
151888
  });
149940
151889
  switch (outcome.kind) {
@@ -150180,6 +152129,76 @@ function listableClips(rows, window) {
150180
152129
  inProgress: inWindow.length - kept.length
150181
152130
  };
150182
152131
  }
152132
+ function pairTwins(subs, mains) {
152133
+ const datedSubs = subs.filter((f) => f.startTime instanceof Date);
152134
+ const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
152135
+ const claimed = /* @__PURE__ */ new Set();
152136
+ const pairs = [];
152137
+ for (const sub of datedSubs.toSorted((a, b) => startOf(a) - startOf(b))) {
152138
+ const subStart = startOf(sub);
152139
+ let bestIndex = -1;
152140
+ let bestDelta = Number.POSITIVE_INFINITY;
152141
+ for (const [index, candidate] of datedMains.entries()) {
152142
+ if (claimed.has(index)) continue;
152143
+ const delta = Math.abs(startOf(candidate) - subStart);
152144
+ if (delta > 3e3) continue;
152145
+ if (delta < bestDelta) {
152146
+ bestDelta = delta;
152147
+ bestIndex = index;
152148
+ }
152149
+ }
152150
+ if (bestIndex === -1) {
152151
+ pairs.push({ sub });
152152
+ continue;
152153
+ }
152154
+ claimed.add(bestIndex);
152155
+ const main = datedMains[bestIndex];
152156
+ pairs.push(main === void 0 ? { sub } : {
152157
+ sub,
152158
+ main
152159
+ });
152160
+ }
152161
+ return pairs.toSorted((a, b) => startOf(b.sub) - startOf(a.sub));
152162
+ }
152163
+ function startOf(file) {
152164
+ return file.startTime?.getTime() ?? 0;
152165
+ }
152166
+ /**
152167
+ * The ONE place that turns a requested profile into a twin.
152168
+ *
152169
+ * Three call sites used to decide this independently — `getClipPlayback`,
152170
+ * `readClipBytes` and the stream dial — and they did not agree: the dial
152171
+ * REFUSED `high` outright while the other two served the sub copy with a
152172
+ * warning. Same question, three answers, and the one that refused made `high`
152173
+ * look unsupported rather than unavailable for that recording.
152174
+ *
152175
+ * The mapping is the profile ladder the rest of the system already uses:
152176
+ * `high` reaches for the main twin, everything else is the sub. A `high` with
152177
+ * no main twin degrades to the sub and SAYS SO through `served` and
152178
+ * `downgraded` — "there is no high-quality copy" is an answer (`pairTwins`
152179
+ * models it as `main: undefined`), and a silent substitution is what D576
152180
+ * forbids. Refusing was the other way of getting it wrong.
152181
+ *
152182
+ * An ABSENT profile is not `high`: a caller that stated no preference gets the
152183
+ * cheap copy, which is what all three call sites did before this function
152184
+ * existed (`profile === 'high'` on `undefined` is false).
152185
+ */
152186
+ function chooseClipTwin(profile, row) {
152187
+ const wantsMain = profile === "high";
152188
+ const mainFileName = wantsMain ? row.mainFileName : void 0;
152189
+ if (mainFileName !== void 0) return {
152190
+ twin: "main",
152191
+ fileName: mainFileName,
152192
+ served: "high",
152193
+ downgraded: false
152194
+ };
152195
+ return {
152196
+ twin: "sub",
152197
+ fileName: row.subFileName,
152198
+ served: "low",
152199
+ downgraded: wantsMain
152200
+ };
152201
+ }
150183
152202
  //#endregion
150184
152203
  //#region src/clips/clip-identity.ts
150185
152204
  /**
@@ -150385,40 +152404,6 @@ function rankOf(label) {
150385
152404
  const at = CLASS_RANK.indexOf(label);
150386
152405
  return at === -1 ? CLASS_RANK.length : at;
150387
152406
  }
150388
- function pairTwins(subs, mains) {
150389
- const datedSubs = subs.filter((f) => f.startTime instanceof Date);
150390
- const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
150391
- const claimed = /* @__PURE__ */ new Set();
150392
- const pairs = [];
150393
- for (const sub of datedSubs.toSorted((a, b) => startOf(a) - startOf(b))) {
150394
- const subStart = startOf(sub);
150395
- let bestIndex = -1;
150396
- let bestDelta = Number.POSITIVE_INFINITY;
150397
- for (const [index, candidate] of datedMains.entries()) {
150398
- if (claimed.has(index)) continue;
150399
- const delta = Math.abs(startOf(candidate) - subStart);
150400
- if (delta > 3e3) continue;
150401
- if (delta < bestDelta) {
150402
- bestDelta = delta;
150403
- bestIndex = index;
150404
- }
150405
- }
150406
- if (bestIndex === -1) {
150407
- pairs.push({ sub });
150408
- continue;
150409
- }
150410
- claimed.add(bestIndex);
150411
- const main = datedMains[bestIndex];
150412
- pairs.push(main === void 0 ? { sub } : {
150413
- sub,
150414
- main
150415
- });
150416
- }
150417
- return pairs.toSorted((a, b) => startOf(b.sub) - startOf(a.sub));
150418
- }
150419
- function startOf(file) {
150420
- return file.startTime?.getTime() ?? 0;
150421
- }
150422
152407
  var partsCache = /* @__PURE__ */ new Map();
150423
152408
  function formatterFor$1(timeZone) {
150424
152409
  const cached = partsCache.get(timeZone);
@@ -151120,14 +153105,17 @@ function createReolinkVideoclipsProvider(deps) {
151120
153105
  if (device === null) throw new Error(`videoclips: device ${deviceId} is not a Reolink camera`);
151121
153106
  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.`);
151122
153107
  const row = rowsByKey.get(`${deviceId}|${clipKeyFor(parsed.channel, parsed.fileName)}`) ?? null;
151123
- const wantsMain = profile === "high" && row?.mainFileName !== void 0;
151124
- const fileName = wantsMain ? row?.mainFileName ?? parsed.fileName : parsed.fileName;
153108
+ const chosen = row === null ? null : chooseClipTwin(profile, {
153109
+ subFileName: row.subFileName,
153110
+ ...row.mainFileName !== void 0 ? { mainFileName: row.mainFileName } : {}
153111
+ });
153112
+ const fileName = chosen === null || chosen.fileName === "" ? parsed.fileName : chosen.fileName;
151125
153113
  const prepared = await deps.preparePlayback({
151126
153114
  device,
151127
153115
  row,
151128
153116
  fileName,
151129
153117
  clipKey: clipKeyFor(parsed.channel, parsed.fileName),
151130
- profile: wantsMain ? "high" : "low"
153118
+ profile: chosen?.served ?? "low"
151131
153119
  });
151132
153120
  return {
151133
153121
  playbackUrl: prepared.playbackUrl,
@@ -151188,8 +153176,8 @@ function createReolinkVideoclipsProvider(deps) {
151188
153176
  const row = rowsByKey.get(`${deviceId}|${clipKey}`);
151189
153177
  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 });
151190
153178
  if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written, so it has no end to export`, { clipKey });
151191
- const mainFileName = profile === "high" ? row.mainFileName : void 0;
151192
- if (profile === "high" && mainFileName === void 0) deps.logger.warn("videoclips: no main twin for this clip — serving the sub copy", {
153179
+ const chosen = chooseClipTwin(profile, row);
153180
+ if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — serving the sub copy", {
151193
153181
  tags,
151194
153182
  meta: {
151195
153183
  clipId,
@@ -151198,13 +153186,13 @@ function createReolinkVideoclipsProvider(deps) {
151198
153186
  asked: profile
151199
153187
  }
151200
153188
  });
151201
- const twin = mainFileName === void 0 ? "sub" : "main";
151202
- const fileName = mainFileName ?? row.subFileName;
153189
+ const twin = chosen.twin;
153190
+ const fileName = chosen.fileName;
151203
153191
  if (fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
151204
153192
  clipKey,
151205
153193
  twin
151206
153194
  });
151207
- const served = twin === "main" ? "high" : "low";
153195
+ const served = chosen.served;
151208
153196
  const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
151209
153197
  const declaredBytes = twin === "main" ? row.mainBytes : row.subBytes;
151210
153198
  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`, {
@@ -151248,6 +153236,75 @@ function createReolinkVideoclipsProvider(deps) {
151248
153236
  served,
151249
153237
  ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
151250
153238
  };
153239
+ },
153240
+ /**
153241
+ * Where this clip's STREAM is dialled (D597) — the same questions
153242
+ * `readClipBytes` asks before the camera is touched (the id, the row, a
153243
+ * file still being written), the same vocabulary, and NO fetch: the URL
153244
+ * is one shot on the provider's own listener and the camera is opened
153245
+ * when the broker dials it.
153246
+ *
153247
+ * It serves EVERY profile, through the same {@link chooseClipTwin} the
153248
+ * byte read asks (D599). It used to refuse `high` by name, on the grounds
153249
+ * that handing a sub copy to a `high` ask is the silent 33× substitution
153250
+ * D576 forbids — true, but the refusal was not the cure. The cure is
153251
+ * `served`, which is on this answer and states which twin the stream
153252
+ * actually carries. Refusing left the operator with `high` looking
153253
+ * unsupported, and left two transports behind one pointer.
153254
+ */
153255
+ dialClipStream: async ({ deviceId, clipId, profile, audio }) => {
153256
+ const tags = { deviceId };
153257
+ const refuse = (code, detail, meta) => {
153258
+ deps.logger.warn("videoclips: refusing a clip stream dial", {
153259
+ tags,
153260
+ meta: {
153261
+ clipId,
153262
+ branch: code,
153263
+ ...meta
153264
+ }
153265
+ });
153266
+ throw new Error(clipExportFailure(code, detail));
153267
+ };
153268
+ const parsed = parseClipId(clipId);
153269
+ if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Reolink provider`, {});
153270
+ const clipKey = clipKeyFor(parsed.channel, parsed.fileName);
153271
+ const row = rowsByKey.get(`${deviceId}|${clipKey}`);
153272
+ 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 });
153273
+ if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written; it has no end to stream to`, { clipKey });
153274
+ const chosen = chooseClipTwin(profile, row);
153275
+ if (chosen.downgraded) deps.logger.warn("videoclips: no main twin for this clip — streaming the sub copy", {
153276
+ tags,
153277
+ meta: {
153278
+ clipId,
153279
+ clipKey,
153280
+ branch: "no-main-twin",
153281
+ asked: profile
153282
+ }
153283
+ });
153284
+ if (chosen.fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
153285
+ clipKey,
153286
+ twin: chosen.twin
153287
+ });
153288
+ const askedAudio = audio ?? "none";
153289
+ const audioReason = askedAudio === "none" ? "not-requested" : null;
153290
+ const outcome = await deps.prepareStreamDial({
153291
+ deviceId,
153292
+ row,
153293
+ twin: chosen.twin,
153294
+ audio: askedAudio
153295
+ });
153296
+ if (outcome.kind === "refused") return refuse(outcome.code, outcome.detail, {
153297
+ clipKey,
153298
+ twin: chosen.twin
153299
+ });
153300
+ return {
153301
+ url: outcome.url,
153302
+ expiresAtMs: outcome.expiresAtMs,
153303
+ transport: "forward-only",
153304
+ served: chosen.served,
153305
+ servedAudio: askedAudio,
153306
+ ...audioReason !== null ? { audioReason } : {}
153307
+ };
151251
153308
  }
151252
153309
  };
151253
153310
  /**
@@ -152161,12 +154218,23 @@ function createClipService(deps) {
152161
154218
  producer: createClipStreamProducer({
152162
154219
  logger: deps.logger,
152163
154220
  spawnMux: (args) => spawn$1(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
154221
+ "pipe",
152164
154222
  "pipe",
152165
154223
  "pipe",
152166
154224
  "pipe"
152167
154225
  ] })
152168
154226
  })
152169
154227
  });
154228
+ /**
154229
+ * The stream's DIAL (D597): the same route behind a one-shot token on a
154230
+ * loopback listener, because the broker holds no credential the data
154231
+ * plane or the hub proxy would take. See `clip-stream-dial.ts`.
154232
+ */
154233
+ const streamDial = createClipStreamDial({
154234
+ logger: deps.logger,
154235
+ stream: streamClip,
154236
+ now
154237
+ });
152170
154238
  const provider = createReolinkVideoclipsProvider({
152171
154239
  now,
152172
154240
  cache,
@@ -152179,11 +154247,41 @@ function createClipService(deps) {
152179
154247
  served: profile === "high" ? "high" : "low"
152180
154248
  }),
152181
154249
  prepareBytes,
154250
+ prepareStreamDial: async ({ deviceId, row, twin, audio }) => {
154251
+ const gate = await gateClipDevice({
154252
+ deviceId,
154253
+ clipKey: row.clipKey,
154254
+ twin,
154255
+ path: "stream"
154256
+ });
154257
+ if (gate.kind === "refused") return {
154258
+ kind: "refused",
154259
+ code: gate.code,
154260
+ detail: gate.detail
154261
+ };
154262
+ const minted = await streamDial.mint({
154263
+ deviceId,
154264
+ clipKey: row.clipKey,
154265
+ twin,
154266
+ audio
154267
+ });
154268
+ if (minted.kind === "refused") return {
154269
+ kind: "refused",
154270
+ code: "fetch-failed",
154271
+ detail: `${minted.code}: ${minted.detail}`
154272
+ };
154273
+ return {
154274
+ kind: "ok",
154275
+ url: minted.url,
154276
+ expiresAtMs: minted.expiresAtMs
154277
+ };
154278
+ },
152182
154279
  logger: deps.logger
152183
154280
  });
152184
154281
  return {
152185
154282
  provider,
152186
154283
  cache,
154284
+ disposeClipStreamDial: () => streamDial.dispose(),
152187
154285
  refreshCatalog: async (deviceId) => {
152188
154286
  const until = now();
152189
154287
  await provider.listClips({
@@ -166219,6 +168317,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
166219
168317
  this.clipRefreshTimer = null;
166220
168318
  }
166221
168319
  this.logChannels?.stop();
168320
+ await this.clipService?.disposeClipStreamDial().catch(() => {});
166222
168321
  await this.emailPushServer?.stop().catch(() => {});
166223
168322
  await this.autodetectCache.dispose();
166224
168323
  }