Skip to main content

heldar_kernel/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use sqlx::types::Json;
5use sqlx::FromRow;
6
7use crate::camera_url;
8
9/// Camera row as stored. `password` is never serialized to clients; use [`CameraView`] for output.
10#[derive(Debug, Clone, FromRow)]
11pub struct Camera {
12    pub id: String,
13    pub site_id: Option<String>,
14    pub name: String,
15    pub vendor: String,
16    pub model: Option<String>,
17    pub address: Option<String>,
18    pub rtsp_port: i64,
19    pub username: Option<String>,
20    pub password: Option<String>,
21    pub main_stream_url: Option<String>,
22    pub sub_stream_url: Option<String>,
23    pub record_stream: String,
24    pub codec: Option<String>,
25    pub resolution_main: Option<String>,
26    pub resolution_sub: Option<String>,
27    pub fps_main: Option<i64>,
28    pub fps_sub: Option<i64>,
29    pub capabilities: Json<Value>,
30    pub record_enabled: bool,
31    pub segment_seconds: i64,
32    pub retention_hours: i64,
33    /// Per-camera storage quota in bytes; NULL means no per-camera cap.
34    pub storage_quota_bytes: Option<i64>,
35    /// Record the camera's audio stream (pass-through) instead of dropping it.
36    pub record_audio: bool,
37    /// When the recorder runs: `continuous` | `scheduled` | `event` | `scheduled_event`.
38    pub record_mode: String,
39    /// Event recording: footage desired BEFORE a trigger (best-effort, see recorder service).
40    pub pre_roll_seconds: i64,
41    /// Event recording: how long the recorder keeps writing after a trigger (the trigger window).
42    pub post_roll_seconds: i64,
43    /// Run a SECOND ffmpeg pipeline writing identical segments to HELDAR_MIRROR_RECORDINGS_DIR
44    /// (redundant DVR copy). No-op unless the mirror dir is configured.
45    pub mirror_enabled: bool,
46    /// Let the ANR loop re-fetch missed footage from the camera's onboard storage to fill gaps.
47    pub anr_enabled: bool,
48    /// Optional replay URL template for ANR ({start}/{end} placeholders); NULL = default Hikvision
49    /// RTSP playback built from address+credentials.
50    pub anr_replay_url_template: Option<String>,
51    pub enabled: bool,
52    pub created_at: DateTime<Utc>,
53    pub updated_at: DateTime<Utc>,
54}
55
56impl Camera {
57    /// Whether the recorder should be running a process for this camera.
58    pub fn should_record(&self) -> bool {
59        self.enabled && self.record_enabled
60    }
61}
62
63/// Client-facing camera representation: credentials stripped, stream URLs masked.
64#[derive(Debug, Clone, Serialize)]
65pub struct CameraView {
66    pub id: String,
67    pub site_id: Option<String>,
68    pub name: String,
69    pub vendor: String,
70    pub model: Option<String>,
71    pub address: Option<String>,
72    pub rtsp_port: i64,
73    pub username: Option<String>,
74    pub has_password: bool,
75    pub record_stream: String,
76    /// Effective RTSP URL for the recorded stream, with credentials masked.
77    pub record_url_masked: Option<String>,
78    pub codec: Option<String>,
79    pub resolution_main: Option<String>,
80    pub resolution_sub: Option<String>,
81    pub fps_main: Option<i64>,
82    pub fps_sub: Option<i64>,
83    pub capabilities: Value,
84    pub record_enabled: bool,
85    pub segment_seconds: i64,
86    pub retention_hours: i64,
87    pub storage_quota_bytes: Option<i64>,
88    pub record_audio: bool,
89    pub record_mode: String,
90    pub pre_roll_seconds: i64,
91    pub post_roll_seconds: i64,
92    pub mirror_enabled: bool,
93    pub anr_enabled: bool,
94    pub anr_replay_url_template: Option<String>,
95    pub enabled: bool,
96    pub created_at: DateTime<Utc>,
97    pub updated_at: DateTime<Utc>,
98}
99
100impl From<Camera> for CameraView {
101    fn from(c: Camera) -> Self {
102        let record_url_masked = camera_url::record_url(&c).map(|u| camera_url::mask_url(&u));
103        CameraView {
104            id: c.id,
105            site_id: c.site_id,
106            name: c.name,
107            vendor: c.vendor,
108            model: c.model,
109            address: c.address,
110            rtsp_port: c.rtsp_port,
111            username: c.username,
112            has_password: c
113                .password
114                .as_deref()
115                .map(|p| !p.is_empty())
116                .unwrap_or(false),
117            record_stream: c.record_stream,
118            record_url_masked,
119            codec: c.codec,
120            resolution_main: c.resolution_main,
121            resolution_sub: c.resolution_sub,
122            fps_main: c.fps_main,
123            fps_sub: c.fps_sub,
124            capabilities: c.capabilities.0,
125            record_enabled: c.record_enabled,
126            segment_seconds: c.segment_seconds,
127            retention_hours: c.retention_hours,
128            storage_quota_bytes: c.storage_quota_bytes,
129            record_audio: c.record_audio,
130            record_mode: c.record_mode,
131            pre_roll_seconds: c.pre_roll_seconds,
132            post_roll_seconds: c.post_roll_seconds,
133            mirror_enabled: c.mirror_enabled,
134            anr_enabled: c.anr_enabled,
135            anr_replay_url_template: c.anr_replay_url_template,
136            enabled: c.enabled,
137            created_at: c.created_at,
138            updated_at: c.updated_at,
139        }
140    }
141}
142
143/// Payload to create a camera. `id` may be omitted (slug auto-derived from name).
144#[derive(Debug, Deserialize)]
145pub struct CameraCreate {
146    pub id: Option<String>,
147    pub name: String,
148    pub site_id: Option<String>,
149    #[serde(default = "default_vendor")]
150    pub vendor: String,
151    pub model: Option<String>,
152    pub address: Option<String>,
153    pub rtsp_port: Option<i64>,
154    pub username: Option<String>,
155    pub password: Option<String>,
156    pub main_stream_url: Option<String>,
157    pub sub_stream_url: Option<String>,
158    pub record_stream: Option<String>,
159    pub capabilities: Option<Value>,
160    pub record_enabled: Option<bool>,
161    pub segment_seconds: Option<i64>,
162    pub retention_hours: Option<i64>,
163    pub storage_quota_bytes: Option<i64>,
164    pub record_audio: Option<bool>,
165    pub record_mode: Option<String>,
166    pub pre_roll_seconds: Option<i64>,
167    pub post_roll_seconds: Option<i64>,
168    pub mirror_enabled: Option<bool>,
169    pub anr_enabled: Option<bool>,
170    pub anr_replay_url_template: Option<String>,
171    pub enabled: Option<bool>,
172}
173
174fn default_vendor() -> String {
175    "generic".to_string()
176}
177
178/// Partial update; only present fields are changed.
179#[derive(Debug, Deserialize, Default)]
180pub struct CameraUpdate {
181    pub name: Option<String>,
182    pub site_id: Option<String>,
183    pub vendor: Option<String>,
184    pub model: Option<String>,
185    pub address: Option<String>,
186    pub rtsp_port: Option<i64>,
187    pub username: Option<String>,
188    pub password: Option<String>,
189    pub main_stream_url: Option<String>,
190    pub sub_stream_url: Option<String>,
191    pub record_stream: Option<String>,
192    pub capabilities: Option<Value>,
193    pub record_enabled: Option<bool>,
194    pub segment_seconds: Option<i64>,
195    pub retention_hours: Option<i64>,
196    pub storage_quota_bytes: Option<i64>,
197    pub record_audio: Option<bool>,
198    pub record_mode: Option<String>,
199    pub pre_roll_seconds: Option<i64>,
200    pub post_roll_seconds: Option<i64>,
201    pub mirror_enabled: Option<bool>,
202    pub anr_enabled: Option<bool>,
203    pub anr_replay_url_template: Option<String>,
204    pub enabled: Option<bool>,
205}
206
207#[derive(Debug, Clone, Serialize, FromRow)]
208pub struct Segment {
209    pub id: String,
210    pub camera_id: String,
211    pub path: String,
212    pub start_time: DateTime<Utc>,
213    pub end_time: DateTime<Utc>,
214    pub duration_s: f64,
215    pub codec: Option<String>,
216    pub width: Option<i64>,
217    pub height: Option<i64>,
218    pub size_bytes: i64,
219    pub container: String,
220    /// Transient read-lock held by clip/snapshot export; cleared at startup. Not durable.
221    pub locked: bool,
222    /// Durable evidence hold: when true the segment is never pruned by retention. Set via the
223    /// incident API; survives restarts (unlike `locked`).
224    pub evidence_locked: bool,
225    pub incident_id: Option<String>,
226    pub created_at: DateTime<Utc>,
227}
228
229/// A recording gap detected by the indexer (a hole > 3s between consecutive segments). The ANR loop
230/// (services/anr.rs) tries to re-fill pending gaps from the camera's onboard storage. `fill_state` is
231/// `pending` | `filled` | `failed`.
232#[derive(Debug, Clone, Serialize, FromRow)]
233pub struct RecordingGap {
234    pub id: String,
235    pub camera_id: String,
236    pub gap_start: DateTime<Utc>,
237    pub gap_end: DateTime<Utc>,
238    pub gap_seconds: i64,
239    pub fill_state: String,
240    pub fill_attempts: i64,
241    pub last_attempt_at: Option<DateTime<Utc>>,
242    pub filled_at: Option<DateTime<Utc>>,
243    pub created_at: DateTime<Utc>,
244}
245
246#[derive(Debug, Clone, Serialize, FromRow)]
247pub struct CameraStatus {
248    pub camera_id: String,
249    pub state: String,
250    pub last_segment_at: Option<DateTime<Utc>>,
251    pub last_started_at: Option<DateTime<Utc>>,
252    pub reconnect_count: i64,
253    pub segments_written: i64,
254    pub fps_observed: Option<f64>,
255    pub bitrate_kbps: Option<f64>,
256    pub last_error: Option<String>,
257    pub recorder_pid: Option<i64>,
258    pub updated_at: DateTime<Utc>,
259}
260
261#[derive(Debug, Clone, Serialize, FromRow)]
262pub struct Event {
263    pub id: String,
264    pub camera_id: Option<String>,
265    pub site_id: Option<String>,
266    pub event_type: String,
267    pub severity: String,
268    pub timestamp: DateTime<Utc>,
269    pub payload: Json<Value>,
270    pub created_at: DateTime<Utc>,
271}
272
273// ---- Stage 2: AI frame sampling ----
274
275/// A perception task to run on a camera (consumed by AI workers).
276#[derive(Debug, Clone, Serialize, FromRow)]
277pub struct AiTask {
278    pub id: String,
279    pub camera_id: String,
280    pub task_type: String,
281    pub enabled: bool,
282    pub stream_profile: String,
283    pub fps: f64,
284    pub width: i64,
285    pub config: Json<Value>,
286    pub created_at: DateTime<Utc>,
287    pub updated_at: DateTime<Utc>,
288}
289
290#[derive(Debug, Deserialize)]
291pub struct AiTaskCreate {
292    pub task_type: String,
293    pub stream_profile: Option<String>,
294    pub fps: Option<f64>,
295    pub width: Option<i64>,
296    pub config: Option<Value>,
297    pub enabled: Option<bool>,
298}
299
300#[derive(Debug, Deserialize, Default)]
301pub struct AiTaskUpdate {
302    pub task_type: Option<String>,
303    pub stream_profile: Option<String>,
304    pub fps: Option<f64>,
305    pub width: Option<i64>,
306    pub config: Option<Value>,
307    pub enabled: Option<bool>,
308}
309
310/// A detection result posted by an AI worker.
311#[derive(Debug, Clone, Serialize, FromRow)]
312pub struct Detection {
313    pub id: String,
314    pub camera_id: String,
315    pub task_type: String,
316    pub timestamp: DateTime<Utc>,
317    pub label: Option<String>,
318    pub confidence: Option<f64>,
319    pub bbox: Option<Json<Value>>,
320    pub track_id: Option<String>,
321    pub attributes: Json<Value>,
322    /// Worker-supplied per-camera frame id this detection belongs to (idempotency / batch grouping).
323    pub frame_id: Option<String>,
324    pub created_at: DateTime<Utc>,
325}
326
327/// One detection inside an ingest request.
328#[derive(Debug, Deserialize)]
329pub struct DetectionIngest {
330    pub label: Option<String>,
331    pub confidence: Option<f64>,
332    pub bbox: Option<Value>,
333    pub track_id: Option<String>,
334    pub attributes: Option<Value>,
335}
336
337/// Optional event an AI worker can raise alongside its detections.
338#[derive(Debug, Deserialize)]
339pub struct IngestEvent {
340    pub event_type: String,
341    pub severity: Option<String>,
342    pub payload: Option<Value>,
343}
344
345/// Payload an AI worker POSTs to ingest detections (and optionally an event) for a camera.
346#[derive(Debug, Deserialize)]
347pub struct AiIngest {
348    pub camera_id: String,
349    pub task_type: String,
350    pub timestamp: Option<String>,
351    /// Optional per-camera monotonic frame id. When present, ingest is idempotent on
352    /// (camera_id, frame_id): a duplicate redelivery is a no-op (no double-insert, no re-fire of
353    /// consumer side effects). Omit it (e.g. the dependency-light client) to accept every batch.
354    pub frame_id: Option<String>,
355    #[serde(default)]
356    pub detections: Vec<DetectionIngest>,
357    pub event: Option<IngestEvent>,
358}
359
360// ---- Stage 3: zones + zone events ----
361
362/// A polygon region on a camera; tracked detections crossing it raise enter/exit/dwell events.
363#[derive(Debug, Clone, Serialize, FromRow)]
364pub struct Zone {
365    pub id: String,
366    pub camera_id: String,
367    pub name: String,
368    pub kind: String,
369    /// JSON array of [x, y] vertices, normalized 0..1.
370    pub polygon: Json<Value>,
371    pub dwell_seconds: f64,
372    /// JSON array of detection labels that count toward this zone (empty = all labels).
373    pub labels: Json<Value>,
374    pub severity: String,
375    pub config: Json<Value>,
376    pub enabled: bool,
377    pub created_at: DateTime<Utc>,
378    pub updated_at: DateTime<Utc>,
379}
380
381#[derive(Debug, Deserialize)]
382pub struct ZoneCreate {
383    pub name: String,
384    pub kind: Option<String>,
385    pub polygon: Value,
386    pub dwell_seconds: Option<f64>,
387    pub labels: Option<Value>,
388    pub severity: Option<String>,
389    pub config: Option<Value>,
390    pub enabled: Option<bool>,
391}
392
393#[derive(Debug, Deserialize, Default)]
394pub struct ZoneUpdate {
395    pub name: Option<String>,
396    pub kind: Option<String>,
397    pub polygon: Option<Value>,
398    pub dwell_seconds: Option<f64>,
399    pub labels: Option<Value>,
400    pub severity: Option<String>,
401    pub config: Option<Value>,
402    pub enabled: Option<bool>,
403}
404
405#[derive(Debug, Clone, Serialize, FromRow)]
406pub struct ZoneEvent {
407    pub id: String,
408    pub camera_id: String,
409    pub zone_id: String,
410    pub zone_name: String,
411    pub track_id: Option<String>,
412    pub event_type: String,
413    pub label: Option<String>,
414    pub timestamp: DateTime<Utc>,
415    pub dwell_seconds: Option<f64>,
416    pub evidence_path: Option<String>,
417    pub created_at: DateTime<Utc>,
418}
419
420// ---- Stage 4: Access control — RBAC ----
421
422/// Operator account. `password_hash` is never serialized; use [`UserView`] for output.
423#[derive(Debug, Clone, FromRow)]
424pub struct User {
425    pub id: String,
426    pub username: String,
427    pub password_hash: String,
428    pub role: String,
429    pub display_name: Option<String>,
430    pub active: bool,
431    pub created_at: DateTime<Utc>,
432    pub updated_at: DateTime<Utc>,
433}
434
435#[derive(Debug, Clone, Serialize)]
436pub struct UserView {
437    pub id: String,
438    pub username: String,
439    pub role: String,
440    pub display_name: Option<String>,
441    pub active: bool,
442    pub created_at: DateTime<Utc>,
443    pub updated_at: DateTime<Utc>,
444}
445
446impl From<User> for UserView {
447    fn from(u: User) -> Self {
448        UserView {
449            id: u.id,
450            username: u.username,
451            role: u.role,
452            display_name: u.display_name,
453            active: u.active,
454            created_at: u.created_at,
455            updated_at: u.updated_at,
456        }
457    }
458}
459
460#[derive(Debug, Deserialize)]
461pub struct UserCreate {
462    pub username: String,
463    pub password: String,
464    pub role: Option<String>,
465    pub display_name: Option<String>,
466    pub active: Option<bool>,
467}
468
469#[derive(Debug, Deserialize, Default)]
470pub struct UserUpdate {
471    pub password: Option<String>,
472    pub role: Option<String>,
473    pub display_name: Option<String>,
474    pub active: Option<bool>,
475}
476
477#[derive(Debug, Deserialize)]
478pub struct LoginRequest {
479    pub username: String,
480    pub password: String,
481}
482
483#[derive(Debug, Clone, FromRow)]
484pub struct ApiKey {
485    pub id: String,
486    pub name: String,
487    /// Mapped from the row for completeness; never exposed (see [`ApiKeyView`]).
488    pub key_hash: String,
489    pub key_prefix: String,
490    pub role: String,
491    pub active: bool,
492    pub last_used_at: Option<DateTime<Utc>>,
493    pub created_at: DateTime<Utc>,
494}
495
496#[derive(Debug, Clone, Serialize)]
497pub struct ApiKeyView {
498    pub id: String,
499    pub name: String,
500    pub key_prefix: String,
501    pub role: String,
502    pub active: bool,
503    pub last_used_at: Option<DateTime<Utc>>,
504    pub created_at: DateTime<Utc>,
505}
506
507impl From<ApiKey> for ApiKeyView {
508    fn from(k: ApiKey) -> Self {
509        ApiKeyView {
510            id: k.id,
511            name: k.name,
512            key_prefix: k.key_prefix,
513            role: k.role,
514            active: k.active,
515            last_used_at: k.last_used_at,
516            created_at: k.created_at,
517        }
518    }
519}
520
521#[derive(Debug, Deserialize)]
522pub struct ApiKeyCreate {
523    pub name: String,
524    pub role: Option<String>,
525}
526
527// ---- Scheduled interval snapshots ----
528
529/// A per-camera schedule that captures a live JPEG every `interval_seconds`.
530#[derive(Debug, Clone, Serialize, FromRow)]
531pub struct SnapshotSchedule {
532    pub id: String,
533    pub camera_id: String,
534    pub interval_seconds: i64,
535    pub enabled: bool,
536    pub last_fired_at: Option<DateTime<Utc>>,
537    pub created_at: DateTime<Utc>,
538    pub updated_at: DateTime<Utc>,
539}
540
541#[derive(Debug, Deserialize)]
542pub struct SnapshotScheduleCreate {
543    pub interval_seconds: Option<i64>,
544    pub enabled: Option<bool>,
545}
546
547#[derive(Debug, Deserialize, Default)]
548pub struct SnapshotScheduleUpdate {
549    pub interval_seconds: Option<i64>,
550    pub enabled: Option<bool>,
551}
552
553/// A captured snapshot frame on disk (one file under snapshots_dir/{camera_id}/).
554#[derive(Debug, Clone, Serialize, FromRow)]
555pub struct PersistedSnapshot {
556    pub id: String,
557    pub camera_id: String,
558    pub schedule_id: Option<String>,
559    pub path: String,
560    pub taken_at: DateTime<Utc>,
561    pub size_bytes: i64,
562    pub created_at: DateTime<Utc>,
563}
564
565// ---- Per-camera recording schedule (time-of-day windows) ----
566
567/// A recurring per-camera recording window, applied when the camera's `record_mode` is `scheduled`
568/// or `scheduled_event`. `days` is a JSON array of weekday ints (0=Mon..6=Sun); `time_start` /
569/// `time_end` are "HH:MM" 24h in the SERVER's LOCAL timezone (chrono::Local). When `time_start` >
570/// `time_end` the window wraps past midnight (its early-morning portion is attributed to the day it
571/// started on).
572#[derive(Debug, Clone, Serialize, FromRow)]
573pub struct RecordSchedule {
574    pub id: String,
575    pub camera_id: String,
576    pub days: Json<Value>,
577    pub time_start: String,
578    pub time_end: String,
579    pub enabled: bool,
580    pub created_at: DateTime<Utc>,
581    pub updated_at: DateTime<Utc>,
582}
583
584#[derive(Debug, Deserialize)]
585pub struct RecordScheduleCreate {
586    /// JSON array of weekday ints (0=Mon..6=Sun).
587    pub days: Value,
588    /// "HH:MM" 24h, server local time.
589    pub time_start: String,
590    /// "HH:MM" 24h, server local time (start > end means an overnight window).
591    pub time_end: String,
592    pub enabled: Option<bool>,
593}
594
595#[derive(Debug, Deserialize, Default)]
596pub struct RecordScheduleUpdate {
597    pub days: Option<Value>,
598    pub time_start: Option<String>,
599    pub time_end: Option<String>,
600    pub enabled: Option<bool>,
601}
602
603// ---- Backup subsystem: destinations, policies, jobs, archive export ----
604
605/// Config keys that hold a secret. Masked in [`BackupDestinationView`] (and preserved across an
606/// update when the client round-trips the `***` placeholder back).
607pub const BACKUP_SECRET_KEYS: &[&str] = &["pass", "password", "secret_key", "secret"];
608
609/// A backup transfer target. `config` is a kind-specific JSON blob (credentials live here and are
610/// never serialized raw — use [`BackupDestinationView`]). Not `Serialize` for exactly that reason.
611#[derive(Debug, Clone, FromRow)]
612pub struct BackupDestination {
613    pub id: String,
614    pub name: String,
615    /// `local` | `sftp` | `ftp` | `s3`.
616    pub kind: String,
617    pub config: Json<Value>,
618    pub enabled: bool,
619    pub created_at: DateTime<Utc>,
620    pub updated_at: DateTime<Utc>,
621}
622
623/// Client-facing destination: secret config values are replaced with `***`.
624#[derive(Debug, Clone, Serialize)]
625pub struct BackupDestinationView {
626    pub id: String,
627    pub name: String,
628    pub kind: String,
629    /// The config blob with any secret values masked to `***`.
630    pub config: Value,
631    /// Whether at least one secret credential is configured (so the UI can show "set" without the value).
632    pub has_credentials: bool,
633    pub enabled: bool,
634    pub created_at: DateTime<Utc>,
635    pub updated_at: DateTime<Utc>,
636}
637
638/// Mask the secret values in a config blob, returning the masked blob and whether any secret was set.
639pub fn mask_backup_config(mut config: Value) -> (Value, bool) {
640    let mut has_credentials = false;
641    if let Some(obj) = config.as_object_mut() {
642        for key in BACKUP_SECRET_KEYS {
643            if let Some(v) = obj.get_mut(*key) {
644                if v.as_str().map(|s| !s.is_empty()).unwrap_or(false) {
645                    has_credentials = true;
646                    *v = Value::String("***".to_string());
647                }
648            }
649        }
650    }
651    (config, has_credentials)
652}
653
654impl From<BackupDestination> for BackupDestinationView {
655    fn from(d: BackupDestination) -> Self {
656        let (config, has_credentials) = mask_backup_config(d.config.0);
657        BackupDestinationView {
658            id: d.id,
659            name: d.name,
660            kind: d.kind,
661            config,
662            has_credentials,
663            enabled: d.enabled,
664            created_at: d.created_at,
665            updated_at: d.updated_at,
666        }
667    }
668}
669
670#[derive(Debug, Deserialize)]
671pub struct BackupDestinationCreate {
672    pub name: String,
673    /// `local` | `sftp` | `ftp` | `s3`.
674    pub kind: String,
675    pub config: Option<Value>,
676    pub enabled: Option<bool>,
677}
678
679#[derive(Debug, Deserialize, Default)]
680pub struct BackupDestinationUpdate {
681    pub name: Option<String>,
682    pub kind: Option<String>,
683    pub config: Option<Value>,
684    pub enabled: Option<bool>,
685}
686
687/// Result of POST /api/v1/backup/destinations/{id}/test (a connectivity / writability probe).
688#[derive(Debug, Clone, Serialize)]
689pub struct BackupTestResult {
690    pub ok: bool,
691    pub error: Option<String>,
692    pub latency_ms: i64,
693}
694
695/// A scheduled backup policy: ship a camera selection's recent footage to a destination on an interval.
696#[derive(Debug, Clone, Serialize, FromRow)]
697pub struct BackupPolicy {
698    pub id: String,
699    pub name: String,
700    pub destination_id: String,
701    /// JSON array of camera ids; empty array means all cameras.
702    pub camera_ids: Json<Value>,
703    pub incident_lock_only: bool,
704    pub schedule_interval_s: i64,
705    pub lookback_hours: i64,
706    pub last_run_at: Option<DateTime<Utc>>,
707    pub last_job_id: Option<String>,
708    pub enabled: bool,
709    pub created_at: DateTime<Utc>,
710    pub updated_at: DateTime<Utc>,
711}
712
713#[derive(Debug, Deserialize)]
714pub struct BackupPolicyCreate {
715    pub name: String,
716    pub destination_id: String,
717    pub camera_ids: Option<Value>,
718    pub incident_lock_only: Option<bool>,
719    pub schedule_interval_s: Option<i64>,
720    pub lookback_hours: Option<i64>,
721    pub enabled: Option<bool>,
722}
723
724#[derive(Debug, Deserialize, Default)]
725pub struct BackupPolicyUpdate {
726    pub name: Option<String>,
727    pub destination_id: Option<String>,
728    pub camera_ids: Option<Value>,
729    pub incident_lock_only: Option<bool>,
730    pub schedule_interval_s: Option<i64>,
731    pub lookback_hours: Option<i64>,
732    pub enabled: Option<bool>,
733}
734
735/// A single backup run (policy-scheduled, manually triggered, or an on-demand archive export).
736#[derive(Debug, Clone, Serialize, FromRow)]
737pub struct BackupJob {
738    pub id: String,
739    pub policy_id: Option<String>,
740    pub destination_id: Option<String>,
741    /// `policy` | `on_demand_archive`.
742    pub kind: String,
743    pub camera_ids: Json<Value>,
744    pub from_time: Option<DateTime<Utc>>,
745    pub to_time: Option<DateTime<Utc>>,
746    pub incident_lock_only: bool,
747    /// `pending` | `running` | `completed` | `error`.
748    pub status: String,
749    pub files_total: i64,
750    pub files_copied: i64,
751    pub bytes_copied: i64,
752    pub error: Option<String>,
753    /// Filesystem path of the produced artifact (archive .zip), if any.
754    pub output_path: Option<String>,
755    /// Browser-fetchable URL of the produced artifact (under /media/archives/...), if any.
756    pub output_url: Option<String>,
757    pub started_at: Option<DateTime<Utc>>,
758    pub finished_at: Option<DateTime<Utc>>,
759    pub created_at: DateTime<Utc>,
760}
761
762// ---- ONVIF (Profile S MVP): device profile + PTZ presets ----
763
764/// Per-camera ONVIF device profile, populated by [`crate::services::onvif::probe`]. `scopes` is a
765/// JSON array of ONVIF scope URIs. `ptz_enabled` is true when the device exposes a PTZ service and
766/// the chosen media profile carries a PTZConfiguration.
767#[derive(Debug, Clone, Serialize, FromRow)]
768pub struct CameraOnvif {
769    pub camera_id: String,
770    pub device_url: String,
771    pub manufacturer: Option<String>,
772    pub model: Option<String>,
773    pub firmware_version: Option<String>,
774    pub serial_number: Option<String>,
775    pub hardware_id: Option<String>,
776    pub scopes: Json<Value>,
777    pub media_url: Option<String>,
778    pub ptz_url: Option<String>,
779    pub profile_token: Option<String>,
780    pub ptz_node_token: Option<String>,
781    pub ptz_enabled: bool,
782    pub probed_at: DateTime<Utc>,
783}
784
785/// A PTZ preset fetched from a camera's ONVIF PTZ service (GetPresets). One row per (camera, token).
786#[derive(Debug, Clone, Serialize, FromRow)]
787pub struct PtzPreset {
788    pub id: String,
789    pub camera_id: String,
790    pub token: String,
791    pub name: Option<String>,
792    pub fetched_at: DateTime<Utc>,
793}
794
795/// Request body for POST /api/v1/archive/export — zip a selection of recorded footage on demand.
796#[derive(Debug, Deserialize)]
797pub struct ArchiveExportRequest {
798    /// Camera ids to include; empty/omitted means all cameras.
799    #[serde(default)]
800    pub camera_ids: Vec<String>,
801    pub from: Option<String>,
802    pub to: Option<String>,
803    pub incident_lock_only: Option<bool>,
804    /// Trim each segment to the [from, to] window (re-mux with -c copy); requires both bounds.
805    pub trim: Option<bool>,
806}