@@ -42,10 +42,71 @@ export type WebUserDto = {
4242 cameras : string [ ] | null
4343}
4444
45+ // One recorded file. `playable` is false for H.265, which browsers won't decode
46+ // — the UI then offers a download instead of a dead player.
47+ export type RecordingDto = {
48+ id : string
49+ cameraId : string
50+ cameraName : string | null
51+ fileName : string
52+ startedAt : string
53+ endedAt : string | null
54+ durationSeconds : number | null
55+ sizeBytes : number
56+ codec : string | null
57+ hasMotion : boolean
58+ playable : boolean
59+ }
60+
61+ // One dot on the archive calendar. Deliberately minimal: the browser groups
62+ // these into days in its own time zone.
63+ // A page of the archive: the rows plus where they sit in the whole set.
64+ export type RecordingPageDto = {
65+ total : number
66+ offset : number
67+ limit : number
68+ items : RecordingDto [ ]
69+ }
70+
71+ export type CalendarPointDto = { startedAt : string ; sizeBytes : number }
72+
4573export type GroupDto = { id : number ; name : string ; sortOrder : number }
4674
4775export type PtzPresetDto = { token : string ; name : string }
4876
77+ // A snapshot kept in the shared library (same rows the desktop browser reads).
78+ export type SnapshotDto = {
79+ id : string
80+ cameraId : string
81+ cameraName : string | null
82+ takenAt : string
83+ width : number
84+ height : number
85+ hasThumb : boolean
86+ source : string
87+ }
88+
89+ // A Majestic config.json, flattened by the server into whatever scalar knobs the
90+ // live config actually exposes — the schema drifts between firmware builds, so
91+ // nothing here is a fixed field list.
92+ export type MajesticFieldDto = {
93+ path : string
94+ section : string
95+ key : string
96+ kind : 'string' | 'bool' | 'int' | 'number' | 'enum'
97+ value : string
98+ options : string [ ] | null
99+ // Changing this one restarts the video pipeline (the tile will blink).
100+ restart : boolean
101+ }
102+
103+ export type MajesticDto = {
104+ info : { model : string | null ; firmware : string | null ; chip : string | null ; uptime : string | null } | null
105+ nightMode : 'unknown' | 'day' | 'night' | 'auto'
106+ sections : { name : string ; fields : MajesticFieldDto [ ] } [ ]
107+ rawJson : string
108+ }
109+
49110export type LayoutDto = {
50111 id : number
51112 name : string
@@ -126,6 +187,14 @@ export class ApiError extends Error {
126187 }
127188}
128189
190+ // Drops empty values so the URL only carries the filters that are actually set.
191+ function query ( params : Record < string , string | number | undefined > ) : string {
192+ const pairs = Object . entries ( params )
193+ . filter ( ( [ , v ] ) => v !== undefined && v !== '' )
194+ . map ( ( [ k , v ] ) => [ k , String ( v ) ] as [ string , string ] )
195+ return pairs . length ? '?' + new URLSearchParams ( pairs ) . toString ( ) : ''
196+ }
197+
129198async function req < T > ( method : string , path : string , body ?: unknown ) : Promise < T > {
130199 const res = await fetch ( path , {
131200 method,
@@ -174,6 +243,56 @@ export const api = {
174243 req < CameraDraftDto > ( 'POST' , '/api/v1/discovery/probe' , body ) ,
175244 addDiscovered : ( body : DiscoveryAdd ) => req < CameraDto > ( 'POST' , '/api/v1/discovery/add' , body ) ,
176245
246+ // The recorded archive. Playback is a ranged file response, so the URL goes
247+ // straight into a <video> and the browser does its own seeking.
248+ recordings : ( filter : { cameraId ?: string ; from ?: string ; to ?: string ; offset ?: number ; limit ?: number } = { } ) =>
249+ req < RecordingPageDto > (
250+ 'GET' ,
251+ '/api/v1/recordings' +
252+ query ( {
253+ ...filter ,
254+ offset : filter . offset ? String ( filter . offset ) : undefined ,
255+ limit : filter . limit ? String ( filter . limit ) : undefined ,
256+ } ) ,
257+ ) ,
258+ // Cut of a recording, as a download URL (start/end are seconds into the file).
259+ recordingExportUrl : ( id : string , start : number , end : number , precise : boolean ) =>
260+ `/api/v1/recordings/${ id } /export?start=${ start . toFixed ( 2 ) } &end=${ end . toFixed ( 2 ) } ` +
261+ ( precise ? '&precise=true' : '' ) ,
262+ recordingCalendar : ( filter : { cameraId ?: string ; from ?: string ; to ?: string } = { } ) =>
263+ req < CalendarPointDto [ ] > ( 'GET' , '/api/v1/recordings/calendar' + query ( filter ) ) ,
264+ recordingStreamUrl : ( id : string , download = false ) =>
265+ `/api/v1/recordings/${ id } /stream` + ( download ? '?download=true' : '' ) ,
266+ deleteRecording : ( id : string ) => req < void > ( 'DELETE' , `/api/v1/recordings/${ id } ` ) ,
267+ // Camera ids currently being recorded by this server.
268+ activeRecordings : ( ) => req < string [ ] > ( 'GET' , '/api/v1/recordings/active' ) ,
269+ startRecording : ( cameraId : string ) =>
270+ req < { id : string ; startedAt : string } > ( 'POST' , `/api/v1/cameras/${ cameraId } /recording/start` ) ,
271+ stopRecording : ( cameraId : string ) =>
272+ req < { id : string ; sizeBytes : number } > ( 'POST' , `/api/v1/cameras/${ cameraId } /recording/stop` ) ,
273+
274+ // A fresh still, straight from the camera (Majestic /image.jpg when available,
275+ // otherwise one ffmpeg pull). Used as an <img> src and as a download target,
276+ // so it's a URL rather than a fetch wrapper.
277+ snapshotUrl : ( id : string ) => `/api/v1/cameras/${ id } /snapshot` ,
278+
279+ // Keep a still: captured the same way as the preview above, but written into
280+ // the shared library so the desktop gallery sees it too.
281+ saveSnapshot : ( cameraId : string ) => req < SnapshotDto > ( 'POST' , `/api/v1/cameras/${ cameraId } /snapshots` ) ,
282+ snapshots : ( q : { cameraId ?: string ; offset ?: number ; limit ?: number } = { } ) => {
283+ const p = new URLSearchParams ( )
284+ if ( q . cameraId ) p . set ( 'cameraId' , q . cameraId )
285+ if ( q . offset ) p . set ( 'offset' , String ( q . offset ) )
286+ if ( q . limit ) p . set ( 'limit' , String ( q . limit ) )
287+ const qs = p . toString ( )
288+ return req < { total : number ; offset : number ; limit : number ; items : SnapshotDto [ ] } > (
289+ 'GET' , '/api/v1/snapshots' + ( qs ? '?' + qs : '' ) )
290+ } ,
291+ // A URL rather than a fetch: these are <img> sources and download targets.
292+ snapshotImageUrl : ( id : string , thumb = false , download = false ) =>
293+ `/api/v1/snapshots/${ id } /image` + ( thumb ? '?thumb=true' : download ? '?download=true' : '' ) ,
294+ deleteSnapshot : ( id : string ) => req < void > ( 'DELETE' , `/api/v1/snapshots/${ id } ` ) ,
295+
177296 // PTZ. Movement is stateless on the server: each move carries a self-stop
178297 // timeout, so the caller must repeat it while a direction is held (see PtzPad)
179298 // and send stop on release. A camera that never gets the stop halts on its own.
@@ -188,6 +307,26 @@ export const api = {
188307 ptzDeletePreset : ( id : string , token : string ) =>
189308 req < void > ( 'DELETE' , `/api/v1/cameras/${ id } /ptz/presets/${ encodeURIComponent ( token ) } ` ) ,
190309
310+ // Camera settings (Majestic config.json). Manage-only on the server, reads
311+ // included: the config carries the camera's own logins.
312+ majestic : ( id : string ) => req < MajesticDto > ( 'GET' , `/api/v1/cameras/${ id } /majestic` ) ,
313+ // Only changed fields travel; the server re-reads the camera's config and
314+ // applies them onto it, so a stale tab can't revert someone else's edit.
315+ majesticApply : ( id : string , edits : { path : string ; value : string } [ ] ) =>
316+ req < { applied : number ; restart : boolean } > ( 'POST' , `/api/v1/cameras/${ id } /majestic/config` , { edits } ) ,
317+ majesticRaw : ( id : string , json : string ) =>
318+ req < void > ( 'POST' , `/api/v1/cameras/${ id } /majestic/raw` , { json } ) ,
319+ majesticNight : ( id : string , mode : 'day' | 'night' | 'auto' ) =>
320+ req < void > ( 'POST' , `/api/v1/cameras/${ id } /majestic/night` , { mode } ) ,
321+ majesticMetrics : ( id : string ) =>
322+ req < { name : string ; value : number } [ ] > ( 'GET' , `/api/v1/cameras/${ id } /majestic/metrics` ) ,
323+
324+ // Can this camera be talked to, and is someone already doing it? A real RTSP
325+ // probe, so it costs a round-trip to the camera — the UI asks on press, not
326+ // on page load.
327+ talkProbe : ( id : string ) =>
328+ req < { supported : boolean | null ; busy : boolean } > ( 'GET' , `/api/v1/cameras/${ id } /talk` ) ,
329+
191330 system : ( ) =>
192331 req < { version : string ; cameras : number ; groups : number ; sessions : number ; streams : number } > (
193332 'GET' ,
0 commit comments