@sentinel-nvr/web 0.1.0
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/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/api/index.d.ts +20 -0
- package/dist/api/index.js +261 -0
- package/dist/api/index.js.map +1 -0
- package/dist/client-BU0GTX3b.d.ts +233 -0
- package/dist/player/index.d.ts +255 -0
- package/dist/player/index.js +1867 -0
- package/dist/player/index.js.map +1 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muggedadscher
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# sentinel-nvr-web
|
|
2
|
+
|
|
3
|
+
Shared browser code for the [Sentinel NVR](https://github.com/Muggedadscher/sentinel-nvr)
|
|
4
|
+
plugin for Scrypted. Two consumers use these packages so their camera UI stays
|
|
5
|
+
identical and bugs are fixed once:
|
|
6
|
+
|
|
7
|
+
- the plugin's own web UI (`sentinel-nvr/ui`)
|
|
8
|
+
- the native Sentinel NVR integration in the [HAPulse](https://github.com/Muggedadscher/hapulse) dashboard
|
|
9
|
+
|
|
10
|
+
## Package
|
|
11
|
+
|
|
12
|
+
One npm package, **`@sentinel-nvr/web`**, with subpath entry points (one version, one bump in both consumers):
|
|
13
|
+
|
|
14
|
+
| Entry point | Status | Contents |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `@sentinel-nvr/web/api` | **available** | DOM-free data model + types mirroring the plugin's `docs/API.md`, URL/setup parsing, event classification, storage forecast, timeline clip-run merge, locale formatters, and a `fetch`/WebSocket `SentinelClient`. No framework deps. |
|
|
17
|
+
| `@sentinel-nvr/web/player` | **available** | `PlayerController`, WebRTC signaling, the no-reneg recorded-playback relay client, watchdog and the live/recorded fallback chain. |
|
|
18
|
+
| `@sentinel-nvr/web/ui` | planned | React components (vertical timeline, class badges, event list/strip, camera tiles, date picker, stat cards), the i18n dictionaries (`nvr.*`, 7 locales) and a themeable CSS foundation (HAPulse tokens × 4 identities). |
|
|
19
|
+
|
|
20
|
+
The package is published once `player` and `ui` are in. Until then, the earlier
|
|
21
|
+
`@sentinel-nvr/api@0.1.0` (same code as `/api`) stays on npm as a transitional release.
|
|
22
|
+
|
|
23
|
+
The plugin's server code (`sentinel-nvr/src`) also imports `@sentinel-nvr/api`
|
|
24
|
+
for its API types, so server and both clients share one contract.
|
|
25
|
+
|
|
26
|
+
## Design contracts (for `ui`)
|
|
27
|
+
|
|
28
|
+
- **Theming:** components read only `--*` CSS custom properties (HAPulse token
|
|
29
|
+
names). Each host maps its own tokens; `ui` ships a `foundation.css` with the
|
|
30
|
+
four HAPulse identities × light/dark for hosts (like the Sentinel UI) that do
|
|
31
|
+
not already define them.
|
|
32
|
+
- **i18n:** components take a `t(key, vars)` + `locale` via a provider. `ui` ships
|
|
33
|
+
the `nvr.*` dictionaries; hosts merge them.
|
|
34
|
+
|
|
35
|
+
## Develop
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install
|
|
39
|
+
npm run typecheck
|
|
40
|
+
npm test
|
|
41
|
+
npm run build
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Releases use [Changesets](https://github.com/changesets/changesets):
|
|
45
|
+
`npx changeset` to record a change, then the release workflow publishes to npm.
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT — see [LICENSE](LICENSE) and [NOTICE.md](NOTICE.md).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { S as SENTINEL_DAY_MS, a as SENTINEL_ENDPOINT_PATH, b as SENTINEL_EVENT_CLASSES, c as SentinelBox, d as SentinelCamera, e as SentinelClient, f as SentinelClip, g as SentinelClipsResponse, h as SentinelEvent, i as SentinelEventBox, j as SentinelEventClass, k as SentinelHttpError, l as SentinelRecentEvent, m as SentinelRun, n as SentinelSetup, o as SentinelStats, p as SentinelStorageForecast, q as SentinelStorageStatus, r as parseSentinelSetup, s as sentinelClassOf, t as sentinelClassesOf, u as sentinelClipIndexFor, v as sentinelClipRuns, w as sentinelDayOf, x as sentinelEntryUrl, y as sentinelEventPlayTs, z as sentinelHumanBytes, A as sentinelLoginBase, B as sentinelMergeDays, C as sentinelPublicBase, D as sentinelStorageForecast, E as sentinelTimelineLink, F as sentinelUrl } from '../client-BU0GTX3b.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Locale-aware, translation-free formatting (Intl only). Word-dependent helpers
|
|
5
|
+
* (relative time, "N days") live in @sentinel-nvr/ui, which owns the i18n dictionary.
|
|
6
|
+
*/
|
|
7
|
+
/** HH:MM in the viewer's locale (24 h where the locale uses it). */
|
|
8
|
+
declare function fmtTime(ts: number, locale: string): string;
|
|
9
|
+
/** HH:MM:SS. */
|
|
10
|
+
declare function fmtTimeSec(ts: number, locale: string): string;
|
|
11
|
+
/** "Mon 8 Sep" style day label. */
|
|
12
|
+
declare function fmtDay(ts: number, locale: string): string;
|
|
13
|
+
/** Month + year for a date-picker header. */
|
|
14
|
+
declare function fmtMonthYear(ts: number, locale: string): string;
|
|
15
|
+
/** Weekday short names (Monday first) for a date-picker grid. */
|
|
16
|
+
declare function weekdayShorts(locale: string): string[];
|
|
17
|
+
/** HH:MM string for a <input type="time">. */
|
|
18
|
+
declare function hhmmInput(ts: number): string;
|
|
19
|
+
|
|
20
|
+
export { fmtDay, fmtMonthYear, fmtTime, fmtTimeSec, hhmmInput, weekdayShorts };
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// src/api/model.ts
|
|
2
|
+
var SENTINEL_EVENT_CLASSES = [
|
|
3
|
+
"person",
|
|
4
|
+
"car",
|
|
5
|
+
"bike",
|
|
6
|
+
"animal",
|
|
7
|
+
"package",
|
|
8
|
+
"motion"
|
|
9
|
+
];
|
|
10
|
+
var SENTINEL_DAY_MS = 24 * 60 * 60 * 1e3;
|
|
11
|
+
var SENTINEL_ENDPOINT_PATH = "/endpoint/@local/sentinel-nvr";
|
|
12
|
+
function parseSentinelSetup(input) {
|
|
13
|
+
const raw = (input ?? "").trim();
|
|
14
|
+
if (!raw) return null;
|
|
15
|
+
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
16
|
+
let u;
|
|
17
|
+
try {
|
|
18
|
+
u = new URL(withScheme);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (!u.hostname) return null;
|
|
23
|
+
const token = u.searchParams.get("token");
|
|
24
|
+
return { origin: u.origin, token: token && token.trim() ? token.trim() : null };
|
|
25
|
+
}
|
|
26
|
+
function sentinelPublicBase(origin) {
|
|
27
|
+
return `${origin.replace(/\/+$/, "")}${SENTINEL_ENDPOINT_PATH}/public/`;
|
|
28
|
+
}
|
|
29
|
+
function sentinelLoginBase(origin) {
|
|
30
|
+
return `${origin.replace(/\/+$/, "")}${SENTINEL_ENDPOINT_PATH}/`;
|
|
31
|
+
}
|
|
32
|
+
function sentinelEntryUrl(origin) {
|
|
33
|
+
return sentinelPublicBase(origin);
|
|
34
|
+
}
|
|
35
|
+
function sentinelUrl(base, token, path) {
|
|
36
|
+
const u = base + path;
|
|
37
|
+
if (!token) return u;
|
|
38
|
+
return `${u}${u.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
|
|
39
|
+
}
|
|
40
|
+
function sentinelTimelineLink(origin, cameraId, atMs) {
|
|
41
|
+
const q = atMs ? `?at=${Math.round(atMs)}` : "";
|
|
42
|
+
return `${sentinelEntryUrl(origin)}#/timeline/${encodeURIComponent(cameraId)}${q}`;
|
|
43
|
+
}
|
|
44
|
+
function isKnownClass(k) {
|
|
45
|
+
return SENTINEL_EVENT_CLASSES.includes(k);
|
|
46
|
+
}
|
|
47
|
+
function sentinelClassOf(ev) {
|
|
48
|
+
const k = ev.classes?.[0];
|
|
49
|
+
return k !== void 0 && isKnownClass(k) ? k : "motion";
|
|
50
|
+
}
|
|
51
|
+
function sentinelClassesOf(ev) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const k of ev.classes ?? []) {
|
|
54
|
+
const c = isKnownClass(k) ? k : "motion";
|
|
55
|
+
if (!out.includes(c)) out.push(c);
|
|
56
|
+
}
|
|
57
|
+
return out.length ? out : [sentinelClassOf(ev)];
|
|
58
|
+
}
|
|
59
|
+
function sentinelEventPlayTs(ev) {
|
|
60
|
+
const ts = ev.startTs || ev.ts || ev.timestamp || 0;
|
|
61
|
+
return ev.startTs ? ts - 2e3 : ts - 3e3;
|
|
62
|
+
}
|
|
63
|
+
function sentinelStorageForecast(st, nowMs = Date.now()) {
|
|
64
|
+
const spanDays = st.earliest ? Math.max(0, (nowMs - st.earliest) / SENTINEL_DAY_MS) : 0;
|
|
65
|
+
const ratePerDay = spanDays >= 1 / 24 ? st.bytes / Math.max(spanDays, 1 / 24) : 0;
|
|
66
|
+
const reserve = Math.min(st.minFreeBytes || 0, st.diskTotal);
|
|
67
|
+
const usableFree = Math.max(0, st.diskFree - reserve);
|
|
68
|
+
const capacity = st.bytes + usableFree;
|
|
69
|
+
const fitsDays = ratePerDay ? capacity / ratePerDay : 0;
|
|
70
|
+
const fillsInDays = ratePerDay ? usableFree / ratePerDay : 0;
|
|
71
|
+
const steady = usableFree < 0.02 * st.diskTotal;
|
|
72
|
+
const other = Math.max(0, st.diskTotal - st.diskFree - st.bytes);
|
|
73
|
+
let status = "unknown";
|
|
74
|
+
if (ratePerDay) {
|
|
75
|
+
if (fitsDays < st.retentionDays * 0.95) status = "unreachable";
|
|
76
|
+
else if (steady || spanDays >= st.retentionDays) status = "reachable";
|
|
77
|
+
else status = "filling";
|
|
78
|
+
}
|
|
79
|
+
return { spanDays, ratePerDay, reserve, usableFree, capacity, fitsDays, fillsInDays, steady, other, status };
|
|
80
|
+
}
|
|
81
|
+
function sentinelClipRuns(clips, gapMs = 2e3) {
|
|
82
|
+
const out = [];
|
|
83
|
+
let cur = null;
|
|
84
|
+
for (const c of clips) {
|
|
85
|
+
const e = c.startTime + (c.duration || 0);
|
|
86
|
+
if (cur && c.startTime - cur.e <= gapMs) cur.e = Math.max(cur.e, e);
|
|
87
|
+
else {
|
|
88
|
+
cur = { s: c.startTime, e };
|
|
89
|
+
out.push(cur);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function sentinelClipIndexFor(clips, ts) {
|
|
95
|
+
for (let i = 0; i < clips.length; i++) {
|
|
96
|
+
const c = clips[i];
|
|
97
|
+
if (ts >= c.startTime && ts < c.startTime + (c.duration || 6e4)) return i;
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
function sentinelDayOf(ts) {
|
|
102
|
+
return new Date(ts).setHours(0, 0, 0, 0);
|
|
103
|
+
}
|
|
104
|
+
function sentinelMergeDays(days) {
|
|
105
|
+
const keys = Object.keys(days).map(Number).sort((a, b) => a - b);
|
|
106
|
+
const clips = [], events = [], motion = [];
|
|
107
|
+
let codecs = null;
|
|
108
|
+
for (const k of keys) {
|
|
109
|
+
const d = days[k];
|
|
110
|
+
clips.push(...d.clips);
|
|
111
|
+
events.push(...d.events);
|
|
112
|
+
motion.push(...d.motion);
|
|
113
|
+
if (d.codecs) codecs = d.codecs;
|
|
114
|
+
}
|
|
115
|
+
clips.sort((a, b) => a.startTime - b.startTime);
|
|
116
|
+
events.sort((a, b) => a.timestamp - b.timestamp);
|
|
117
|
+
return { clips, events, motion, codecs, oldestDay: keys.length ? keys[0] : null };
|
|
118
|
+
}
|
|
119
|
+
function sentinelHumanBytes(b) {
|
|
120
|
+
if (!b || b <= 0) return { value: "0", unit: "B" };
|
|
121
|
+
const u = ["B", "KB", "MB", "GB", "TB"];
|
|
122
|
+
const i = Math.min(u.length - 1, Math.floor(Math.log(b) / Math.log(1024)));
|
|
123
|
+
return { value: (b / Math.pow(1024, i)).toFixed(i ? 1 : 0), unit: u[i] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/api/client.ts
|
|
127
|
+
var SentinelClient = class {
|
|
128
|
+
origin;
|
|
129
|
+
token;
|
|
130
|
+
base;
|
|
131
|
+
/** "Open Sentinel" target: public base without token → sign-in / 302 into the session. */
|
|
132
|
+
entryUrl;
|
|
133
|
+
/** fetch() can read media bodies (segments, live fMP4): the plugin sends CORS on media. */
|
|
134
|
+
corsMedia = true;
|
|
135
|
+
constructor(origin, token) {
|
|
136
|
+
this.origin = origin;
|
|
137
|
+
this.token = token;
|
|
138
|
+
this.base = sentinelPublicBase(origin);
|
|
139
|
+
this.entryUrl = sentinelEntryUrl(origin);
|
|
140
|
+
}
|
|
141
|
+
/** Cache key — a new client is built whenever origin/token change. */
|
|
142
|
+
get key() {
|
|
143
|
+
return `${this.origin} ${this.token}`;
|
|
144
|
+
}
|
|
145
|
+
url(path) {
|
|
146
|
+
return sentinelUrl(this.base, this.token, path);
|
|
147
|
+
}
|
|
148
|
+
/** JSON GET with a timeout (an unreachable host otherwise waits for the browser's TCP timeout). */
|
|
149
|
+
async getJson(path, signal, timeoutMs = 1e4) {
|
|
150
|
+
const signals = [AbortSignal.timeout(timeoutMs)];
|
|
151
|
+
if (signal) signals.push(signal);
|
|
152
|
+
const combined = typeof AbortSignal.any === "function" ? AbortSignal.any(signals) : signals[0];
|
|
153
|
+
const r = await fetch(this.url(path), { cache: "no-store", signal: combined });
|
|
154
|
+
if (!r.ok) throw new SentinelHttpError(r.status, path);
|
|
155
|
+
return await r.json();
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Fire a control GET (204 = ok, 404 = session gone). Resolves `r.ok`. A rejected
|
|
159
|
+
* fetch (network error, or an older plugin whose 204 lacks CORS headers — the
|
|
160
|
+
* command was delivered either way) resolves true; dead sessions surface via relay-pos.
|
|
161
|
+
*/
|
|
162
|
+
async control(path) {
|
|
163
|
+
try {
|
|
164
|
+
const r = await fetch(this.url(path), { cache: "no-store" });
|
|
165
|
+
return r.ok;
|
|
166
|
+
} catch {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Text POST (client telemetry). Best effort, never throws. */
|
|
171
|
+
postText(path, body) {
|
|
172
|
+
try {
|
|
173
|
+
void fetch(this.url(path), { method: "POST", body, keepalive: true }).catch(() => {
|
|
174
|
+
});
|
|
175
|
+
} catch {
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** WebSocket signaling URL (the plugin's Engine.IO handler). */
|
|
179
|
+
signalingUrl(cameraId, params = {}) {
|
|
180
|
+
const u = new URL(this.url(""));
|
|
181
|
+
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
182
|
+
u.hash = "";
|
|
183
|
+
u.searchParams.set("camera", cameraId);
|
|
184
|
+
u.searchParams.set("signaling", "1");
|
|
185
|
+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
|
186
|
+
return u.href;
|
|
187
|
+
}
|
|
188
|
+
// ---- media URL helpers (all <img>/<video>-loadable) ----
|
|
189
|
+
snapshotUrl(cameraId, bust) {
|
|
190
|
+
return this.url(`api/snapshot?camera=${encodeURIComponent(cameraId)}`) + (bust != null ? `&_=${bust}` : "");
|
|
191
|
+
}
|
|
192
|
+
eventThumbUrl(cameraId, ts) {
|
|
193
|
+
return this.url(`api/evthumb?camera=${encodeURIComponent(cameraId)}&ts=${Math.round(ts)}`);
|
|
194
|
+
}
|
|
195
|
+
eventFrameUrl(cameraId, ts) {
|
|
196
|
+
return this.url(`api/evframe?camera=${encodeURIComponent(cameraId)}&ts=${Math.round(ts)}`);
|
|
197
|
+
}
|
|
198
|
+
segmentThumbUrl(thumbId) {
|
|
199
|
+
return this.url(`api/thumb?id=${encodeURIComponent(thumbId)}`);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
var SentinelHttpError = class extends Error {
|
|
203
|
+
status;
|
|
204
|
+
constructor(status, path) {
|
|
205
|
+
super(`${status} ${path}`);
|
|
206
|
+
this.name = "SentinelHttpError";
|
|
207
|
+
this.status = status;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/api/format.ts
|
|
212
|
+
var pad = (n) => String(n).padStart(2, "0");
|
|
213
|
+
function fmtTime(ts, locale) {
|
|
214
|
+
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(ts);
|
|
215
|
+
}
|
|
216
|
+
function fmtTimeSec(ts, locale) {
|
|
217
|
+
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(ts);
|
|
218
|
+
}
|
|
219
|
+
function fmtDay(ts, locale) {
|
|
220
|
+
return new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short" }).format(ts);
|
|
221
|
+
}
|
|
222
|
+
function fmtMonthYear(ts, locale) {
|
|
223
|
+
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(ts);
|
|
224
|
+
}
|
|
225
|
+
function weekdayShorts(locale) {
|
|
226
|
+
const f = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
|
227
|
+
return Array.from({ length: 7 }, (_, i) => f.format(new Date(2024, 0, 1 + i)));
|
|
228
|
+
}
|
|
229
|
+
function hhmmInput(ts) {
|
|
230
|
+
const d = new Date(ts);
|
|
231
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
232
|
+
}
|
|
233
|
+
export {
|
|
234
|
+
SENTINEL_DAY_MS,
|
|
235
|
+
SENTINEL_ENDPOINT_PATH,
|
|
236
|
+
SENTINEL_EVENT_CLASSES,
|
|
237
|
+
SentinelClient,
|
|
238
|
+
SentinelHttpError,
|
|
239
|
+
fmtDay,
|
|
240
|
+
fmtMonthYear,
|
|
241
|
+
fmtTime,
|
|
242
|
+
fmtTimeSec,
|
|
243
|
+
hhmmInput,
|
|
244
|
+
parseSentinelSetup,
|
|
245
|
+
sentinelClassOf,
|
|
246
|
+
sentinelClassesOf,
|
|
247
|
+
sentinelClipIndexFor,
|
|
248
|
+
sentinelClipRuns,
|
|
249
|
+
sentinelDayOf,
|
|
250
|
+
sentinelEntryUrl,
|
|
251
|
+
sentinelEventPlayTs,
|
|
252
|
+
sentinelHumanBytes,
|
|
253
|
+
sentinelLoginBase,
|
|
254
|
+
sentinelMergeDays,
|
|
255
|
+
sentinelPublicBase,
|
|
256
|
+
sentinelStorageForecast,
|
|
257
|
+
sentinelTimelineLink,
|
|
258
|
+
sentinelUrl,
|
|
259
|
+
weekdayShorts
|
|
260
|
+
};
|
|
261
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/api/model.ts","../../src/api/client.ts","../../src/api/format.ts"],"sourcesContent":["/**\n * Sentinel NVR — DOM-free data model + pure helpers.\n *\n * Mirrors the Sentinel NVR plugin's `docs/API.md` (the binding contract): types,\n * URL/setup parsing, event classification, the playback-start heuristic, the\n * storage forecast the status page computes, and the timeline clip-run merge.\n * No fetch/DOM here — the browser client lives in `client.ts`.\n */\n\n// ---------------------------------------------------------------------------\n// Types (docs/API.md §3)\n// ---------------------------------------------------------------------------\n\n/** UI classes of an event; anything unknown collapses to `motion`. */\nexport type SentinelEventClass = 'person' | 'car' | 'bike' | 'animal' | 'package' | 'motion';\n\nexport const SENTINEL_EVENT_CLASSES: readonly SentinelEventClass[] = [\n 'person', 'car', 'bike', 'animal', 'package', 'motion',\n];\n\nexport type SentinelBox = [number, number, number, number];\n\n/** `api/cameras` entry. */\nexport interface SentinelCamera {\n id: string;\n name: string;\n recording: boolean;\n online: boolean;\n lastSegmentAt?: number;\n eventsToday: number;\n lastEventTs?: number;\n latestThumbId?: string;\n latestTs?: number;\n /** RFC-6381 codec string of the recordings (MSE). */\n codecs?: string;\n}\n\n/** `api/stats`. */\nexport interface SentinelStats {\n cameras: number;\n recording: number;\n eventsToday: number;\n segments: number;\n bytes: number;\n earliest?: number;\n retentionDays: number;\n diskFree: number;\n diskTotal: number;\n minFreeBytes: number;\n}\n\n/** `api/recent-events` entry (short form). */\nexport interface SentinelRecentEvent {\n camera: string;\n cameraName: string;\n ts: number;\n startTs?: number;\n classes: string[];\n score: number;\n}\n\nexport interface SentinelEventBox {\n class: string;\n rawClass?: string;\n score: number;\n box: SentinelBox;\n nbox?: SentinelBox;\n}\n\n/** Full event (`api/clips.events`). */\nexport interface SentinelEvent {\n id: string;\n timestamp: number;\n startTs?: number;\n classes: string[];\n score: number;\n source: 'object' | 'motion';\n boxes?: SentinelEventBox[];\n}\n\n/** Recording segment (`api/clips.clips`). */\nexport interface SentinelClip {\n id: string;\n videoId?: string;\n thumbnailId?: string;\n startTime: number;\n duration?: number;\n event?: string;\n detectionClasses?: string[];\n}\n\nexport interface SentinelClipsResponse {\n clips: SentinelClip[];\n events: SentinelEvent[];\n motion: [number, number][];\n codecs?: string;\n}\n\nexport const SENTINEL_DAY_MS = 24 * 60 * 60 * 1000;\n\n// ---------------------------------------------------------------------------\n// Setup / URLs (docs/API.md §1)\n// ---------------------------------------------------------------------------\n\n/** Where the plugin lives below a Scrypted origin. */\nexport const SENTINEL_ENDPOINT_PATH = '/endpoint/@local/sentinel-nvr';\n\nexport interface SentinelSetup {\n /** Scrypted origin, e.g. `https://192.168.2.120:10443` (no trailing slash). */\n origin: string;\n /** Access token found in the pasted URL (`?token=`), if any. */\n token: string | null;\n}\n\n/**\n * Turn whatever the user pastes into a clean Scrypted origin (+ token, when the\n * pasted string was Sentinel's embed URL). Accepts a bare host (`https://` is\n * assumed), the Scrypted root, the plugin's login/public path, or the embed\n * URL with `?token=…&embed=1`. Returns null for unusable input.\n */\nexport function parseSentinelSetup(input: string | null | undefined): SentinelSetup | null {\n const raw = (input ?? '').trim();\n if (!raw) return null;\n const withScheme = /^https?:\\/\\//i.test(raw) ? raw : `https://${raw}`;\n let u: URL;\n try {\n u = new URL(withScheme);\n } catch {\n return null;\n }\n if (!u.hostname) return null;\n const token = u.searchParams.get('token');\n return { origin: u.origin, token: token && token.trim() ? token.trim() : null };\n}\n\n/** Token-guarded machine base (`…/public/`), always with a trailing slash. */\nexport function sentinelPublicBase(origin: string): string {\n return `${origin.replace(/\\/+$/, '')}${SENTINEL_ENDPOINT_PATH}/public/`;\n}\n\n/**\n * Human base behind the Scrypted login (`…/sentinel-nvr/`). NOT a link target:\n * without a session Scrypted answers \"Not Authorized\" here. Humans enter via\n * `sentinelEntryUrl` (the public base without a token shows Sentinel's sign-in\n * page, or redirects straight here when a session exists).\n */\nexport function sentinelLoginBase(origin: string): string {\n return `${origin.replace(/\\/+$/, '')}${SENTINEL_ENDPOINT_PATH}/`;\n}\n\n/** Where a human opens Sentinel's own UI: the public base WITHOUT a token (sign-in page / 302 into the session). */\nexport function sentinelEntryUrl(origin: string): string {\n return sentinelPublicBase(origin);\n}\n\n/**\n * Absolute URL for an API/media path below the public base, with the token as\n * a query parameter (a `GET` with a query token is a CORS \"simple request\" —\n * the `x-sentinel-token` header would force a preflight the plugin does not\n * answer; `<img>`/`<video>` sources need the query form anyway).\n */\nexport function sentinelUrl(base: string, token: string, path: string): string {\n const u = base + path;\n if (!token) return u;\n return `${u}${u.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;\n}\n\n/**\n * Deep link into Sentinel's own web UI for a camera timeline — via the entry\n * URL (sign-in or 302 into the session; browsers carry the `#` fragment across\n * the redirect).\n */\nexport function sentinelTimelineLink(origin: string, cameraId: string, atMs?: number): string {\n const q = atMs ? `?at=${Math.round(atMs)}` : '';\n return `${sentinelEntryUrl(origin)}#/timeline/${encodeURIComponent(cameraId)}${q}`;\n}\n\n// ---------------------------------------------------------------------------\n// Events\n// ---------------------------------------------------------------------------\n\nfunction isKnownClass(k: string): k is SentinelEventClass {\n return (SENTINEL_EVENT_CLASSES as readonly string[]).includes(k);\n}\n\n/** Primary class of an event (first known class, else `motion`). */\nexport function sentinelClassOf(ev: { classes?: string[] | undefined }): SentinelEventClass {\n const k = ev.classes?.[0];\n return k !== undefined && isKnownClass(k) ? k : 'motion';\n}\n\n/** Distinct known classes of an event, in order — never empty. */\nexport function sentinelClassesOf(ev: { classes?: string[] | undefined }): SentinelEventClass[] {\n const out: SentinelEventClass[] = [];\n for (const k of ev.classes ?? []) {\n const c: SentinelEventClass = isKnownClass(k) ? k : 'motion';\n if (!out.includes(c)) out.push(c);\n }\n return out.length ? out : [sentinelClassOf(ev)];\n}\n\n/**\n * Where playback starts for an event: 2 s before the first sighting when the\n * detector recorded one, else 3 s before the trigger (docs/API.md §3.3).\n */\nexport function sentinelEventPlayTs(ev: { timestamp?: number; ts?: number; startTs?: number }): number {\n const ts = ev.startTs || ev.ts || ev.timestamp || 0;\n return ev.startTs ? ts - 2000 : ts - 3000;\n}\n\n// ---------------------------------------------------------------------------\n// Storage forecast (docs/API.md §3.2 — the same maths the Sentinel status page uses)\n// ---------------------------------------------------------------------------\n\nexport type SentinelStorageStatus = 'unknown' | 'unreachable' | 'reachable' | 'filling';\n\nexport interface SentinelStorageForecast {\n /** Days spanned by the archive (earliest segment → now). */\n spanDays: number;\n /** Bytes recorded per day (0 until at least an hour of footage exists). */\n ratePerDay: number;\n /** Free-space guard reserve, clamped to the disk size. */\n reserve: number;\n /** Free bytes above the reserve. */\n usableFree: number;\n /** Bytes available for recordings = archive + usable free. */\n capacity: number;\n /** Days the archive can grow to at the current rate (0 = unknown). */\n fitsDays: number;\n /** Days until the reserve is reached (0 = unknown). */\n fillsInDays: number;\n /** The disk is effectively full (free ≤ 2 % above the reserve) → steady-state eviction. */\n steady: boolean;\n /** Bytes used by something other than recordings. */\n other: number;\n /** Which forecast message applies. */\n status: SentinelStorageStatus;\n}\n\nexport function sentinelStorageForecast(st: SentinelStats, nowMs: number = Date.now()): SentinelStorageForecast {\n const spanDays = st.earliest ? Math.max(0, (nowMs - st.earliest) / SENTINEL_DAY_MS) : 0;\n const ratePerDay = spanDays >= 1 / 24 ? st.bytes / Math.max(spanDays, 1 / 24) : 0;\n const reserve = Math.min(st.minFreeBytes || 0, st.diskTotal);\n const usableFree = Math.max(0, st.diskFree - reserve);\n const capacity = st.bytes + usableFree;\n const fitsDays = ratePerDay ? capacity / ratePerDay : 0;\n const fillsInDays = ratePerDay ? usableFree / ratePerDay : 0;\n const steady = usableFree < 0.02 * st.diskTotal;\n const other = Math.max(0, st.diskTotal - st.diskFree - st.bytes);\n let status: SentinelStorageStatus = 'unknown';\n if (ratePerDay) {\n if (fitsDays < st.retentionDays * 0.95) status = 'unreachable';\n else if (steady || spanDays >= st.retentionDays) status = 'reachable';\n else status = 'filling';\n }\n return { spanDays, ratePerDay, reserve, usableFree, capacity, fitsDays, fillsInDays, steady, other, status };\n}\n\n// ---------------------------------------------------------------------------\n// Timeline helpers\n// ---------------------------------------------------------------------------\n\nexport interface SentinelRun { s: number; e: number }\n\n/**\n * Contiguous recording runs: segments whose gap is ≤ `gapMs` (2 s) are drawn as\n * ONE band (otherwise every 60-s segment boundary shows a notch).\n */\nexport function sentinelClipRuns(clips: SentinelClip[], gapMs = 2000): SentinelRun[] {\n const out: SentinelRun[] = [];\n let cur: SentinelRun | null = null;\n for (const c of clips) {\n const e = c.startTime + (c.duration || 0);\n if (cur && c.startTime - cur.e <= gapMs) cur.e = Math.max(cur.e, e);\n else { cur = { s: c.startTime, e }; out.push(cur); }\n }\n return out;\n}\n\n/** Index of the clip covering `ts` (60 s assumed when a duration is missing), −1 if none. */\nexport function sentinelClipIndexFor(clips: SentinelClip[], ts: number): number {\n for (let i = 0; i < clips.length; i++) {\n const c = clips[i]!;\n if (ts >= c.startTime && ts < c.startTime + (c.duration || 60000)) return i;\n }\n return -1;\n}\n\n/** Local-midnight start of the day containing `ts`. */\nexport function sentinelDayOf(ts: number): number {\n return new Date(ts).setHours(0, 0, 0, 0);\n}\n\n/** Merge per-day clip responses into one sorted, continuous data set. */\nexport function sentinelMergeDays(days: Record<number, SentinelClipsResponse>): {\n clips: SentinelClip[]; events: SentinelEvent[]; motion: [number, number][]; codecs: string | null; oldestDay: number | null;\n} {\n const keys = Object.keys(days).map(Number).sort((a, b) => a - b);\n const clips: SentinelClip[] = [], events: SentinelEvent[] = [], motion: [number, number][] = [];\n let codecs: string | null = null;\n for (const k of keys) {\n const d = days[k]!;\n clips.push(...d.clips); events.push(...d.events); motion.push(...d.motion);\n if (d.codecs) codecs = d.codecs;\n }\n clips.sort((a, b) => a.startTime - b.startTime);\n events.sort((a, b) => a.timestamp - b.timestamp);\n return { clips, events, motion, codecs, oldestDay: keys.length ? keys[0]! : null };\n}\n\n/** Human-readable byte size split into value + unit (binary steps). */\nexport function sentinelHumanBytes(b: number): { value: string; unit: string } {\n if (!b || b <= 0) return { value: '0', unit: 'B' };\n const u = ['B', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.min(u.length - 1, Math.floor(Math.log(b) / Math.log(1024)));\n return { value: (b / Math.pow(1024, i)).toFixed(i ? 1 : 0), unit: u[i]! };\n}\n","/**\n * Browser client for the Sentinel NVR plugin's token-guarded `/public/` endpoint.\n *\n * The plugin sends permissive CORS on every answer (JSON, the 204 control replies,\n * api/segment incl. 206, api/livemse, images, errors) and answers OPTIONS, so a\n * cross-origin static SPA can talk to it directly with `?token=`. Media elements\n * may use crossOrigin=\"anonymous\". Older builds (JSON-only CORS) degrade: control()\n * treats an unreadable reply as delivered; media fetches fall into the next fallback.\n */\n\nimport { sentinelPublicBase, sentinelEntryUrl, sentinelUrl } from './model';\n\nexport class SentinelClient {\n readonly origin: string;\n readonly token: string;\n readonly base: string;\n /** \"Open Sentinel\" target: public base without token → sign-in / 302 into the session. */\n readonly entryUrl: string;\n /** fetch() can read media bodies (segments, live fMP4): the plugin sends CORS on media. */\n readonly corsMedia = true;\n\n constructor(origin: string, token: string) {\n this.origin = origin;\n this.token = token;\n this.base = sentinelPublicBase(origin);\n this.entryUrl = sentinelEntryUrl(origin);\n }\n\n /** Cache key — a new client is built whenever origin/token change. */\n get key(): string {\n return `${this.origin} ${this.token}`;\n }\n\n url(path: string): string {\n return sentinelUrl(this.base, this.token, path);\n }\n\n /** JSON GET with a timeout (an unreachable host otherwise waits for the browser's TCP timeout). */\n async getJson<T>(path: string, signal?: AbortSignal, timeoutMs = 10_000): Promise<T> {\n const signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)];\n if (signal) signals.push(signal);\n // AbortSignal.any is Chrome 116 / Safari 17.4+; older engines get the timeout signal alone.\n const combined = typeof AbortSignal.any === 'function' ? AbortSignal.any(signals) : signals[0]!;\n const r = await fetch(this.url(path), { cache: 'no-store', signal: combined });\n if (!r.ok) throw new SentinelHttpError(r.status, path);\n return (await r.json()) as T;\n }\n\n /**\n * Fire a control GET (204 = ok, 404 = session gone). Resolves `r.ok`. A rejected\n * fetch (network error, or an older plugin whose 204 lacks CORS headers — the\n * command was delivered either way) resolves true; dead sessions surface via relay-pos.\n */\n async control(path: string): Promise<boolean> {\n try {\n const r = await fetch(this.url(path), { cache: 'no-store' });\n return r.ok;\n } catch {\n return true;\n }\n }\n\n /** Text POST (client telemetry). Best effort, never throws. */\n postText(path: string, body: string): void {\n try {\n void fetch(this.url(path), { method: 'POST', body, keepalive: true }).catch(() => { /* telemetry only */ });\n } catch { /* ignore */ }\n }\n\n /** WebSocket signaling URL (the plugin's Engine.IO handler). */\n signalingUrl(cameraId: string, params: Record<string, string> = {}): string {\n const u = new URL(this.url(''));\n u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';\n u.hash = '';\n u.searchParams.set('camera', cameraId);\n u.searchParams.set('signaling', '1');\n for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);\n return u.href;\n }\n\n // ---- media URL helpers (all <img>/<video>-loadable) ----\n snapshotUrl(cameraId: string, bust?: number): string {\n return this.url(`api/snapshot?camera=${encodeURIComponent(cameraId)}`) + (bust != null ? `&_=${bust}` : '');\n }\n eventThumbUrl(cameraId: string, ts: number): string {\n return this.url(`api/evthumb?camera=${encodeURIComponent(cameraId)}&ts=${Math.round(ts)}`);\n }\n eventFrameUrl(cameraId: string, ts: number): string {\n return this.url(`api/evframe?camera=${encodeURIComponent(cameraId)}&ts=${Math.round(ts)}`);\n }\n segmentThumbUrl(thumbId: string): string {\n return this.url(`api/thumb?id=${encodeURIComponent(thumbId)}`);\n }\n}\n\nexport class SentinelHttpError extends Error {\n readonly status: number;\n constructor(status: number, path: string) {\n super(`${status} ${path}`);\n this.name = 'SentinelHttpError';\n this.status = status;\n }\n}\n","/**\n * Locale-aware, translation-free formatting (Intl only). Word-dependent helpers\n * (relative time, \"N days\") live in @sentinel-nvr/ui, which owns the i18n dictionary.\n */\n\nconst pad = (n: number) => String(n).padStart(2, '0');\n\n/** HH:MM in the viewer's locale (24 h where the locale uses it). */\nexport function fmtTime(ts: number, locale: string): string {\n return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(ts);\n}\n\n/** HH:MM:SS. */\nexport function fmtTimeSec(ts: number, locale: string): string {\n return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(ts);\n}\n\n/** \"Mon 8 Sep\" style day label. */\nexport function fmtDay(ts: number, locale: string): string {\n return new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(ts);\n}\n\n/** Month + year for a date-picker header. */\nexport function fmtMonthYear(ts: number, locale: string): string {\n return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(ts);\n}\n\n/** Weekday short names (Monday first) for a date-picker grid. */\nexport function weekdayShorts(locale: string): string[] {\n const f = new Intl.DateTimeFormat(locale, { weekday: 'short' });\n // 2024-01-01 is a Monday.\n return Array.from({ length: 7 }, (_, i) => f.format(new Date(2024, 0, 1 + i)));\n}\n\n/** HH:MM string for a <input type=\"time\">. */\nexport function hhmmInput(ts: number): string {\n const d = new Date(ts);\n return `${pad(d.getHours())}:${pad(d.getMinutes())}`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel NVR — DOM-free data model + pure helpers.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the Sentinel NVR plugin's `docs/API.md` (the binding contract): types,
|
|
5
|
+
* URL/setup parsing, event classification, the playback-start heuristic, the
|
|
6
|
+
* storage forecast the status page computes, and the timeline clip-run merge.
|
|
7
|
+
* No fetch/DOM here — the browser client lives in `client.ts`.
|
|
8
|
+
*/
|
|
9
|
+
/** UI classes of an event; anything unknown collapses to `motion`. */
|
|
10
|
+
type SentinelEventClass = 'person' | 'car' | 'bike' | 'animal' | 'package' | 'motion';
|
|
11
|
+
declare const SENTINEL_EVENT_CLASSES: readonly SentinelEventClass[];
|
|
12
|
+
type SentinelBox = [number, number, number, number];
|
|
13
|
+
/** `api/cameras` entry. */
|
|
14
|
+
interface SentinelCamera {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
recording: boolean;
|
|
18
|
+
online: boolean;
|
|
19
|
+
lastSegmentAt?: number;
|
|
20
|
+
eventsToday: number;
|
|
21
|
+
lastEventTs?: number;
|
|
22
|
+
latestThumbId?: string;
|
|
23
|
+
latestTs?: number;
|
|
24
|
+
/** RFC-6381 codec string of the recordings (MSE). */
|
|
25
|
+
codecs?: string;
|
|
26
|
+
}
|
|
27
|
+
/** `api/stats`. */
|
|
28
|
+
interface SentinelStats {
|
|
29
|
+
cameras: number;
|
|
30
|
+
recording: number;
|
|
31
|
+
eventsToday: number;
|
|
32
|
+
segments: number;
|
|
33
|
+
bytes: number;
|
|
34
|
+
earliest?: number;
|
|
35
|
+
retentionDays: number;
|
|
36
|
+
diskFree: number;
|
|
37
|
+
diskTotal: number;
|
|
38
|
+
minFreeBytes: number;
|
|
39
|
+
}
|
|
40
|
+
/** `api/recent-events` entry (short form). */
|
|
41
|
+
interface SentinelRecentEvent {
|
|
42
|
+
camera: string;
|
|
43
|
+
cameraName: string;
|
|
44
|
+
ts: number;
|
|
45
|
+
startTs?: number;
|
|
46
|
+
classes: string[];
|
|
47
|
+
score: number;
|
|
48
|
+
}
|
|
49
|
+
interface SentinelEventBox {
|
|
50
|
+
class: string;
|
|
51
|
+
rawClass?: string;
|
|
52
|
+
score: number;
|
|
53
|
+
box: SentinelBox;
|
|
54
|
+
nbox?: SentinelBox;
|
|
55
|
+
}
|
|
56
|
+
/** Full event (`api/clips.events`). */
|
|
57
|
+
interface SentinelEvent {
|
|
58
|
+
id: string;
|
|
59
|
+
timestamp: number;
|
|
60
|
+
startTs?: number;
|
|
61
|
+
classes: string[];
|
|
62
|
+
score: number;
|
|
63
|
+
source: 'object' | 'motion';
|
|
64
|
+
boxes?: SentinelEventBox[];
|
|
65
|
+
}
|
|
66
|
+
/** Recording segment (`api/clips.clips`). */
|
|
67
|
+
interface SentinelClip {
|
|
68
|
+
id: string;
|
|
69
|
+
videoId?: string;
|
|
70
|
+
thumbnailId?: string;
|
|
71
|
+
startTime: number;
|
|
72
|
+
duration?: number;
|
|
73
|
+
event?: string;
|
|
74
|
+
detectionClasses?: string[];
|
|
75
|
+
}
|
|
76
|
+
interface SentinelClipsResponse {
|
|
77
|
+
clips: SentinelClip[];
|
|
78
|
+
events: SentinelEvent[];
|
|
79
|
+
motion: [number, number][];
|
|
80
|
+
codecs?: string;
|
|
81
|
+
}
|
|
82
|
+
declare const SENTINEL_DAY_MS: number;
|
|
83
|
+
/** Where the plugin lives below a Scrypted origin. */
|
|
84
|
+
declare const SENTINEL_ENDPOINT_PATH = "/endpoint/@local/sentinel-nvr";
|
|
85
|
+
interface SentinelSetup {
|
|
86
|
+
/** Scrypted origin, e.g. `https://192.168.2.120:10443` (no trailing slash). */
|
|
87
|
+
origin: string;
|
|
88
|
+
/** Access token found in the pasted URL (`?token=`), if any. */
|
|
89
|
+
token: string | null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Turn whatever the user pastes into a clean Scrypted origin (+ token, when the
|
|
93
|
+
* pasted string was Sentinel's embed URL). Accepts a bare host (`https://` is
|
|
94
|
+
* assumed), the Scrypted root, the plugin's login/public path, or the embed
|
|
95
|
+
* URL with `?token=…&embed=1`. Returns null for unusable input.
|
|
96
|
+
*/
|
|
97
|
+
declare function parseSentinelSetup(input: string | null | undefined): SentinelSetup | null;
|
|
98
|
+
/** Token-guarded machine base (`…/public/`), always with a trailing slash. */
|
|
99
|
+
declare function sentinelPublicBase(origin: string): string;
|
|
100
|
+
/**
|
|
101
|
+
* Human base behind the Scrypted login (`…/sentinel-nvr/`). NOT a link target:
|
|
102
|
+
* without a session Scrypted answers "Not Authorized" here. Humans enter via
|
|
103
|
+
* `sentinelEntryUrl` (the public base without a token shows Sentinel's sign-in
|
|
104
|
+
* page, or redirects straight here when a session exists).
|
|
105
|
+
*/
|
|
106
|
+
declare function sentinelLoginBase(origin: string): string;
|
|
107
|
+
/** Where a human opens Sentinel's own UI: the public base WITHOUT a token (sign-in page / 302 into the session). */
|
|
108
|
+
declare function sentinelEntryUrl(origin: string): string;
|
|
109
|
+
/**
|
|
110
|
+
* Absolute URL for an API/media path below the public base, with the token as
|
|
111
|
+
* a query parameter (a `GET` with a query token is a CORS "simple request" —
|
|
112
|
+
* the `x-sentinel-token` header would force a preflight the plugin does not
|
|
113
|
+
* answer; `<img>`/`<video>` sources need the query form anyway).
|
|
114
|
+
*/
|
|
115
|
+
declare function sentinelUrl(base: string, token: string, path: string): string;
|
|
116
|
+
/**
|
|
117
|
+
* Deep link into Sentinel's own web UI for a camera timeline — via the entry
|
|
118
|
+
* URL (sign-in or 302 into the session; browsers carry the `#` fragment across
|
|
119
|
+
* the redirect).
|
|
120
|
+
*/
|
|
121
|
+
declare function sentinelTimelineLink(origin: string, cameraId: string, atMs?: number): string;
|
|
122
|
+
/** Primary class of an event (first known class, else `motion`). */
|
|
123
|
+
declare function sentinelClassOf(ev: {
|
|
124
|
+
classes?: string[] | undefined;
|
|
125
|
+
}): SentinelEventClass;
|
|
126
|
+
/** Distinct known classes of an event, in order — never empty. */
|
|
127
|
+
declare function sentinelClassesOf(ev: {
|
|
128
|
+
classes?: string[] | undefined;
|
|
129
|
+
}): SentinelEventClass[];
|
|
130
|
+
/**
|
|
131
|
+
* Where playback starts for an event: 2 s before the first sighting when the
|
|
132
|
+
* detector recorded one, else 3 s before the trigger (docs/API.md §3.3).
|
|
133
|
+
*/
|
|
134
|
+
declare function sentinelEventPlayTs(ev: {
|
|
135
|
+
timestamp?: number;
|
|
136
|
+
ts?: number;
|
|
137
|
+
startTs?: number;
|
|
138
|
+
}): number;
|
|
139
|
+
type SentinelStorageStatus = 'unknown' | 'unreachable' | 'reachable' | 'filling';
|
|
140
|
+
interface SentinelStorageForecast {
|
|
141
|
+
/** Days spanned by the archive (earliest segment → now). */
|
|
142
|
+
spanDays: number;
|
|
143
|
+
/** Bytes recorded per day (0 until at least an hour of footage exists). */
|
|
144
|
+
ratePerDay: number;
|
|
145
|
+
/** Free-space guard reserve, clamped to the disk size. */
|
|
146
|
+
reserve: number;
|
|
147
|
+
/** Free bytes above the reserve. */
|
|
148
|
+
usableFree: number;
|
|
149
|
+
/** Bytes available for recordings = archive + usable free. */
|
|
150
|
+
capacity: number;
|
|
151
|
+
/** Days the archive can grow to at the current rate (0 = unknown). */
|
|
152
|
+
fitsDays: number;
|
|
153
|
+
/** Days until the reserve is reached (0 = unknown). */
|
|
154
|
+
fillsInDays: number;
|
|
155
|
+
/** The disk is effectively full (free ≤ 2 % above the reserve) → steady-state eviction. */
|
|
156
|
+
steady: boolean;
|
|
157
|
+
/** Bytes used by something other than recordings. */
|
|
158
|
+
other: number;
|
|
159
|
+
/** Which forecast message applies. */
|
|
160
|
+
status: SentinelStorageStatus;
|
|
161
|
+
}
|
|
162
|
+
declare function sentinelStorageForecast(st: SentinelStats, nowMs?: number): SentinelStorageForecast;
|
|
163
|
+
interface SentinelRun {
|
|
164
|
+
s: number;
|
|
165
|
+
e: number;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Contiguous recording runs: segments whose gap is ≤ `gapMs` (2 s) are drawn as
|
|
169
|
+
* ONE band (otherwise every 60-s segment boundary shows a notch).
|
|
170
|
+
*/
|
|
171
|
+
declare function sentinelClipRuns(clips: SentinelClip[], gapMs?: number): SentinelRun[];
|
|
172
|
+
/** Index of the clip covering `ts` (60 s assumed when a duration is missing), −1 if none. */
|
|
173
|
+
declare function sentinelClipIndexFor(clips: SentinelClip[], ts: number): number;
|
|
174
|
+
/** Local-midnight start of the day containing `ts`. */
|
|
175
|
+
declare function sentinelDayOf(ts: number): number;
|
|
176
|
+
/** Merge per-day clip responses into one sorted, continuous data set. */
|
|
177
|
+
declare function sentinelMergeDays(days: Record<number, SentinelClipsResponse>): {
|
|
178
|
+
clips: SentinelClip[];
|
|
179
|
+
events: SentinelEvent[];
|
|
180
|
+
motion: [number, number][];
|
|
181
|
+
codecs: string | null;
|
|
182
|
+
oldestDay: number | null;
|
|
183
|
+
};
|
|
184
|
+
/** Human-readable byte size split into value + unit (binary steps). */
|
|
185
|
+
declare function sentinelHumanBytes(b: number): {
|
|
186
|
+
value: string;
|
|
187
|
+
unit: string;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Browser client for the Sentinel NVR plugin's token-guarded `/public/` endpoint.
|
|
192
|
+
*
|
|
193
|
+
* The plugin sends permissive CORS on every answer (JSON, the 204 control replies,
|
|
194
|
+
* api/segment incl. 206, api/livemse, images, errors) and answers OPTIONS, so a
|
|
195
|
+
* cross-origin static SPA can talk to it directly with `?token=`. Media elements
|
|
196
|
+
* may use crossOrigin="anonymous". Older builds (JSON-only CORS) degrade: control()
|
|
197
|
+
* treats an unreadable reply as delivered; media fetches fall into the next fallback.
|
|
198
|
+
*/
|
|
199
|
+
declare class SentinelClient {
|
|
200
|
+
readonly origin: string;
|
|
201
|
+
readonly token: string;
|
|
202
|
+
readonly base: string;
|
|
203
|
+
/** "Open Sentinel" target: public base without token → sign-in / 302 into the session. */
|
|
204
|
+
readonly entryUrl: string;
|
|
205
|
+
/** fetch() can read media bodies (segments, live fMP4): the plugin sends CORS on media. */
|
|
206
|
+
readonly corsMedia = true;
|
|
207
|
+
constructor(origin: string, token: string);
|
|
208
|
+
/** Cache key — a new client is built whenever origin/token change. */
|
|
209
|
+
get key(): string;
|
|
210
|
+
url(path: string): string;
|
|
211
|
+
/** JSON GET with a timeout (an unreachable host otherwise waits for the browser's TCP timeout). */
|
|
212
|
+
getJson<T>(path: string, signal?: AbortSignal, timeoutMs?: number): Promise<T>;
|
|
213
|
+
/**
|
|
214
|
+
* Fire a control GET (204 = ok, 404 = session gone). Resolves `r.ok`. A rejected
|
|
215
|
+
* fetch (network error, or an older plugin whose 204 lacks CORS headers — the
|
|
216
|
+
* command was delivered either way) resolves true; dead sessions surface via relay-pos.
|
|
217
|
+
*/
|
|
218
|
+
control(path: string): Promise<boolean>;
|
|
219
|
+
/** Text POST (client telemetry). Best effort, never throws. */
|
|
220
|
+
postText(path: string, body: string): void;
|
|
221
|
+
/** WebSocket signaling URL (the plugin's Engine.IO handler). */
|
|
222
|
+
signalingUrl(cameraId: string, params?: Record<string, string>): string;
|
|
223
|
+
snapshotUrl(cameraId: string, bust?: number): string;
|
|
224
|
+
eventThumbUrl(cameraId: string, ts: number): string;
|
|
225
|
+
eventFrameUrl(cameraId: string, ts: number): string;
|
|
226
|
+
segmentThumbUrl(thumbId: string): string;
|
|
227
|
+
}
|
|
228
|
+
declare class SentinelHttpError extends Error {
|
|
229
|
+
readonly status: number;
|
|
230
|
+
constructor(status: number, path: string);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export { sentinelLoginBase as A, sentinelMergeDays as B, sentinelPublicBase as C, sentinelStorageForecast as D, sentinelTimelineLink as E, sentinelUrl as F, SENTINEL_DAY_MS as S, SENTINEL_ENDPOINT_PATH as a, SENTINEL_EVENT_CLASSES as b, type SentinelBox as c, type SentinelCamera as d, SentinelClient as e, type SentinelClip as f, type SentinelClipsResponse as g, type SentinelEvent as h, type SentinelEventBox as i, type SentinelEventClass as j, SentinelHttpError as k, type SentinelRecentEvent as l, type SentinelRun as m, type SentinelSetup as n, type SentinelStats as o, type SentinelStorageForecast as p, type SentinelStorageStatus as q, parseSentinelSetup as r, sentinelClassOf as s, sentinelClassesOf as t, sentinelClipIndexFor as u, sentinelClipRuns as v, sentinelDayOf as w, sentinelEntryUrl as x, sentinelEventPlayTs as y, sentinelHumanBytes as z };
|