Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions db/migrations/0061_add_capture_camera_identity.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- Persist immutable camera identity on historical media and detection rows.

-- migrate:up

ALTER TABLE recordings ADD COLUMN camera_uuid TEXT;
ALTER TABLE detections ADD COLUMN camera_uuid TEXT;

-- Existing rows are safe to backfill only when their legacy stream name still
-- maps to one current camera. streams.name is unique, so unmatched rows remain
-- NULL and retain stream_name as an explicit legacy identity.
UPDATE recordings
SET camera_uuid = (
SELECT streams.camera_uuid
FROM streams
WHERE streams.name = recordings.stream_name
)
WHERE camera_uuid IS NULL
AND EXISTS (
SELECT 1 FROM streams WHERE streams.name = recordings.stream_name
);

UPDATE detections
SET camera_uuid = COALESCE(
(
SELECT recordings.camera_uuid
FROM recordings
WHERE recordings.id = detections.recording_id
),
(
SELECT streams.camera_uuid
FROM streams
WHERE streams.name = detections.stream_name
)
)
WHERE camera_uuid IS NULL;

CREATE INDEX idx_recordings_camera_time
ON recordings(camera_uuid, start_time, end_time)
WHERE camera_uuid IS NOT NULL;

CREATE INDEX idx_detections_camera_time_id
ON detections(camera_uuid, timestamp, id)
WHERE camera_uuid IS NOT NULL;

-- migrate:down

DROP INDEX IF EXISTS idx_detections_camera_time_id;
DROP INDEX IF EXISTS idx_recordings_camera_time;
SELECT 1;
6 changes: 6 additions & 0 deletions include/database/db_detections.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
int store_detections_in_db(const char *stream_name, const detection_result_t *result,
time_t timestamp, uint64_t recording_id);

/** Store detections with an immutable capture-time camera identity. */
int store_detections_in_db_for_camera(
const char *camera_uuid, const char *stream_name,
const detection_result_t *result, time_t timestamp,
uint64_t recording_id);

/** Store detections as one open external-motion interval. */
int store_external_motion_detections(const char *stream_name,
const detection_result_t *result,
Expand Down
35 changes: 34 additions & 1 deletion include/database/db_embedded_migrations.h
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,32 @@ static const char migration_0060_down[] =
"DROP TABLE IF EXISTS event_destinations;\n"
"SELECT 1;";

static const char migration_0061_up[] =
"ALTER TABLE recordings ADD COLUMN camera_uuid TEXT;\n"
"ALTER TABLE detections ADD COLUMN camera_uuid TEXT;\n"
"UPDATE recordings SET camera_uuid = ("
"SELECT streams.camera_uuid FROM streams "
"WHERE streams.name = recordings.stream_name) "
"WHERE camera_uuid IS NULL AND EXISTS ("
"SELECT 1 FROM streams WHERE streams.name = recordings.stream_name);\n"
"UPDATE detections SET camera_uuid = COALESCE(("
"SELECT recordings.camera_uuid FROM recordings "
"WHERE recordings.id = detections.recording_id), ("
"SELECT streams.camera_uuid FROM streams "
"WHERE streams.name = detections.stream_name)) "
"WHERE camera_uuid IS NULL;\n"
"CREATE INDEX idx_recordings_camera_time "
"ON recordings(camera_uuid, start_time, end_time) "
"WHERE camera_uuid IS NOT NULL;\n"
"CREATE INDEX idx_detections_camera_time_id "
"ON detections(camera_uuid, timestamp, id) "
"WHERE camera_uuid IS NOT NULL;";

static const char migration_0061_down[] =
"DROP INDEX IF EXISTS idx_detections_camera_time_id;\n"
"DROP INDEX IF EXISTS idx_recordings_camera_time;\n"
"SELECT 1;";

static const migration_t embedded_migrations_data[] = {
{
.version = "0001",
Expand Down Expand Up @@ -1650,8 +1676,15 @@ static const migration_t embedded_migrations_data[] = {
.sql_down = migration_0060_down,
.is_embedded = true
},
{
.version = "0061",
.description = "add_capture_camera_identity",
.sql_up = migration_0061_up,
.sql_down = migration_0061_down,
.is_embedded = true
},
};

#define EMBEDDED_MIGRATIONS_COUNT 60
#define EMBEDDED_MIGRATIONS_COUNT 61

#endif /* DB_EMBEDDED_MIGRATIONS_H */
1 change: 1 addition & 0 deletions include/database/db_recordings.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
typedef struct {
uint64_t id;
char stream_name[64];
char camera_uuid[CAMERA_UUID_STRING_SIZE];
char file_path[MAX_PATH_LENGTH];
time_t start_time;
time_t end_time;
Expand Down
1 change: 1 addition & 0 deletions include/video/mp4_writer.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ typedef struct {
struct mp4_writer {
char output_path[MAX_PATH_LENGTH];
char stream_name[MAX_STREAM_NAME];
char camera_uuid[CAMERA_UUID_STRING_SIZE];
AVFormatContext *output_ctx;
int video_stream_idx;
int has_audio; // Flag indicating if audio is enabled
Expand Down
13 changes: 13 additions & 0 deletions include/web/api_handlers_investigations.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#ifndef API_HANDLERS_INVESTIGATIONS_H
#define API_HANDLERS_INVESTIGATIONS_H

#include "web/request_response.h"

#define INVESTIGATION_MAX_CAMERAS 16
#define INVESTIGATION_MAX_SEGMENTS_PER_CAMERA 2048

/** POST /api/investigations/timeline */
void handle_post_investigation_timeline(const http_request_t *request,
http_response_t *response);

#endif /* API_HANDLERS_INVESTIGATIONS_H */
11 changes: 11 additions & 0 deletions include/web/api_handlers_timeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
typedef struct {
uint64_t id;
char stream_name[64];
char camera_uuid[CAMERA_UUID_STRING_SIZE];
char file_path[MAX_PATH_LENGTH];
time_t start_time;
time_t end_time;
uint64_t size_bytes;
bool has_detection;
char trigger_type[16];
int schedule_restricted; // -1 = unknown/legacy, 0 = no, 1 = yes
} timeline_segment_t;

Expand All @@ -32,6 +34,15 @@ typedef struct {
int get_timeline_segments(const char *stream_name, time_t start_time, time_t end_time,
timeline_segment_t *segments, int max_segments);

/**
* Get timeline segments by immutable capture-time camera UUID. This continues
* to find recordings made before a camera was renamed and intentionally omits
* unresolved legacy rows whose camera_uuid is NULL.
*/
int get_timeline_segments_by_camera_uuid(
const char *camera_uuid, time_t start_time, time_t end_time,
timeline_segment_t *segments, int max_segments);

/**
* Handle GET request for timeline segments
* Endpoint: /api/timeline/segments
Expand Down
11 changes: 11 additions & 0 deletions include/web/httpd_utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ int httpd_authorize_stream_action_with_context(
authorization_action_t action, const char *stream_name, user_t *user,
fleet_camera_t *camera, authorization_evaluation_t *evaluation);

/**
* Authorize historical media using its immutable capture-time camera UUID.
* legacy_stream_name is consulted only for pre-migration rows whose UUID is
* unresolved. This prevents a camera rename from orphaning authorized media.
*/
int httpd_authorize_camera_identity_action_with_context(
const http_request_t *req, http_response_t *res,
authorization_action_t action, const char *camera_uuid,
const char *legacy_stream_name, user_t *user, fleet_camera_t *camera,
authorization_evaluation_t *evaluation);

/**
* Evaluate an already-authenticated user against a server-resolved stream.
* Returns 0 with an allow/deny evaluation, 1 if the stream does not exist, and
Expand Down
34 changes: 26 additions & 8 deletions src/database/db_detections.c
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
* @param recording_id Recording ID to link detections to (0 for no link)
* @return 0 on success, non-zero on failure
*/
static int store_detections_with_source(const char *stream_name,
static int store_detections_with_source(const char *camera_uuid,
const char *stream_name,
const detection_result_t *result,
time_t timestamp,
uint64_t recording_id,
Expand Down Expand Up @@ -83,8 +84,11 @@ static int store_detections_with_source(const char *stream_name,
return -1;
}

const char *sql = "INSERT INTO detections (stream_name, timestamp, label, confidence, x, y, width, height, track_id, zone_id, recording_id, source, event_end_time) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);";
const char *sql = "INSERT INTO detections (stream_name, timestamp, label, confidence, x, y, width, height, track_id, zone_id, recording_id, source, event_end_time, camera_uuid) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "
"COALESCE((SELECT camera_uuid FROM recordings WHERE id = NULLIF(?, 0)), "
"NULLIF(?, ''), "
"(SELECT camera_uuid FROM streams WHERE name = ?)));";

rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
Expand Down Expand Up @@ -120,6 +124,10 @@ static int store_detections_with_source(const char *stream_name,
} else {
sqlite3_bind_int64(stmt, 13, (sqlite3_int64)timestamp);
}
sqlite3_bind_int64(stmt, 14, (sqlite3_int64)recording_id);
sqlite3_bind_text(stmt, 15, camera_uuid ? camera_uuid : "", -1,
SQLITE_STATIC);
sqlite3_bind_text(stmt, 16, stream_name, -1, SQLITE_STATIC);

// Execute statement
rc = sqlite3_step(stmt);
Expand Down Expand Up @@ -175,15 +183,23 @@ int store_detections_in_db(const char *stream_name,
const detection_result_t *result,
time_t timestamp,
uint64_t recording_id) {
return store_detections_with_source(stream_name, result, timestamp,
return store_detections_with_source(NULL, stream_name, result, timestamp,
recording_id, "", false);
}

int store_detections_in_db_for_camera(
const char *camera_uuid, const char *stream_name,
const detection_result_t *result, time_t timestamp,
uint64_t recording_id) {
return store_detections_with_source(camera_uuid, stream_name, result,
timestamp, recording_id, "", false);
}

int store_external_motion_detections(const char *stream_name,
const detection_result_t *result,
time_t timestamp,
uint64_t recording_id) {
return store_detections_with_source(stream_name, result, timestamp,
return store_detections_with_source(NULL, stream_name, result, timestamp,
recording_id, "external_motion", true);
}

Expand Down Expand Up @@ -1116,7 +1132,8 @@ int update_detections_recording_id(const char *stream_name, uint64_t recording_i
// Update detections where recording_id is NULL or 0 for the given stream and time range
const char *sql =
"UPDATE detections "
"SET recording_id = ? "
"SET recording_id = ?, "
"camera_uuid = COALESCE((SELECT camera_uuid FROM recordings WHERE id = ?), camera_uuid) "
"WHERE stream_name = ? AND timestamp >= ? AND (recording_id IS NULL OR recording_id = 0);";

rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
Expand All @@ -1128,8 +1145,9 @@ int update_detections_recording_id(const char *stream_name, uint64_t recording_i

// Bind parameters
sqlite3_bind_int64(stmt, 1, (sqlite3_int64)recording_id);
sqlite3_bind_text(stmt, 2, stream_name, -1, SQLITE_STATIC);
sqlite3_bind_int64(stmt, 3, (sqlite3_int64)since_time);
sqlite3_bind_int64(stmt, 2, (sqlite3_int64)recording_id);
sqlite3_bind_text(stmt, 3, stream_name, -1, SQLITE_STATIC);
sqlite3_bind_int64(stmt, 4, (sqlite3_int64)since_time);

rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
Expand Down
30 changes: 24 additions & 6 deletions src/database/db_recordings.c
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ uint64_t add_recording_metadata(const recording_metadata_t *metadata) {

const char *sql = "INSERT INTO recordings (stream_name, file_path, start_time, end_time, "
"size_bytes, width, height, fps, codec, is_complete, trigger_type, "
"retention_tier, disk_pressure_eligible, schedule_restricted) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);";
"retention_tier, disk_pressure_eligible, schedule_restricted, camera_uuid) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "
"COALESCE(NULLIF(?, ''), "
"(SELECT camera_uuid FROM streams WHERE name = ?)));";

rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
Expand Down Expand Up @@ -134,6 +136,8 @@ uint64_t add_recording_metadata(const recording_metadata_t *metadata) {
} else {
sqlite3_bind_int(stmt, 14, metadata->schedule_restricted ? 1 : 0);
}
sqlite3_bind_text(stmt, 15, metadata->camera_uuid, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 16, metadata->stream_name, -1, SQLITE_STATIC);

// Execute statement
rc = sqlite3_step(stmt);
Expand Down Expand Up @@ -276,7 +280,7 @@ int get_recording_metadata_by_id(uint64_t id, recording_metadata_t *metadata) {
const char *sql = "SELECT id, stream_name, file_path, start_time, end_time, "
"size_bytes, width, height, fps, codec, is_complete, trigger_type, "
"protected, retention_override_days, retention_tier, disk_pressure_eligible, "
"schedule_restricted "
"schedule_restricted, camera_uuid "
"FROM recordings WHERE id = ?;";

rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
Expand Down Expand Up @@ -352,6 +356,9 @@ int get_recording_metadata_by_id(uint64_t id, recording_metadata_t *metadata) {
? (sqlite3_column_int(stmt, 15) != 0) : true;
metadata->schedule_restricted = (sqlite3_column_type(stmt, 16) != SQLITE_NULL)
? (sqlite3_column_int(stmt, 16) != 0) : -1;
const char *camera_uuid = (const char *)sqlite3_column_text(stmt, 17);
safe_strcpy(metadata->camera_uuid, camera_uuid ? camera_uuid : "",
sizeof(metadata->camera_uuid), 0);

result = 0; // Success
}
Expand Down Expand Up @@ -387,7 +394,7 @@ int get_recording_metadata_by_path(const char *file_path, recording_metadata_t *
const char *sql = "SELECT id, stream_name, file_path, start_time, end_time, "
"size_bytes, width, height, fps, codec, is_complete, trigger_type, "
"protected, retention_override_days, retention_tier, disk_pressure_eligible, "
"schedule_restricted "
"schedule_restricted, camera_uuid "
"FROM recordings WHERE file_path = ?;";

rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
Expand Down Expand Up @@ -459,6 +466,9 @@ int get_recording_metadata_by_path(const char *file_path, recording_metadata_t *
? (sqlite3_column_int(stmt, 15) != 0) : true;
metadata->schedule_restricted = (sqlite3_column_type(stmt, 16) != SQLITE_NULL)
? (sqlite3_column_int(stmt, 16) != 0) : -1;
const char *camera_uuid = (const char *)sqlite3_column_text(stmt, 17);
safe_strcpy(metadata->camera_uuid, camera_uuid ? camera_uuid : "",
sizeof(metadata->camera_uuid), 0);

result = 0; // Success
}
Expand Down Expand Up @@ -497,7 +507,7 @@ int get_recording_metadata(time_t start_time, time_t end_time,
snprintf(sql, sizeof(sql), "SELECT id, stream_name, file_path, start_time, end_time, "
"size_bytes, width, height, fps, codec, is_complete, trigger_type, "
"protected, retention_override_days, retention_tier, disk_pressure_eligible, "
"schedule_restricted "
"schedule_restricted, camera_uuid "
"FROM recordings WHERE is_complete = 1 AND end_time IS NOT NULL"); // Only complete recordings with end_time set

if (start_time > 0) {
Expand Down Expand Up @@ -604,6 +614,10 @@ int get_recording_metadata(time_t start_time, time_t end_time,
? (sqlite3_column_int(stmt, 15) != 0) : true;
metadata[count].schedule_restricted = (sqlite3_column_type(stmt, 16) != SQLITE_NULL)
? (sqlite3_column_int(stmt, 16) != 0) : -1;
const char *camera_uuid = (const char *)sqlite3_column_text(stmt, 17);
safe_strcpy(metadata[count].camera_uuid,
camera_uuid ? camera_uuid : "",
sizeof(metadata[count].camera_uuid), 0);

count++;
}
Expand Down Expand Up @@ -917,7 +931,7 @@ int get_recording_metadata_paginated(time_t start_time, time_t end_time,
"SELECT r.id, r.stream_name, r.file_path, r.start_time, r.end_time, "
"r.size_bytes, r.width, r.height, r.fps, r.codec, r.is_complete, r.trigger_type, "
"r.protected, r.retention_override_days, r.retention_tier, r.disk_pressure_eligible, "
"r.schedule_restricted "
"r.schedule_restricted, r.camera_uuid "
"FROM recordings r WHERE r.is_complete = 1 AND r.end_time IS NOT NULL");

if (has_detection == 1) {
Expand Down Expand Up @@ -1173,6 +1187,10 @@ int get_recording_metadata_paginated(time_t start_time, time_t end_time,
? (sqlite3_column_int(stmt, 15) != 0) : true;
metadata[count].schedule_restricted = (sqlite3_column_type(stmt, 16) != SQLITE_NULL)
? (sqlite3_column_int(stmt, 16) != 0) : -1;
const char *camera_uuid = (const char *)sqlite3_column_text(stmt, 17);
safe_strcpy(metadata[count].camera_uuid,
camera_uuid ? camera_uuid : "",
sizeof(metadata[count].camera_uuid), 0);

count++;
}
Expand Down
2 changes: 2 additions & 0 deletions src/video/mp4_recording_core.c
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ static void *mp4_recording_thread(void *arg) {
ctx->running = 0;
return NULL;
}
safe_strcpy(ctx->mp4_writer->camera_uuid, ctx->config.camera_uuid,
sizeof(ctx->mp4_writer->camera_uuid), 0);

// Configure audio recording based on stream config BEFORE anything else uses the writer
mp4_writer_set_audio(ctx->mp4_writer, ctx->config.record_audio ? 1 : 0);
Expand Down
2 changes: 2 additions & 0 deletions src/video/mp4_writer_thread.c
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ static void on_segment_started_cb(void *user_ctx) {
recording_metadata_t metadata;
memset(&metadata, 0, sizeof(recording_metadata_t));
safe_strcpy(metadata.stream_name, stream_name, sizeof(metadata.stream_name), 0);
safe_strcpy(metadata.camera_uuid, thread_ctx->writer->camera_uuid,
sizeof(metadata.camera_uuid), 0);
safe_strcpy(metadata.file_path, thread_ctx->writer->output_path, sizeof(metadata.file_path), 0);
metadata.start_time = time(NULL); // Align to keyframe time
metadata.end_time = 0;
Expand Down
Loading
Loading