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