@sentinel-nvr/web 0.1.0 → 0.1.1

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.
@@ -1,4 +1,4 @@
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';
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-DuIb-Dez.js';
2
2
 
3
3
  /**
4
4
  * Locale-aware, translation-free formatting (Intl only). Word-dependent helpers
package/dist/api/index.js CHANGED
@@ -132,10 +132,18 @@ var SentinelClient = class {
132
132
  entryUrl;
133
133
  /** fetch() can read media bodies (segments, live fMP4): the plugin sends CORS on media. */
134
134
  corsMedia = true;
135
- constructor(origin, token) {
135
+ /**
136
+ * @param origin Scrypted origin, e.g. https://host:10443
137
+ * @param token access token (empty behind a Scrypted login session)
138
+ * @param opts.base override the request base: needed when the plugin is served
139
+ * under a reverse-proxy prefix, or behind the Scrypted login path
140
+ * (`…/endpoint/@local/sentinel-nvr/`, cookie session, no token). Defaults to
141
+ * the token-guarded public base below `origin`.
142
+ */
143
+ constructor(origin, token, opts = {}) {
136
144
  this.origin = origin;
137
145
  this.token = token;
138
- this.base = sentinelPublicBase(origin);
146
+ this.base = opts.base ? opts.base.replace(/\/*$/, "/") : sentinelPublicBase(origin);
139
147
  this.entryUrl = sentinelEntryUrl(origin);
140
148
  }
141
149
  /** Cache key — a new client is built whenever origin/token change. */
@@ -1 +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":[]}
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 /**\n * @param origin Scrypted origin, e.g. https://host:10443\n * @param token access token (empty behind a Scrypted login session)\n * @param opts.base override the request base: needed when the plugin is served\n * under a reverse-proxy prefix, or behind the Scrypted login path\n * (`…/endpoint/@local/sentinel-nvr/`, cookie session, no token). Defaults to\n * the token-guarded public base below `origin`.\n */\n constructor(origin: string, token: string, opts: { base?: string } = {}) {\n this.origin = origin;\n this.token = token;\n this.base = opts.base ? opts.base.replace(/\\/*$/, '/') : 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":[]}
@@ -204,7 +204,17 @@ declare class SentinelClient {
204
204
  readonly entryUrl: string;
205
205
  /** fetch() can read media bodies (segments, live fMP4): the plugin sends CORS on media. */
206
206
  readonly corsMedia = true;
207
- constructor(origin: string, token: string);
207
+ /**
208
+ * @param origin Scrypted origin, e.g. https://host:10443
209
+ * @param token access token (empty behind a Scrypted login session)
210
+ * @param opts.base override the request base: needed when the plugin is served
211
+ * under a reverse-proxy prefix, or behind the Scrypted login path
212
+ * (`…/endpoint/@local/sentinel-nvr/`, cookie session, no token). Defaults to
213
+ * the token-guarded public base below `origin`.
214
+ */
215
+ constructor(origin: string, token: string, opts?: {
216
+ base?: string;
217
+ });
208
218
  /** Cache key — a new client is built whenever origin/token change. */
209
219
  get key(): string;
210
220
  url(path: string): string;
@@ -1,4 +1,4 @@
1
- import { f as SentinelClip, e as SentinelClient } from '../client-BU0GTX3b.js';
1
+ import { f as SentinelClip, e as SentinelClient } from '../client-DuIb-Dez.js';
2
2
 
3
3
  /**
4
4
  * PlayerController — the whole playback brain of the NVR. Framework-free: it owns
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinel-nvr/web",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Shared browser code for the Sentinel NVR plugin for Scrypted: API model + client (/api), player (/player) and React UI (/ui, planned). Consumed by the plugin's own web UI and the HAPulse dashboard.",
5
5
  "license": "MIT",
6
6
  "author": "Muggedadscher",