@camstack/addon-provider-reolink 1.2.158 → 1.2.160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +3143 -209
  2. package/dist/addon.mjs +3143 -209
  3. package/package.json +2 -2
package/dist/addon.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-BVhJDJka.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",
@@ -76810,7 +78283,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
76810
78283
  };
76811
78284
  }));
76812
78285
  //#endregion
76813
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-5PJ6XPBF.js
78286
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-GNC5U3YA.js
76814
78287
  var import_fxp = require_fxp();
76815
78288
  var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
76816
78289
  function encodeHeader(h) {
@@ -80975,6 +82448,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
80975
82448
  lockedChannelId = frame.header.channelId;
80976
82449
  lockedStreamType = frame.header.streamType;
80977
82450
  }
82451
+ params.onChunk?.(decrypted);
80978
82452
  chunks.push(decrypted);
80979
82453
  armIdleFinish();
80980
82454
  if (frame.header.responseCode === 201) finish(Buffer.concat(chunks));
@@ -92981,7 +94455,8 @@ ${stderr}`));
92981
94455
  payloadXml,
92982
94456
  streamType: 0,
92983
94457
  timeoutMs,
92984
- ...params.idleTimeoutMs != null ? { idleTimeoutMs: params.idleTimeoutMs } : {}
94458
+ ...params.idleTimeoutMs != null ? { idleTimeoutMs: params.idleTimeoutMs } : {},
94459
+ ...params.onChunk ? { onChunk: params.onChunk } : {}
92985
94460
  });
92986
94461
  } catch (e) {
92987
94462
  trace(`download failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -149045,6 +150520,161 @@ function createClipPlaybackHandler(deps) {
149045
150520
  }).pipe(res);
149046
150521
  };
149047
150522
  }
150523
+ /**
150524
+ * `/addon/provider-reolink/clip-stream/<deviceId>/<clipKey>.mp4` — the same
150525
+ * recording as `clip/…`, served as it ARRIVES from the camera.
150526
+ *
150527
+ * **Not `Range`-able, and it says so.** The response has no length: it is a
150528
+ * fragmented MP4 written by an ffmpeg that is still reading the camera when
150529
+ * the first byte leaves. So there is no `content-length`, `accept-ranges` is
150530
+ * `none`, a `Range` header is IGNORED (a 206 over something with no length
150531
+ * would be a lie), and the peer may not cache it (`no-store`) — a stream is
150532
+ * not an immutable artifact, the file route is. The status line is written
150533
+ * on the first muxed byte, never before: until then every refusal still owns
150534
+ * it, and a camera that serves nothing answers a status with a reason, not an
150535
+ * empty 200 (D391).
150536
+ *
150537
+ * `HEAD` answers the headers and touches no camera.
150538
+ */
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
+ */
150565
+ var STREAM_HEADERS = {
150566
+ "accept-ranges": "none",
150567
+ "cache-control": "no-store",
150568
+ [CLIP_STREAM_TRANSPORT_HEADER]: "forward-only"
150569
+ };
150570
+ /** The status a refusal answers. `404` = nothing here to serve; `409` = a
150571
+ * clip that exists and cannot be served right now; `502` = the camera. */
150572
+ function streamRefusalStatus(code) {
150573
+ switch (code) {
150574
+ case "unknown-device":
150575
+ case "no-catalog-row": return 404;
150576
+ case "sleeping":
150577
+ case "uid-missing":
150578
+ case "clip-in-progress": return 409;
150579
+ case "camera-refused":
150580
+ case "fetch-failed": return 502;
150581
+ }
150582
+ }
150583
+ function createClipStreamHandler(deps) {
150584
+ const resolveTarget = deps.target ?? resolveClipStreamPathTarget;
150585
+ return async (req, res) => {
150586
+ if (req.method !== "GET" && req.method !== "HEAD") {
150587
+ res.writeHead(405, { allow: "GET, HEAD" });
150588
+ res.end();
150589
+ return;
150590
+ }
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
+ });
150602
+ res.end();
150603
+ return;
150604
+ }
150605
+ const { deviceId, clipKey } = resolved.target;
150606
+ if (req.method === "HEAD") {
150607
+ res.writeHead(200, {
150608
+ "content-type": "video/mp4",
150609
+ ...STREAM_HEADERS
150610
+ });
150611
+ res.end();
150612
+ return;
150613
+ }
150614
+ if (req.headers.range !== void 0) deps.logger.debug("videoclips: a Range header on the clip stream route is ignored", {
150615
+ tags: { deviceId },
150616
+ meta: {
150617
+ clipKey,
150618
+ range: req.headers.range
150619
+ }
150620
+ });
150621
+ const sink = {
150622
+ begin: (contentType) => {
150623
+ res.writeHead(200, {
150624
+ "content-type": contentType,
150625
+ ...STREAM_HEADERS
150626
+ });
150627
+ },
150628
+ write: (chunk) => res.write(chunk),
150629
+ onceDrain: (listener) => {
150630
+ res.once("drain", listener);
150631
+ },
150632
+ onceClose: (listener) => {
150633
+ res.once("close", listener);
150634
+ },
150635
+ end: () => {
150636
+ res.end();
150637
+ },
150638
+ destroy: () => {
150639
+ res.destroy();
150640
+ }
150641
+ };
150642
+ let outcome;
150643
+ try {
150644
+ outcome = await deps.stream(resolved.target, sink);
150645
+ } catch (err) {
150646
+ deps.logger.warn("videoclips: the clip stream route threw", {
150647
+ tags: { deviceId },
150648
+ meta: {
150649
+ clipKey,
150650
+ branch: "stream-threw",
150651
+ error: err instanceof Error ? err.message : String(err)
150652
+ }
150653
+ });
150654
+ if (res.headersSent) res.destroy();
150655
+ else {
150656
+ res.writeHead(500);
150657
+ res.end();
150658
+ }
150659
+ return;
150660
+ }
150661
+ if (outcome.kind !== "refused") return;
150662
+ deps.logger.info("videoclips: clip stream not served", {
150663
+ tags: { deviceId },
150664
+ meta: {
150665
+ clipKey,
150666
+ code: outcome.code,
150667
+ reason: outcome.detail,
150668
+ status: streamRefusalStatus(outcome.code)
150669
+ }
150670
+ });
150671
+ res.writeHead(streamRefusalStatus(outcome.code), {
150672
+ [CLIP_STILL_REASON_HEADER]: outcome.detail.slice(0, 500),
150673
+ [CLIP_STILL_REASON_CODE_HEADER]: outcome.code
150674
+ });
150675
+ res.end();
150676
+ };
150677
+ }
149048
150678
  //#endregion
149049
150679
  //#region src/clips/clip-mux.ts
149050
150680
  /** A clip that has not transferred by here is not going to. */
@@ -149202,6 +150832,147 @@ function describeMuxFailure(run) {
149202
150832
  if (run.code !== 0) return `mux-failed (exit ${String(run.code)}): ${run.stderr.slice(0, 300)}`;
149203
150833
  return null;
149204
150834
  }
150835
+ /** The fragmented container a stream is muxed into — see the file header. */
150836
+ var CLIP_STREAM_MOVFLAGS = "frag_keyframe+empty_moov+default_base_moof";
150837
+ /**
150838
+ * The rate the probe measured, through the library's one derivation.
150839
+ *
150840
+ * `estimateVideoTiming` falls back to the info header when the timestamps
150841
+ * cannot say; that fallback is REFUSED here (`fpsSource: 'unmeasured'`), as
150842
+ * D567 says, so a guess is never handed to `-r` under a measurement's name.
150843
+ */
150844
+ function probeClipStreamRate(timestampsUs) {
150845
+ const timing = estimateVideoTiming({ timestampsUs });
150846
+ if (timing.fpsSource !== "timestamps" || timing.fps === null) return {
150847
+ fps: null,
150848
+ fpsSource: "unmeasured",
150849
+ accessUnits: timestampsUs.length
150850
+ };
150851
+ return {
150852
+ fps: timing.fps,
150853
+ fpsSource: "timestamps",
150854
+ accessUnits: timestampsUs.length
150855
+ };
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;
150861
+ /**
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.
150879
+ *
150880
+ * Mirrors `buildClipMuxArgs` where a stream can: the demuxer is named from
150881
+ * the DETECTED codec (the BcMedia header lies on a hub child), `-r` is an
150882
+ * input option before its own `-i`, and the video is copied. Where it
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.
150886
+ */
150887
+ function buildClipStreamMuxArgs(input) {
150888
+ const demuxer = input.videoType === "H265" ? "hevc" : "h264";
150889
+ const audio = input.audio;
150890
+ return [
150891
+ "-hide_banner",
150892
+ "-loglevel",
150893
+ "error",
150894
+ "-nostdin",
150895
+ ...input.fps !== null && Number.isFinite(input.fps) && input.fps > 0 ? ["-r", String(input.fps)] : [],
150896
+ "-f",
150897
+ demuxer,
150898
+ "-i",
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
+ ] : [],
150912
+ "-c:v",
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
+ ] : [],
150932
+ "-movflags",
150933
+ CLIP_STREAM_MOVFLAGS,
150934
+ "-f",
150935
+ clipStreamContainerFor(audio),
150936
+ "pipe:1"
150937
+ ];
150938
+ }
150939
+ /**
150940
+ * The D447 rule as a state machine, so it can be proved without a socket.
150941
+ *
150942
+ * A `false` write ARMS the timer; further `false` writes while it is armed do
150943
+ * not push the deadline out. `drain` — the hop's own word that it took what
150944
+ * it held — disarms it. Stall fires at most once, and a disposed gate is
150945
+ * silent for ever.
150946
+ */
150947
+ function createDrainGate(deps) {
150948
+ let handle = null;
150949
+ let stalled = false;
150950
+ const disarm = () => {
150951
+ if (handle !== null) clearTimeout(handle);
150952
+ handle = null;
150953
+ };
150954
+ return {
150955
+ wrote(accepted) {
150956
+ if (accepted || handle !== null || stalled) return;
150957
+ handle = setTimeout(() => {
150958
+ handle = null;
150959
+ if (stalled) return;
150960
+ stalled = true;
150961
+ deps.onStall();
150962
+ }, deps.timeoutMs);
150963
+ },
150964
+ drained() {
150965
+ disarm();
150966
+ },
150967
+ waiting() {
150968
+ return handle !== null;
150969
+ },
150970
+ dispose() {
150971
+ disarm();
150972
+ stalled = true;
150973
+ }
150974
+ };
150975
+ }
149205
150976
  //#endregion
149206
150977
  //#region src/clips/clip-truncation.ts
149207
150978
  /**
@@ -149248,6 +151019,885 @@ function judgeClipDuration(input) {
149248
151019
  };
149249
151020
  }
149250
151021
  //#endregion
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
+ }
151032
+ var defaultDecoder = (handlers) => new BcMediaAnnexBDecoder({
151033
+ strict: false,
151034
+ onVideoAccessUnit: handlers.onVideoAccessUnit,
151035
+ onAudioFrame: (frame) => handlers.onAudioFrame({
151036
+ audioType: frame.audioType,
151037
+ data: frame.data
151038
+ })
151039
+ });
151040
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
151041
+ function createClipStreamProducer(deps) {
151042
+ const sleep = deps.sleep ?? defaultSleep;
151043
+ const createDecoder = deps.createDecoder ?? defaultDecoder;
151044
+ const drainTimeoutMs = deps.drainTimeoutMs ?? 1e4;
151045
+ const flushTimeoutMs = deps.flushTimeoutMs ?? 15e3;
151046
+ const lanes = /* @__PURE__ */ new Map();
151047
+ const activeByHost = /* @__PURE__ */ new Map();
151048
+ const runAttempt = (input, attempt) => new ClipStreamRun({
151049
+ logger: deps.logger,
151050
+ spawnMux: deps.spawnMux,
151051
+ createDecoder,
151052
+ drainTimeoutMs,
151053
+ flushTimeoutMs
151054
+ }, input, attempt).run();
151055
+ const runAttempts = async (input) => {
151056
+ const { deviceId } = input.device;
151057
+ let lastDetail = "no reason given";
151058
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
151059
+ if (attempt > 1) await sleep(500 * 2 ** (attempt - 2));
151060
+ const result = await runAttempt(input, attempt);
151061
+ if (result.kind !== "no-media") return result;
151062
+ lastDetail = result.detail;
151063
+ deps.logger.warn("videoclips: a clip stream produced no media — retrying", {
151064
+ tags: { deviceId },
151065
+ meta: {
151066
+ clipKey: input.row.clipKey,
151067
+ twin: input.twin,
151068
+ branch: "stream-no-media",
151069
+ attempt,
151070
+ error: lastDetail
151071
+ }
151072
+ });
151073
+ }
151074
+ deps.logger.warn("videoclips: the camera never delivered this clip stream", {
151075
+ tags: { deviceId },
151076
+ meta: {
151077
+ clipKey: input.row.clipKey,
151078
+ twin: input.twin,
151079
+ branch: "camera-refused",
151080
+ attempts: 3,
151081
+ error: lastDetail
151082
+ }
151083
+ });
151084
+ return {
151085
+ kind: "refused",
151086
+ code: "camera-refused",
151087
+ detail: `clip stream never landed after 3 attempts — ${lastDetail}`,
151088
+ attempts: 3
151089
+ };
151090
+ };
151091
+ return {
151092
+ active: (hostKey) => activeByHost.get(hostKey) ?? 0,
151093
+ stream: (input) => {
151094
+ const { hostKey, deviceId } = input.device;
151095
+ const ahead = activeByHost.get(hostKey) ?? 0;
151096
+ if (ahead > 0) deps.logger.info("videoclips: a clip stream is queued behind another on this host", {
151097
+ tags: { deviceId },
151098
+ meta: {
151099
+ clipKey: input.row.clipKey,
151100
+ twin: input.twin,
151101
+ branch: "stream-queued",
151102
+ ahead
151103
+ }
151104
+ });
151105
+ activeByHost.set(hostKey, ahead + 1);
151106
+ const run = (lanes.get(hostKey) ?? Promise.resolve()).then(() => runAttempts(input)).finally(() => {
151107
+ const left = (activeByHost.get(hostKey) ?? 1) - 1;
151108
+ if (left <= 0) activeByHost.delete(hostKey);
151109
+ else activeByHost.set(hostKey, left);
151110
+ });
151111
+ lanes.set(hostKey, run.then(() => void 0, () => void 0));
151112
+ return run;
151113
+ }
151114
+ };
151115
+ }
151116
+ /**
151117
+ * One transfer: camera → decoder → ffmpeg → peer, with a cut on every hop.
151118
+ *
151119
+ * A class because the run is a state machine with a dozen fields the chunk
151120
+ * callback and the download promise both touch; passing them around as
151121
+ * closures over `let`s was less readable, not more immutable.
151122
+ */
151123
+ var ClipStreamRun = class {
151124
+ deps;
151125
+ input;
151126
+ attempt;
151127
+ accessUnits = 0;
151128
+ /** Every audio frame the decoder handed over, of any codec. */
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;
151138
+ timestampsUs = [];
151139
+ /** Access units held until the rate is probed — the frames in flight. */
151140
+ probe = [];
151141
+ mux = null;
151142
+ muxStderr = "";
151143
+ muxClosed = null;
151144
+ rate = {
151145
+ fps: null,
151146
+ fpsSource: "unmeasured",
151147
+ accessUnits: 0
151148
+ };
151149
+ videoType = null;
151150
+ begun = false;
151151
+ ended = false;
151152
+ bytesOut = 0;
151153
+ cut = null;
151154
+ stdinGate;
151155
+ audioStdinGate;
151156
+ peerGate;
151157
+ tags;
151158
+ constructor(deps, input, attempt) {
151159
+ this.deps = deps;
151160
+ this.input = input;
151161
+ this.attempt = attempt;
151162
+ this.tags = { deviceId: input.device.deviceId };
151163
+ this.stdinGate = createDrainGate({
151164
+ timeoutMs: deps.drainTimeoutMs,
151165
+ onStall: () => this.sever("mux-stalled", "ffmpeg stopped taking access units")
151166
+ });
151167
+ this.audioStdinGate = createDrainGate({
151168
+ timeoutMs: deps.drainTimeoutMs,
151169
+ onStall: () => this.sever("mux-audio-stalled", "ffmpeg stopped taking audio frames")
151170
+ });
151171
+ this.peerGate = createDrainGate({
151172
+ timeoutMs: deps.drainTimeoutMs,
151173
+ onStall: () => this.sever("peer-stalled", "the peer stopped taking bytes")
151174
+ });
151175
+ }
151176
+ async run() {
151177
+ const { device, row, fileName, twin, sink } = this.input;
151178
+ sink.onceClose(() => {
151179
+ if (!this.ended) this.sever("peer-closed", "the peer closed the connection");
151180
+ });
151181
+ const decoder = this.deps.createDecoder({
151182
+ onVideoAccessUnit: (unit) => this.onAccessUnit(unit),
151183
+ onAudioFrame: (frame) => this.onAudioFrame(frame)
151184
+ });
151185
+ this.deps.logger.info("videoclips: streaming a clip file from the camera", {
151186
+ tags: this.tags,
151187
+ meta: {
151188
+ clipKey: row.clipKey,
151189
+ twin,
151190
+ fileName,
151191
+ startMs: row.startMs,
151192
+ endMs: row.endMs,
151193
+ attempt: this.attempt,
151194
+ audioAsked: this.input.audio,
151195
+ idleTimeoutMs: 300
151196
+ }
151197
+ });
151198
+ let api;
151199
+ try {
151200
+ api = await device.getApi();
151201
+ } catch (err) {
151202
+ return {
151203
+ kind: "no-media",
151204
+ detail: describe(err)
151205
+ };
151206
+ }
151207
+ try {
151208
+ await api.fileInfoListReplayBinaryDownload({
151209
+ channel: device.channel,
151210
+ ...device.uid !== void 0 ? { uid: device.uid } : {},
151211
+ fileName,
151212
+ idleTimeoutMs: 300,
151213
+ onChunk: (chunk) => this.onChunk(decoder, chunk)
151214
+ });
151215
+ } catch (err) {
151216
+ return this.onTransferFailed(err);
151217
+ }
151218
+ return this.onTransferEnded();
151219
+ }
151220
+ /** The camera's bytes, as they arrive. A cut run THROWS here: the only stop. */
151221
+ onChunk(decoder, chunk) {
151222
+ if (this.cut !== null) throw new Error(`clip stream cut: ${this.cut.reason}`);
151223
+ try {
151224
+ decoder.push(chunk);
151225
+ } catch (err) {
151226
+ this.sever("decode-failed", describe(err));
151227
+ throw err;
151228
+ }
151229
+ }
151230
+ onAccessUnit(unit) {
151231
+ if (this.cut !== null) return;
151232
+ this.accessUnits += 1;
151233
+ this.timestampsUs.push(unit.microseconds);
151234
+ if (this.mux === null) {
151235
+ this.probe = [...this.probe, unit];
151236
+ if (this.probe.length >= 16) this.startMux();
151237
+ return;
151238
+ }
151239
+ this.feed(unit.annexB);
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
+ }
151296
+ /** Spawn ffmpeg with the probed rate and hand it what was held. */
151297
+ startMux() {
151298
+ const first = this.probe[0];
151299
+ if (first === void 0 || this.mux !== null) return;
151300
+ this.videoType = first.videoType;
151301
+ this.rate = probeClipStreamRate(this.timestampsUs);
151302
+ this.decideAudio();
151303
+ const { row, twin } = this.input;
151304
+ if (this.rate.fpsSource !== "timestamps") this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
151305
+ tags: this.tags,
151306
+ meta: {
151307
+ clipKey: row.clipKey,
151308
+ twin,
151309
+ branch: "fps-unmeasured",
151310
+ probeAccessUnits: this.rate.accessUnits
151311
+ }
151312
+ });
151313
+ let mux;
151314
+ try {
151315
+ mux = this.deps.spawnMux(buildClipStreamMuxArgs({
151316
+ videoType: this.videoType,
151317
+ fps: this.rate.fps,
151318
+ audio: this.audioMuxed
151319
+ }));
151320
+ } catch (err) {
151321
+ this.sever("mux-failed", `ffmpeg could not be spawned: ${describe(err)}`);
151322
+ return;
151323
+ }
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
+ }
151344
+ this.muxClosed = new Promise((resolve) => {
151345
+ mux.on("close", (code) => resolve(code));
151346
+ mux.on("error", (err) => {
151347
+ this.sever("mux-failed", `ffmpeg failed: ${err.message}`);
151348
+ resolve(null);
151349
+ });
151350
+ });
151351
+ mux.stderr?.on("data", (chunk) => {
151352
+ if (this.muxStderr.length < 2e3) this.muxStderr += chunk.toString("utf8");
151353
+ });
151354
+ mux.stdin?.on("error", (err) => this.sever("mux-failed", `ffmpeg stdin: ${err.message}`));
151355
+ mux.stdin?.on("drain", () => this.stdinGate.drained());
151356
+ mux.stdout?.on("data", (chunk) => this.onMuxOut(chunk));
151357
+ mux.stdout?.on("end", () => this.finishSink());
151358
+ this.deps.logger.info("videoclips: a clip stream is muxing", {
151359
+ tags: this.tags,
151360
+ meta: {
151361
+ clipKey: row.clipKey,
151362
+ twin,
151363
+ videoType: this.videoType,
151364
+ fps: this.rate.fps,
151365
+ fpsSource: this.rate.fpsSource,
151366
+ probeAccessUnits: this.rate.accessUnits,
151367
+ audioAsked: this.input.audio,
151368
+ audioMuxed: this.audioMuxed,
151369
+ audioSilentReason: this.audioSilentReason,
151370
+ probeAudioFrames: this.audioProbe.length
151371
+ }
151372
+ });
151373
+ const held = this.probe;
151374
+ this.probe = [];
151375
+ const heldAudio = this.audioProbe;
151376
+ this.audioProbe = [];
151377
+ this.audioProbeBytes = 0;
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);
151381
+ }
151382
+ feed(annexB) {
151383
+ if (this.cut !== null || this.mux?.stdin === null || this.mux?.stdin === void 0) return;
151384
+ this.stdinGate.wrote(this.mux.stdin.write(annexB));
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
+ }
151391
+ /** ffmpeg's output, to the peer, honouring `write()`'s return (D447). */
151392
+ onMuxOut(chunk) {
151393
+ if (this.cut !== null) return;
151394
+ const { sink } = this.input;
151395
+ if (!this.begun) {
151396
+ this.begun = true;
151397
+ sink.begin(clipStreamContentTypeFor(this.audioMuxed));
151398
+ }
151399
+ const accepted = sink.write(chunk);
151400
+ this.bytesOut += chunk.byteLength;
151401
+ this.peerGate.wrote(accepted);
151402
+ if (accepted) return;
151403
+ const stdout = this.mux?.stdout;
151404
+ stdout?.pause();
151405
+ sink.onceDrain(() => {
151406
+ this.peerGate.drained();
151407
+ stdout?.resume();
151408
+ });
151409
+ }
151410
+ finishSink() {
151411
+ if (this.cut !== null || this.ended) return;
151412
+ this.ended = true;
151413
+ this.input.sink.end();
151414
+ }
151415
+ /** Sever every hop, once, and say why. */
151416
+ sever(reason, detail) {
151417
+ if (this.cut !== null) return;
151418
+ this.cut = {
151419
+ reason,
151420
+ detail
151421
+ };
151422
+ this.stdinGate.dispose();
151423
+ this.audioStdinGate.dispose();
151424
+ this.peerGate.dispose();
151425
+ this.mux?.kill("SIGKILL");
151426
+ this.deps.logger.warn("videoclips: a clip stream was cut", {
151427
+ tags: this.tags,
151428
+ meta: {
151429
+ clipKey: this.input.row.clipKey,
151430
+ twin: this.input.twin,
151431
+ branch: reason,
151432
+ detail,
151433
+ accessUnits: this.accessUnits,
151434
+ bytesOut: this.bytesOut,
151435
+ begun: this.begun,
151436
+ audioMuxed: this.audioMuxed,
151437
+ audioFramesIn: this.audioFrames,
151438
+ audioFramesDropped: this.audioFramesDropped
151439
+ }
151440
+ });
151441
+ if (this.begun) this.input.sink.destroy();
151442
+ }
151443
+ cutOutcome(cut) {
151444
+ if (this.begun) return {
151445
+ kind: "cut",
151446
+ reason: cut.reason,
151447
+ detail: cut.detail,
151448
+ bytesOut: this.bytesOut
151449
+ };
151450
+ return {
151451
+ kind: "refused",
151452
+ code: "fetch-failed",
151453
+ detail: `${cut.reason}: ${cut.detail}`,
151454
+ attempts: this.attempt
151455
+ };
151456
+ }
151457
+ onTransferFailed(err) {
151458
+ if (this.cut !== null) return this.cutOutcome(this.cut);
151459
+ if (this.accessUnits === 0) return {
151460
+ kind: "no-media",
151461
+ detail: describe(err)
151462
+ };
151463
+ this.sever("camera-failed", describe(err));
151464
+ return this.cutOutcome({
151465
+ reason: "camera-failed",
151466
+ detail: describe(err)
151467
+ });
151468
+ }
151469
+ async onTransferEnded() {
151470
+ if (this.cut !== null) return this.cutOutcome(this.cut);
151471
+ if (this.accessUnits === 0) return {
151472
+ kind: "no-media",
151473
+ detail: "the camera answered the transfer with no access unit"
151474
+ };
151475
+ if (this.mux === null) this.startMux();
151476
+ const cutAtStart = this.cut;
151477
+ if (cutAtStart !== null) return this.cutOutcome(cutAtStart);
151478
+ const mux = this.mux;
151479
+ if (mux === null || this.muxClosed === null) return this.cutOutcome({
151480
+ reason: "mux-failed",
151481
+ detail: "no mux after the transfer ended"
151482
+ });
151483
+ mux.stdin?.end();
151484
+ this.audioIn?.end();
151485
+ const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
151486
+ const code = await this.muxClosed;
151487
+ clearTimeout(flush);
151488
+ const cutAfter = this.cut;
151489
+ if (cutAfter !== null) return this.cutOutcome(cutAfter);
151490
+ if (code !== 0) {
151491
+ this.sever("mux-failed", `ffmpeg exited ${String(code)}: ${this.muxStderr.slice(0, 300)}`);
151492
+ return this.cutOutcome({
151493
+ reason: "mux-failed",
151494
+ detail: this.muxStderr.slice(0, 300)
151495
+ });
151496
+ }
151497
+ this.finishSink();
151498
+ return this.served();
151499
+ }
151500
+ /** The stream landed: judge it against the row, out loud, and answer. */
151501
+ served() {
151502
+ const { row, twin } = this.input;
151503
+ const timing = estimateVideoTiming({ timestampsUs: this.timestampsUs });
151504
+ const durationSeconds = timing.fpsSource === "timestamps" ? timing.durationSeconds : null;
151505
+ const verdict = judgeClipDuration({
151506
+ expectedMs: row.endMs - row.startMs,
151507
+ decodedSeconds: durationSeconds,
151508
+ ...row.inProgress === true ? { inProgress: true } : {}
151509
+ });
151510
+ const audio = this.audioReport();
151511
+ const meta = {
151512
+ clipKey: row.clipKey,
151513
+ twin,
151514
+ attempt: this.attempt,
151515
+ accessUnits: this.accessUnits,
151516
+ audioMuxed: audio.muxed,
151517
+ bytesOut: this.bytesOut,
151518
+ durationSeconds,
151519
+ expectedMs: row.endMs - row.startMs,
151520
+ fps: this.rate.fps,
151521
+ fpsSource: this.rate.fpsSource,
151522
+ videoType: this.videoType
151523
+ };
151524
+ if (verdict.kind === "short") this.deps.logger.warn("videoclips: a clip stream came up short against its row", {
151525
+ tags: this.tags,
151526
+ meta: {
151527
+ ...meta,
151528
+ branch: "stream-short",
151529
+ shortfallSeconds: verdict.shortfallSeconds,
151530
+ retried: false
151531
+ }
151532
+ });
151533
+ else if (verdict.kind === "unknown") this.deps.logger.warn("videoclips: a clip stream could not be checked for truncation", {
151534
+ tags: this.tags,
151535
+ meta: {
151536
+ ...meta,
151537
+ branch: "completeness-unverified",
151538
+ reason: verdict.reason
151539
+ }
151540
+ });
151541
+ else this.deps.logger.info("videoclips: a clip stream landed whole", {
151542
+ tags: this.tags,
151543
+ meta: {
151544
+ ...meta,
151545
+ branch: "stream-served"
151546
+ }
151547
+ });
151548
+ this.reportAudio(audio);
151549
+ return {
151550
+ kind: "served",
151551
+ accessUnits: this.accessUnits,
151552
+ audio,
151553
+ bytesOut: this.bytesOut,
151554
+ durationSeconds,
151555
+ verdict,
151556
+ rate: this.rate,
151557
+ attempts: this.attempt
151558
+ };
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
+ }
151629
+ };
151630
+ function describe(err) {
151631
+ return err instanceof Error ? err.message : String(err);
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
+ }
151818
+ //#endregion
151819
+ //#region src/clips/clip-stream-route.ts
151820
+ function createClipStreamRoute(deps) {
151821
+ return async (target, sink) => {
151822
+ const { deviceId, clipKey, twin, audio } = target;
151823
+ const gate = await deps.gate({
151824
+ deviceId,
151825
+ clipKey,
151826
+ twin,
151827
+ path: "stream"
151828
+ });
151829
+ if (gate.kind === "refused") return {
151830
+ kind: "refused",
151831
+ code: gate.branch,
151832
+ detail: gate.detail
151833
+ };
151834
+ const row = deps.rowFor(deviceId, clipKey);
151835
+ if (row === null) {
151836
+ deps.logger.warn("videoclips: no catalog row for this clip stream", {
151837
+ tags: { deviceId },
151838
+ meta: {
151839
+ clipKey,
151840
+ twin,
151841
+ branch: "no-catalog-row"
151842
+ }
151843
+ });
151844
+ return {
151845
+ kind: "refused",
151846
+ code: "no-catalog-row",
151847
+ detail: "no-catalog-row: list this day before asking for its stream"
151848
+ };
151849
+ }
151850
+ if (row.inProgress === true) {
151851
+ deps.logger.warn("videoclips: refusing to stream a clip the camera is still writing", {
151852
+ tags: { deviceId },
151853
+ meta: {
151854
+ clipKey,
151855
+ twin,
151856
+ branch: "clip-in-progress"
151857
+ }
151858
+ });
151859
+ return {
151860
+ kind: "refused",
151861
+ code: "clip-in-progress",
151862
+ detail: "clip-in-progress: the camera is still writing this file; it has no end to stream to"
151863
+ };
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
+ }
151881
+ const outcome = await deps.producer.stream({
151882
+ device: gate.device,
151883
+ row,
151884
+ fileName,
151885
+ twin,
151886
+ audio,
151887
+ sink
151888
+ });
151889
+ switch (outcome.kind) {
151890
+ case "served": return { kind: "served" };
151891
+ case "cut": return { kind: "cut" };
151892
+ case "refused": return {
151893
+ kind: "refused",
151894
+ code: outcome.code,
151895
+ detail: outcome.detail
151896
+ };
151897
+ }
151898
+ };
151899
+ }
151900
+ //#endregion
149251
151901
  //#region src/clips/clip-thumb-queue.ts
149252
151902
  /**
149253
151903
  * The provider's OWN CoverPreview lane — two queues, one in flight per host.
@@ -149427,210 +152077,58 @@ function decideClipPrefetch(input) {
149427
152077
  return { kind: "run" };
149428
152078
  }
149429
152079
  //#endregion
149430
- //#region src/clips/clip-identity.ts
152080
+ //#region src/clips/clip-window.ts
149431
152081
  /**
149432
- * A Reolink clip's IDENTITY — the id the operator's surface carries around and
149433
- * the key its cached bytes are filed under.
152082
+ * Does this row overlap the window?
149434
152083
  *
149435
- * `native:reolink:<source>:<channel>:<base64url(fileName)>` (D549 3). It is
149436
- * self-contained on purpose: the thumbnail route, the playback route and the
149437
- * collection dispatcher each resolve a clip from the id alone, so none of them
149438
- * re-lists the camera to answer a click. The source is INSIDE the id because
149439
- * D554 5 routes a scalar cap call by the longest source that prefixes it, and
149440
- * because an id naming a source this provider no longer serves must be
149441
- * REFUSED rather than routed by prefix luck.
152084
+ * Half-open on both ends (`start < until`, `end > since`), so a clip that ends
152085
+ * exactly when the window begins is OUT and a clip that begins exactly when it
152086
+ * ends is OUT — otherwise two adjacent windows both claim the same boundary
152087
+ * clip and a surface paging through them renders it twice.
149442
152088
  *
149443
- * base64url, not base64: a hub's `<Id>` is a real path
149444
- * (`/mnt/sda/U10<uid>-<name>/RecS02_…mp4`), and `/` and `+` in a URL path
149445
- * segment are a different path or a space.
152089
+ * A row whose `endMs` is not after its `startMs` is treated as an INSTANT at
152090
+ * `startMs`. That is not defensive padding: a clip still being written carries
152091
+ * a frozen `endMs` from the camera's index (D563), and on this fleet that has
152092
+ * been observed equal to — and once behind — its own start. Judging such a row
152093
+ * by a bad end would drop a recording that is happening right now.
149446
152094
  */
152095
+ function clipOverlapsWindow(row, window) {
152096
+ const end = row.endMs > row.startMs ? row.endMs : row.startMs;
152097
+ return row.startMs < window.until && end > window.since;
152098
+ }
149447
152099
  /**
149448
- * The camera's OWN recordings — the ONE Reolink source.
152100
+ * What the provider ANSWERS with — the one gate, so there is a single place
152101
+ * that decides what leaves this listing and a single line that reports it.
149449
152102
  *
149450
- * It was two (`onboard` for the files view, `hub` for the parent's event
149451
- * view, D549 11) for a few hours, on the premise that the two might hold
149452
- * DIFFERENT sets. Our own measurement disproved that premise: the hub's uid
149453
- * addresses no catalog at all (empty month over 24 months, zero files, zero
149454
- * rows) while the child's uid addresses all of it, cmd 5 serves identical
149455
- * bytes under either, and on 3628 the two views cover each other almost
149456
- * exactly — 29 events, 28 inside the 20 listed files, and all 20 files
149457
- * carrying an event. They are two VIEWS of one store.
152103
+ * ## Why a clip still being written is not listed
149458
152104
  *
149459
- * So the operator collapsed them (2026-09-20): one source, which picked the
149460
- * best API for what the camera IS, and REPORTED the pick on `ClipSource.via`.
149461
- * Later the same day the pick went too (D565): `FileInfoList` is the catalog
149462
- * on every topology, so `via` had one value and is gone. What is left is one
149463
- * source reading one API — and `clipKeyFor` below is still keyed off
149464
- * `(channel, fileName)` rather than the source, which is now simply true
149465
- * rather than a concession.
149466
- */
149467
- var CLIP_SOURCE_ONBOARD = "native:reolink:onboard";
149468
- /** Every source this provider can name. Order is the picker's order. */
149469
- var REOLINK_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
149470
- function encodeFileName(fileName) {
149471
- return Buffer.from(fileName, "utf8").toString("base64url");
149472
- }
149473
- function mintClipId(parts) {
149474
- const base = `${parts.source}:${parts.channel}:${encodeFileName(parts.fileName)}`;
149475
- return parts.window === void 0 ? base : `${base}:${parts.window.startMs}-${parts.window.endMs}`;
149476
- }
149477
- /**
149478
- * Parse an id this provider minted. `null` for anything else — including an id
149479
- * from the analytics or HKSV source, which is how the collection dispatcher's
149480
- * "nobody claimed this id" branch stays honest.
149481
- */
149482
- function parseClipId(clipId) {
149483
- const source = REOLINK_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
149484
- if (source === void 0) return null;
149485
- const rest = clipId.slice(source.length + 1);
149486
- const sep = rest.indexOf(":");
149487
- if (sep <= 0) return null;
149488
- const channel = Number(rest.slice(0, sep));
149489
- if (!Number.isInteger(channel) || channel < 0) return null;
149490
- const payload = rest.slice(sep + 1);
149491
- if (payload === "") return null;
149492
- const windowSep = payload.indexOf(":");
149493
- const encoded = windowSep === -1 ? payload : payload.slice(0, windowSep);
149494
- if (encoded === "") return null;
149495
- const fileName = Buffer.from(encoded, "base64url").toString("utf8");
149496
- if (fileName === "") return null;
149497
- if (windowSep === -1) return {
149498
- source,
149499
- channel,
149500
- fileName
149501
- };
149502
- const match = /^(\d+)-(\d+)$/.exec(payload.slice(windowSep + 1));
149503
- if (match === null) return null;
149504
- return {
149505
- source,
149506
- channel,
149507
- fileName,
149508
- window: {
149509
- startMs: Number(match[1]),
149510
- endMs: Number(match[2])
149511
- }
149512
- };
149513
- }
149514
- /**
149515
- * The on-disk key for a clip's derived artifacts (its thumbnail, its muxed
149516
- * MP4). Derived from `(channel, fileName)` and NOT from the source: a
149517
- * thumbnail belongs to the FILE, so any surface that names the same file
149518
- * finds the same picture and the camera is never asked for a second
149519
- * CoverPreview of it.
152105
+ * The operator, 2026-09-22: *"Niente clips in preparazione, non ha senso
152106
+ * visualizzarle."* He is right, and the reasons had been accumulating:
149520
152107
  *
149521
- * A hash rather than the encoded name because a hub `<Id>` is a full path and
149522
- * the encoded form is long enough to hit a file-name limit.
149523
- */
149524
- function clipKeyFor(channel, fileName) {
149525
- return createHash("sha1").update(`${channel}${fileName}`, "utf8").digest("hex");
149526
- }
149527
- //#endregion
149528
- //#region src/clips/clip-native-types.ts
149529
- /** Cap on `ClipSchema.labels`, mirrored from `@camstack/types` MAX_CLIP_LABELS. */
149530
- var MAX_LABELS = 3;
149531
- /** Native → object class. The same rule `native-detection-mapping.ts` applies
149532
- * to live boxes, so a clip and a live box never disagree about a dog. */
149533
- var CLASS_BY_NATIVE = {
149534
- people: "person",
149535
- face: "person",
149536
- visitor: "person",
149537
- vehicle: "vehicle",
149538
- nonmotorveh: "vehicle",
149539
- dog_cat: "animal",
149540
- package: "package"
149541
- };
149542
- /** Natives that state a KIND rather than a class. */
149543
- var KIND_BY_NATIVE = {
149544
- md: "motion",
149545
- pir: "motion",
149546
- cry: "audio",
149547
- other: "motion"
149548
- };
149549
- /** What the subject DID — carried beside a class, never as one. */
149550
- var BEHAVIOUR_BY_NATIVE = {
149551
- crossline: "crossline",
149552
- intrude: "intrude",
149553
- intrusion: "intrude",
149554
- loitering: "loitering",
149555
- visitor: "doorbell"
149556
- };
149557
- /**
149558
- * Recording REASONS — why the file exists, not what is in it. They get no
149559
- * label and no behaviour; the row still lists, showing the reason raw.
149560
- * `none` is the camera declining to classify, which is not the same as the
149561
- * camera saying "nothing". `other` used to live here and is now a motion KIND
149562
- * — see {@link KIND_BY_NATIVE}.
149563
- */
149564
- var RECORDING_REASONS = new Set([
149565
- "sched",
149566
- "manual",
149567
- "io",
149568
- "IPCInputOutput",
149569
- "legacy",
149570
- "loss",
149571
- "answer",
149572
- "tamper",
149573
- "lmsg",
149574
- "none"
149575
- ]);
149576
- /**
149577
- * Ordering for `labels` — "dominant first". A person outranks a vehicle
149578
- * outranks an animal outranks a package; anything unknown sorts last so a
149579
- * newer firmware's invention never displaces a class the operator filters on.
152108
+ * - its `endMs` is a frozen guess — the camera's index keeps whatever it held
152109
+ * when the file was opened, measured on 592 as the same `endTime` for eight
152110
+ * minutes (D563) — so the row's duration, its position on the axis and its
152111
+ * height are all wrong and all move;
152112
+ * - `readClipBytes` already refuses it by name (`clip-in-progress`): it has no
152113
+ * end to export against, so the completion rule has nothing to prove the
152114
+ * fetch finished (D568);
152115
+ * - it cannot be looped, and the surface has to explain why (`resolveClipLoop`);
152116
+ * - the player route would file a TRUNCATED copy under an immutable key, so
152117
+ * the first seconds become permanently what that clip is.
152118
+ *
152119
+ * Four different places bending around a row that cannot be played properly.
152120
+ * Not listing it deletes all four questions instead of answering them: the
152121
+ * clip appears, whole, on the next listing after the camera closes the file.
149580
152122
  */
149581
- var CLASS_RANK = [
149582
- "person",
149583
- "vehicle",
149584
- "animal",
149585
- "package"
149586
- ];
149587
- /** Split the `recordType` CSV a file carries into raw types, de-duplicated,
149588
- * in wire order. Case is NOT normalised: `IPCInputOutput` is what the camera
149589
- * said and what `nativeTypes` must carry back. */
149590
- function parseNativeTypes(recordType) {
149591
- if (recordType === void 0) return [];
149592
- const out = [];
149593
- for (const part of recordType.split(",")) {
149594
- const trimmed = part.trim();
149595
- if (trimmed === "" || out.includes(trimmed)) continue;
149596
- out.push(trimmed);
149597
- }
149598
- return out;
149599
- }
149600
- function classifyNativeTypes(nativeTypes) {
149601
- const labels = [];
149602
- const behaviours = [];
149603
- let kind = "native";
149604
- for (const raw of nativeTypes) {
149605
- const behaviour = BEHAVIOUR_BY_NATIVE[raw];
149606
- if (behaviour !== void 0 && !behaviours.includes(behaviour)) behaviours.push(behaviour);
149607
- const cls = CLASS_BY_NATIVE[raw];
149608
- if (cls !== void 0) {
149609
- if (!labels.includes(cls)) labels.push(cls);
149610
- continue;
149611
- }
149612
- const kindOf = KIND_BY_NATIVE[raw];
149613
- if (kindOf !== void 0) {
149614
- if (kind === "native") kind = kindOf;
149615
- continue;
149616
- }
149617
- if (RECORDING_REASONS.has(raw)) continue;
149618
- if (behaviour !== void 0) continue;
149619
- const unknown = `unknown:${raw}`;
149620
- if (!labels.includes(unknown)) labels.push(unknown);
149621
- }
149622
- if (labels.some((label) => !label.startsWith("unknown:"))) kind = "native";
152123
+ function listableClips(rows, window) {
152124
+ const inWindow = rows.filter((row) => clipOverlapsWindow(row, window));
152125
+ const kept = inWindow.filter((row) => row.inProgress !== true);
149623
152126
  return {
149624
- labels: labels.toSorted((a, b) => rankOf(a) - rankOf(b)).slice(0, MAX_LABELS),
149625
- behaviours,
149626
- nativeTypes: [...nativeTypes],
149627
- kind
152127
+ kept,
152128
+ outOfWindow: rows.length - inWindow.length,
152129
+ inProgress: inWindow.length - kept.length
149628
152130
  };
149629
152131
  }
149630
- function rankOf(label) {
149631
- const at = CLASS_RANK.indexOf(label);
149632
- return at === -1 ? CLASS_RANK.length : at;
149633
- }
149634
152132
  function pairTwins(subs, mains) {
149635
152133
  const datedSubs = subs.filter((f) => f.startTime instanceof Date);
149636
152134
  const datedMains = mains.filter((f) => f.startTime instanceof Date).toSorted((a, b) => startOf(a) - startOf(b));
@@ -149665,6 +152163,247 @@ function pairTwins(subs, mains) {
149665
152163
  function startOf(file) {
149666
152164
  return file.startTime?.getTime() ?? 0;
149667
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
+ }
152202
+ //#endregion
152203
+ //#region src/clips/clip-identity.ts
152204
+ /**
152205
+ * A Reolink clip's IDENTITY — the id the operator's surface carries around and
152206
+ * the key its cached bytes are filed under.
152207
+ *
152208
+ * `native:reolink:<source>:<channel>:<base64url(fileName)>` (D549 3). It is
152209
+ * self-contained on purpose: the thumbnail route, the playback route and the
152210
+ * collection dispatcher each resolve a clip from the id alone, so none of them
152211
+ * re-lists the camera to answer a click. The source is INSIDE the id because
152212
+ * D554 5 routes a scalar cap call by the longest source that prefixes it, and
152213
+ * because an id naming a source this provider no longer serves must be
152214
+ * REFUSED rather than routed by prefix luck.
152215
+ *
152216
+ * base64url, not base64: a hub's `<Id>` is a real path
152217
+ * (`/mnt/sda/U10<uid>-<name>/RecS02_…mp4`), and `/` and `+` in a URL path
152218
+ * segment are a different path or a space.
152219
+ */
152220
+ /**
152221
+ * The camera's OWN recordings — the ONE Reolink source.
152222
+ *
152223
+ * It was two (`onboard` for the files view, `hub` for the parent's event
152224
+ * view, D549 11) for a few hours, on the premise that the two might hold
152225
+ * DIFFERENT sets. Our own measurement disproved that premise: the hub's uid
152226
+ * addresses no catalog at all (empty month over 24 months, zero files, zero
152227
+ * rows) while the child's uid addresses all of it, cmd 5 serves identical
152228
+ * bytes under either, and on 3628 the two views cover each other almost
152229
+ * exactly — 29 events, 28 inside the 20 listed files, and all 20 files
152230
+ * carrying an event. They are two VIEWS of one store.
152231
+ *
152232
+ * So the operator collapsed them (2026-09-20): one source, which picked the
152233
+ * best API for what the camera IS, and REPORTED the pick on `ClipSource.via`.
152234
+ * Later the same day the pick went too (D565): `FileInfoList` is the catalog
152235
+ * on every topology, so `via` had one value and is gone. What is left is one
152236
+ * source reading one API — and `clipKeyFor` below is still keyed off
152237
+ * `(channel, fileName)` rather than the source, which is now simply true
152238
+ * rather than a concession.
152239
+ */
152240
+ var CLIP_SOURCE_ONBOARD = "native:reolink:onboard";
152241
+ /** Every source this provider can name. Order is the picker's order. */
152242
+ var REOLINK_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
152243
+ function encodeFileName(fileName) {
152244
+ return Buffer.from(fileName, "utf8").toString("base64url");
152245
+ }
152246
+ function mintClipId(parts) {
152247
+ const base = `${parts.source}:${parts.channel}:${encodeFileName(parts.fileName)}`;
152248
+ return parts.window === void 0 ? base : `${base}:${parts.window.startMs}-${parts.window.endMs}`;
152249
+ }
152250
+ /**
152251
+ * Parse an id this provider minted. `null` for anything else — including an id
152252
+ * from the analytics or HKSV source, which is how the collection dispatcher's
152253
+ * "nobody claimed this id" branch stays honest.
152254
+ */
152255
+ function parseClipId(clipId) {
152256
+ const source = REOLINK_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
152257
+ if (source === void 0) return null;
152258
+ const rest = clipId.slice(source.length + 1);
152259
+ const sep = rest.indexOf(":");
152260
+ if (sep <= 0) return null;
152261
+ const channel = Number(rest.slice(0, sep));
152262
+ if (!Number.isInteger(channel) || channel < 0) return null;
152263
+ const payload = rest.slice(sep + 1);
152264
+ if (payload === "") return null;
152265
+ const windowSep = payload.indexOf(":");
152266
+ const encoded = windowSep === -1 ? payload : payload.slice(0, windowSep);
152267
+ if (encoded === "") return null;
152268
+ const fileName = Buffer.from(encoded, "base64url").toString("utf8");
152269
+ if (fileName === "") return null;
152270
+ if (windowSep === -1) return {
152271
+ source,
152272
+ channel,
152273
+ fileName
152274
+ };
152275
+ const match = /^(\d+)-(\d+)$/.exec(payload.slice(windowSep + 1));
152276
+ if (match === null) return null;
152277
+ return {
152278
+ source,
152279
+ channel,
152280
+ fileName,
152281
+ window: {
152282
+ startMs: Number(match[1]),
152283
+ endMs: Number(match[2])
152284
+ }
152285
+ };
152286
+ }
152287
+ /**
152288
+ * The on-disk key for a clip's derived artifacts (its thumbnail, its muxed
152289
+ * MP4). Derived from `(channel, fileName)` and NOT from the source: a
152290
+ * thumbnail belongs to the FILE, so any surface that names the same file
152291
+ * finds the same picture and the camera is never asked for a second
152292
+ * CoverPreview of it.
152293
+ *
152294
+ * A hash rather than the encoded name because a hub `<Id>` is a full path and
152295
+ * the encoded form is long enough to hit a file-name limit.
152296
+ */
152297
+ function clipKeyFor(channel, fileName) {
152298
+ return createHash("sha1").update(`${channel}${fileName}`, "utf8").digest("hex");
152299
+ }
152300
+ //#endregion
152301
+ //#region src/clips/clip-native-types.ts
152302
+ /** Cap on `ClipSchema.labels`, mirrored from `@camstack/types` MAX_CLIP_LABELS. */
152303
+ var MAX_LABELS = 3;
152304
+ /** Native → object class. The same rule `native-detection-mapping.ts` applies
152305
+ * to live boxes, so a clip and a live box never disagree about a dog. */
152306
+ var CLASS_BY_NATIVE = {
152307
+ people: "person",
152308
+ face: "person",
152309
+ visitor: "person",
152310
+ vehicle: "vehicle",
152311
+ nonmotorveh: "vehicle",
152312
+ dog_cat: "animal",
152313
+ package: "package"
152314
+ };
152315
+ /** Natives that state a KIND rather than a class. */
152316
+ var KIND_BY_NATIVE = {
152317
+ md: "motion",
152318
+ pir: "motion",
152319
+ cry: "audio",
152320
+ other: "motion"
152321
+ };
152322
+ /** What the subject DID — carried beside a class, never as one. */
152323
+ var BEHAVIOUR_BY_NATIVE = {
152324
+ crossline: "crossline",
152325
+ intrude: "intrude",
152326
+ intrusion: "intrude",
152327
+ loitering: "loitering",
152328
+ visitor: "doorbell"
152329
+ };
152330
+ /**
152331
+ * Recording REASONS — why the file exists, not what is in it. They get no
152332
+ * label and no behaviour; the row still lists, showing the reason raw.
152333
+ * `none` is the camera declining to classify, which is not the same as the
152334
+ * camera saying "nothing". `other` used to live here and is now a motion KIND
152335
+ * — see {@link KIND_BY_NATIVE}.
152336
+ */
152337
+ var RECORDING_REASONS = new Set([
152338
+ "sched",
152339
+ "manual",
152340
+ "io",
152341
+ "IPCInputOutput",
152342
+ "legacy",
152343
+ "loss",
152344
+ "answer",
152345
+ "tamper",
152346
+ "lmsg",
152347
+ "none"
152348
+ ]);
152349
+ /**
152350
+ * Ordering for `labels` — "dominant first". A person outranks a vehicle
152351
+ * outranks an animal outranks a package; anything unknown sorts last so a
152352
+ * newer firmware's invention never displaces a class the operator filters on.
152353
+ */
152354
+ var CLASS_RANK = [
152355
+ "person",
152356
+ "vehicle",
152357
+ "animal",
152358
+ "package"
152359
+ ];
152360
+ /** Split the `recordType` CSV a file carries into raw types, de-duplicated,
152361
+ * in wire order. Case is NOT normalised: `IPCInputOutput` is what the camera
152362
+ * said and what `nativeTypes` must carry back. */
152363
+ function parseNativeTypes(recordType) {
152364
+ if (recordType === void 0) return [];
152365
+ const out = [];
152366
+ for (const part of recordType.split(",")) {
152367
+ const trimmed = part.trim();
152368
+ if (trimmed === "" || out.includes(trimmed)) continue;
152369
+ out.push(trimmed);
152370
+ }
152371
+ return out;
152372
+ }
152373
+ function classifyNativeTypes(nativeTypes) {
152374
+ const labels = [];
152375
+ const behaviours = [];
152376
+ let kind = "native";
152377
+ for (const raw of nativeTypes) {
152378
+ const behaviour = BEHAVIOUR_BY_NATIVE[raw];
152379
+ if (behaviour !== void 0 && !behaviours.includes(behaviour)) behaviours.push(behaviour);
152380
+ const cls = CLASS_BY_NATIVE[raw];
152381
+ if (cls !== void 0) {
152382
+ if (!labels.includes(cls)) labels.push(cls);
152383
+ continue;
152384
+ }
152385
+ const kindOf = KIND_BY_NATIVE[raw];
152386
+ if (kindOf !== void 0) {
152387
+ if (kind === "native") kind = kindOf;
152388
+ continue;
152389
+ }
152390
+ if (RECORDING_REASONS.has(raw)) continue;
152391
+ if (behaviour !== void 0) continue;
152392
+ const unknown = `unknown:${raw}`;
152393
+ if (!labels.includes(unknown)) labels.push(unknown);
152394
+ }
152395
+ if (labels.some((label) => !label.startsWith("unknown:"))) kind = "native";
152396
+ return {
152397
+ labels: labels.toSorted((a, b) => rankOf(a) - rankOf(b)).slice(0, MAX_LABELS),
152398
+ behaviours,
152399
+ nativeTypes: [...nativeTypes],
152400
+ kind
152401
+ };
152402
+ }
152403
+ function rankOf(label) {
152404
+ const at = CLASS_RANK.indexOf(label);
152405
+ return at === -1 ? CLASS_RANK.length : at;
152406
+ }
149668
152407
  var partsCache = /* @__PURE__ */ new Map();
149669
152408
  function formatterFor$1(timeZone) {
149670
152409
  const cached = partsCache.get(timeZone);
@@ -150199,7 +152938,24 @@ function createReolinkVideoclipsProvider(deps) {
150199
152938
  state: "ok",
150200
152939
  catalogAsOf: deps.now()
150201
152940
  });
150202
- return rows;
152941
+ for (const row of rows) rowsByKey.set(`${device.deviceId}|${row.clipKey}`, row);
152942
+ const narrowed = listableClips(rows, {
152943
+ since,
152944
+ until
152945
+ });
152946
+ if (narrowed.outOfWindow > 0 || narrowed.inProgress > 0) deps.logger.info("videoclips: narrowed a day listing to what it can answer", {
152947
+ tags,
152948
+ meta: {
152949
+ branch: "listing-narrowed",
152950
+ listed: rows.length,
152951
+ kept: narrowed.kept.length,
152952
+ outOfWindow: narrowed.outOfWindow,
152953
+ inProgress: narrowed.inProgress,
152954
+ since,
152955
+ until
152956
+ }
152957
+ });
152958
+ return narrowed.kept;
150203
152959
  };
150204
152960
  /**
150205
152961
  * Give a day's rows a class when the FILES carried none.
@@ -150349,14 +153105,17 @@ function createReolinkVideoclipsProvider(deps) {
150349
153105
  if (device === null) throw new Error(`videoclips: device ${deviceId} is not a Reolink camera`);
150350
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.`);
150351
153107
  const row = rowsByKey.get(`${deviceId}|${clipKeyFor(parsed.channel, parsed.fileName)}`) ?? null;
150352
- const wantsMain = profile === "high" && row?.mainFileName !== void 0;
150353
- 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;
150354
153113
  const prepared = await deps.preparePlayback({
150355
153114
  device,
150356
153115
  row,
150357
153116
  fileName,
150358
153117
  clipKey: clipKeyFor(parsed.channel, parsed.fileName),
150359
- profile: wantsMain ? "high" : "low"
153118
+ profile: chosen?.served ?? "low"
150360
153119
  });
150361
153120
  return {
150362
153121
  playbackUrl: prepared.playbackUrl,
@@ -150417,8 +153176,8 @@ function createReolinkVideoclipsProvider(deps) {
150417
153176
  const row = rowsByKey.get(`${deviceId}|${clipKey}`);
150418
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 });
150419
153178
  if (row.inProgress === true) return refuse("clip-in-progress", `clip "${clipId}" is still being written, so it has no end to export`, { clipKey });
150420
- const mainFileName = profile === "high" ? row.mainFileName : void 0;
150421
- 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", {
150422
153181
  tags,
150423
153182
  meta: {
150424
153183
  clipId,
@@ -150427,13 +153186,13 @@ function createReolinkVideoclipsProvider(deps) {
150427
153186
  asked: profile
150428
153187
  }
150429
153188
  });
150430
- const twin = mainFileName === void 0 ? "sub" : "main";
150431
- const fileName = mainFileName ?? row.subFileName;
153189
+ const twin = chosen.twin;
153190
+ const fileName = chosen.fileName;
150432
153191
  if (fileName === "") return refuse("no-file-for-window", `clip "${clipId}" is listed with no byte handle behind it`, {
150433
153192
  clipKey,
150434
153193
  twin
150435
153194
  });
150436
- const served = twin === "main" ? "high" : "low";
153195
+ const served = chosen.served;
150437
153196
  const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
150438
153197
  const declaredBytes = twin === "main" ? row.mainBytes : row.subBytes;
150439
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`, {
@@ -150477,6 +153236,75 @@ function createReolinkVideoclipsProvider(deps) {
150477
153236
  served,
150478
153237
  ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
150479
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
+ };
150480
153308
  }
150481
153309
  };
150482
153310
  /**
@@ -151107,9 +153935,15 @@ function createClipService(deps) {
151107
153935
  * clip-export vocabulary, so the provider can hand the engine a token it
151108
153936
  * believes rather than prose it has to guess at (D572).
151109
153937
  */
151110
- const produceTwin = async (input) => {
151111
- const { deviceId, row, fileName, twin } = input;
151112
- const clipKey = row.clipKey;
153938
+ /**
153939
+ * The gates every clip byte path runs, in ONE place: the device, the
153940
+ * sleeping standalone, the uid. The file producer and the stream producer
153941
+ * both call this and neither decides any of it again — a second copy of
153942
+ * the sleeping gate is a second chance to wake a camera nobody authorised.
153943
+ * Every refusal is logged HERE, with its branch, so a caller cannot forget.
153944
+ */
153945
+ const gateClipDevice = async (input) => {
153946
+ const { deviceId, clipKey, twin, path } = input;
151113
153947
  const tags = { deviceId };
151114
153948
  const device = await deps.resolveDevice(deviceId);
151115
153949
  if (device === null) {
@@ -151118,11 +153952,13 @@ function createClipService(deps) {
151118
153952
  meta: {
151119
153953
  clipKey,
151120
153954
  twin,
153955
+ path,
151121
153956
  branch: "unknown-device"
151122
153957
  }
151123
153958
  });
151124
153959
  return {
151125
153960
  kind: "refused",
153961
+ branch: "unknown-device",
151126
153962
  code: "catalog-unreachable",
151127
153963
  detail: `device ${deviceId} is not a Reolink camera on this node`
151128
153964
  };
@@ -151134,11 +153970,13 @@ function createClipService(deps) {
151134
153970
  meta: {
151135
153971
  clipKey,
151136
153972
  twin,
153973
+ path,
151137
153974
  branch: "sleeping"
151138
153975
  }
151139
153976
  });
151140
153977
  return {
151141
153978
  kind: "refused",
153979
+ branch: "sleeping",
151142
153980
  code: "sleeping",
151143
153981
  detail: `device ${deviceId} is a standalone battery camera and is sleeping — fetching a clip would wake it, and no operator authorisation was carried`
151144
153982
  };
@@ -151148,6 +153986,7 @@ function createClipService(deps) {
151148
153986
  meta: {
151149
153987
  clipKey,
151150
153988
  twin,
153989
+ path,
151151
153990
  branch: "wake-authorised"
151152
153991
  }
151153
153992
  });
@@ -151158,15 +153997,39 @@ function createClipService(deps) {
151158
153997
  meta: {
151159
153998
  clipKey,
151160
153999
  twin,
154000
+ path,
151161
154001
  branch: "uid-missing"
151162
154002
  }
151163
154003
  });
151164
154004
  return {
151165
154005
  kind: "refused",
154006
+ branch: "uid-missing",
151166
154007
  code: "catalog-unreachable",
151167
154008
  detail: UID_MISSING_REASON
151168
154009
  };
151169
154010
  }
154011
+ return {
154012
+ kind: "ok",
154013
+ device
154014
+ };
154015
+ };
154016
+ const produceTwin = async (input) => {
154017
+ const { deviceId, row, fileName, twin } = input;
154018
+ const clipKey = row.clipKey;
154019
+ const tags = { deviceId };
154020
+ const gate = await gateClipDevice({
154021
+ deviceId,
154022
+ clipKey,
154023
+ twin,
154024
+ path: "file",
154025
+ ...input.wake !== void 0 ? { wake: input.wake } : {}
154026
+ });
154027
+ if (gate.kind === "refused") return {
154028
+ kind: "refused",
154029
+ code: gate.code,
154030
+ detail: gate.detail
154031
+ };
154032
+ const { device } = gate;
151170
154033
  let downloaded;
151171
154034
  deps.logger.info("videoclips: fetching a clip file from the camera", {
151172
154035
  tags,
@@ -151344,6 +154207,34 @@ function createClipService(deps) {
151344
154207
  if (outcome.kind === "ok" && twin === "sub") await media.put(deviceId, row.clipKey, outcome.bytes);
151345
154208
  return outcome;
151346
154209
  };
154210
+ /**
154211
+ * The STREAM's path (`clip-stream-route.ts`): the same gates, the same row,
154212
+ * nothing written. Beside the file path, not in its place.
154213
+ */
154214
+ const streamClip = createClipStreamRoute({
154215
+ logger: deps.logger,
154216
+ gate: gateClipDevice,
154217
+ rowFor,
154218
+ producer: createClipStreamProducer({
154219
+ logger: deps.logger,
154220
+ spawnMux: (args) => spawn$1(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
154221
+ "pipe",
154222
+ "pipe",
154223
+ "pipe",
154224
+ "pipe"
154225
+ ] })
154226
+ })
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
+ });
151347
154238
  const provider = createReolinkVideoclipsProvider({
151348
154239
  now,
151349
154240
  cache,
@@ -151356,11 +154247,41 @@ function createClipService(deps) {
151356
154247
  served: profile === "high" ? "high" : "low"
151357
154248
  }),
151358
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
+ },
151359
154279
  logger: deps.logger
151360
154280
  });
151361
154281
  return {
151362
154282
  provider,
151363
154283
  cache,
154284
+ disposeClipStreamDial: () => streamDial.dispose(),
151364
154285
  refreshCatalog: async (deviceId) => {
151365
154286
  const until = now();
151366
154287
  await provider.listClips({
@@ -151373,6 +154294,11 @@ function createClipService(deps) {
151373
154294
  hostBusy: (hostKey) => queue.busy(hostKey),
151374
154295
  thumbPrefix: CLIP_THUMB_PREFIX,
151375
154296
  mediaPrefix: CLIP_MEDIA_PREFIX,
154297
+ streamPrefix: CLIP_STREAM_PREFIX,
154298
+ streamHandler: createClipStreamHandler({
154299
+ stream: streamClip,
154300
+ logger: deps.logger
154301
+ }),
151376
154302
  thumbHandler: createClipThumbHandler({
151377
154303
  readCached: (deviceId, clipKey) => thumbs.read(deviceId, clipKey),
151378
154304
  mint: (deviceId, clipKey) => mintThumbnail(deviceId, clipKey, "interactive"),
@@ -165391,6 +168317,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
165391
168317
  this.clipRefreshTimer = null;
165392
168318
  }
165393
168319
  this.logChannels?.stop();
168320
+ await this.clipService?.disposeClipStreamDial().catch(() => {});
165394
168321
  await this.emailPushServer?.stop().catch(() => {});
165395
168322
  await this.autodetectCache.dispose();
165396
168323
  }
@@ -165612,11 +168539,18 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
165612
168539
  access: "authenticated",
165613
168540
  handler: service.playbackHandler
165614
168541
  });
168542
+ const stream = await this.ctx.dataPlane?.serve({
168543
+ prefix: service.streamPrefix,
168544
+ access: "authenticated",
168545
+ handler: service.streamHandler
168546
+ });
165615
168547
  this.ctx.logger.info("Reolink clip data planes served", { meta: {
165616
168548
  thumbPath: `/addon/${REOLINK_ADDON_ID}/${service.thumbPrefix}`,
165617
168549
  mediaPath: `/addon/${REOLINK_ADDON_ID}/${service.mediaPrefix}`,
168550
+ streamPath: `/addon/${REOLINK_ADDON_ID}/${service.streamPrefix}`,
165618
168551
  thumbServed: thumb !== void 0,
165619
- mediaServed: media !== void 0
168552
+ mediaServed: media !== void 0,
168553
+ streamServed: stream !== void 0
165620
168554
  } });
165621
168555
  } catch (err) {
165622
168556
  this.ctx.logger.warn("Reolink clip data planes failed to serve — clips will not render", { meta: { error: err instanceof Error ? err.message : String(err) } });