diff --git a/db/migrations/0061_add_capture_camera_identity.sql b/db/migrations/0061_add_capture_camera_identity.sql new file mode 100644 index 000000000..24bbe29e7 --- /dev/null +++ b/db/migrations/0061_add_capture_camera_identity.sql @@ -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; diff --git a/include/database/db_detections.h b/include/database/db_detections.h index 64eea055f..b2c47eaee 100644 --- a/include/database/db_detections.h +++ b/include/database/db_detections.h @@ -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, diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index 0be0e592d..a701e72e7 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -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", @@ -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 */ diff --git a/include/database/db_recordings.h b/include/database/db_recordings.h index 077123dd4..e67ba4570 100644 --- a/include/database/db_recordings.h +++ b/include/database/db_recordings.h @@ -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; diff --git a/include/video/mp4_writer.h b/include/video/mp4_writer.h index 877a9fef5..b33f8036b 100644 --- a/include/video/mp4_writer.h +++ b/include/video/mp4_writer.h @@ -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 diff --git a/include/web/api_handlers_investigations.h b/include/web/api_handlers_investigations.h new file mode 100644 index 000000000..8d00df9f9 --- /dev/null +++ b/include/web/api_handlers_investigations.h @@ -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 */ diff --git a/include/web/api_handlers_timeline.h b/include/web/api_handlers_timeline.h index 7c0ffcd6c..1c71b4780 100644 --- a/include/web/api_handlers_timeline.h +++ b/include/web/api_handlers_timeline.h @@ -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; @@ -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 diff --git a/include/web/httpd_utils.h b/include/web/httpd_utils.h index 944414fd7..2cba24bef 100644 --- a/include/web/httpd_utils.h +++ b/include/web/httpd_utils.h @@ -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 diff --git a/src/database/db_detections.c b/src/database/db_detections.c index b39fda2ce..3a0eff619 100644 --- a/src/database/db_detections.c +++ b/src/database/db_detections.c @@ -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, @@ -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) { @@ -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); @@ -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); } @@ -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); @@ -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) { diff --git a/src/database/db_recordings.c b/src/database/db_recordings.c index 26af7b34b..6d7a53b4c 100644 --- a/src/database/db_recordings.c +++ b/src/database/db_recordings.c @@ -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) { @@ -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); @@ -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); @@ -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 } @@ -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); @@ -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 } @@ -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) { @@ -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++; } @@ -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) { @@ -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++; } diff --git a/src/video/mp4_recording_core.c b/src/video/mp4_recording_core.c index 5a6c0b96d..b15128288 100644 --- a/src/video/mp4_recording_core.c +++ b/src/video/mp4_recording_core.c @@ -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); diff --git a/src/video/mp4_writer_thread.c b/src/video/mp4_writer_thread.c index 7d9d6b015..bdc565bd4 100644 --- a/src/video/mp4_writer_thread.c +++ b/src/video/mp4_writer_thread.c @@ -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; diff --git a/src/video/recording.c b/src/video/recording.c index 6f9f1ef35..e70e246c6 100644 --- a/src/video/recording.c +++ b/src/video/recording.c @@ -111,6 +111,8 @@ uint64_t start_recording(const char *stream_name, const char *output_path) { metadata.disk_pressure_eligible = true; safe_strcpy(metadata.stream_name, stream_name, sizeof(metadata.stream_name), 0); + safe_strcpy(metadata.camera_uuid, config.camera_uuid, + sizeof(metadata.camera_uuid), 0); // Format paths for the recording - MAKE SURE THIS POINTS TO REAL FILES char mp4_path[MAX_PATH_LENGTH]; diff --git a/src/video/unified_detection_thread.c b/src/video/unified_detection_thread.c index 984bb9e06..3903784b5 100644 --- a/src/video/unified_detection_thread.c +++ b/src/video/unified_detection_thread.c @@ -2358,6 +2358,8 @@ static int udt_start_recording(unified_detection_ctx_t *ctx) { log_error("[%s] Failed to create MP4 writer", ctx->stream_name); return -1; } + safe_strcpy(ctx->mp4_writer->camera_uuid, ctx->camera_uuid, + sizeof(ctx->mp4_writer->camera_uuid), 0); ctx->mp4_writer->pre_buffer_seconds = ctx->pre_buffer_seconds; // Configure audio recording based on stream settings @@ -2441,6 +2443,8 @@ static int udt_start_recording(unified_detection_ctx_t *ctx) { recording_metadata_t metadata = {0}; safe_strcpy(metadata.file_path, ctx->current_recording_path, sizeof(metadata.file_path), 0); safe_strcpy(metadata.stream_name, ctx->stream_name, sizeof(metadata.stream_name), 0); + safe_strcpy(metadata.camera_uuid, ctx->camera_uuid, + sizeof(metadata.camera_uuid), 0); metadata.start_time = now; metadata.end_time = 0; // Will be set when recording stops metadata.size_bytes = 0; // Will be set when recording stops @@ -2523,6 +2527,8 @@ static int udt_stop_recording(unified_detection_ctx_t *ctx) { recording_metadata_t metadata = {0}; safe_strcpy(metadata.file_path, ctx->current_recording_path, sizeof(metadata.file_path), 0); safe_strcpy(metadata.stream_name, ctx->stream_name, sizeof(metadata.stream_name), 0); + safe_strcpy(metadata.camera_uuid, ctx->camera_uuid, + sizeof(metadata.camera_uuid), 0); metadata.start_time = start_time; metadata.end_time = end_time; metadata.size_bytes = file_size; @@ -2868,7 +2874,8 @@ static void report_detections(unified_detection_ctx_t *ctx, if (!is_api_detection(ctx->model_path)) { uint64_t rec_id = detection_link_recording_id(ctx); - if (store_detections_in_db(ctx->stream_name, result, now, rec_id) != 0) + if (store_detections_in_db_for_camera( + ctx->camera_uuid, ctx->stream_name, result, now, rec_id) != 0) log_warn("[%s] Failed to store detections in database", ctx->stream_name); // API backends enqueue from detect_objects_api / *_snapshot. Local diff --git a/src/web/api_handlers_investigations.c b/src/web/api_handlers_investigations.c new file mode 100644 index 000000000..0240726f9 --- /dev/null +++ b/src/web/api_handlers_investigations.c @@ -0,0 +1,260 @@ +/** + * @file api_handlers_investigations.c + * @brief Multi-camera investigation timeline API. + */ + +#include +#include +#include +#include + +#include + +#include "web/api_handlers_investigations.h" +#include "web/api_handlers_timeline.h" +#include "web/audit_log.h" +#include "web/httpd_utils.h" +#include "core/authorization.h" +#include "core/config.h" +#define LOG_COMPONENT "InvestigationsAPI" +#include "core/logger.h" +#include "database/db_fleet_query.h" +#include "database/db_streams.h" + +#define INVESTIGATION_MAX_RANGE_SECONDS (31 * 24 * 60 * 60) +#define INVESTIGATION_ACTIVE_DECODER_LIMIT 4 + +static bool parse_epoch_seconds(const cJSON *body, const char *name, + time_t *value) { + const cJSON *item = cJSON_GetObjectItemCaseSensitive(body, name); + if (!cJSON_IsNumber(item) || !isfinite(item->valuedouble) || + item->valuedouble < 1 || floor(item->valuedouble) != item->valuedouble) { + return false; + } + *value = (time_t)item->valuedouble; + return true; +} + +static const char *segment_capture_method(const timeline_segment_t *segment) { + if (!segment || segment->trigger_type[0] == '\0') return "scheduled"; + if (strcmp(segment->trigger_type, "scheduled") == 0 && + segment->schedule_restricted == 0) { + return "continuous"; + } + return segment->trigger_type; +} + +static cJSON *segment_json(const timeline_segment_t *segment) { + cJSON *item = cJSON_CreateObject(); + if (!item) return NULL; + cJSON_AddNumberToObject(item, "id", (double)segment->id); + cJSON_AddNumberToObject(item, "start_time", + (double)segment->start_time); + cJSON_AddNumberToObject(item, "end_time", (double)segment->end_time); + cJSON_AddNumberToObject(item, "duration", + (double)(segment->end_time - segment->start_time)); + cJSON_AddStringToObject(item, "capture_method", + segment_capture_method(segment)); + cJSON_AddBoolToObject(item, "has_detection", segment->has_detection); + cJSON_AddBoolToObject(item, "media_available", true); + return item; +} + +static void set_json_response(http_response_t *response, cJSON *root) { + char *encoded = cJSON_PrintUnformatted(root); + if (!encoded) { + http_response_set_json_error(response, 500, + "Failed to encode investigation timeline"); + return; + } + http_response_set_json(response, 200, encoded); + free(encoded); +} + +void handle_post_investigation_timeline(const http_request_t *request, + http_response_t *response) { + if (!request || !response) return; + + cJSON *body = httpd_parse_json_body(request); + if (!body) { + http_response_set_json_error(response, 400, "Invalid JSON body"); + return; + } + + const cJSON *camera_uuids = + cJSON_GetObjectItemCaseSensitive(body, "camera_uuids"); + int camera_count = cJSON_IsArray(camera_uuids) + ? cJSON_GetArraySize(camera_uuids) : 0; + if (camera_count < 1 || camera_count > INVESTIGATION_MAX_CAMERAS) { + cJSON_Delete(body); + http_response_set_json_error( + response, 400, "camera_uuids must contain between 1 and 16 cameras"); + return; + } + + time_t start_time = 0; + time_t end_time = 0; + if (!parse_epoch_seconds(body, "start_time", &start_time) || + !parse_epoch_seconds(body, "end_time", &end_time) || + end_time <= start_time || + end_time - start_time > INVESTIGATION_MAX_RANGE_SECONDS) { + cJSON_Delete(body); + http_response_set_json_error( + response, 400, + "start_time and end_time must define a range of at most 31 days"); + return; + } + + user_t user; + if (!httpd_check_action_access(request, &user)) { + cJSON_Delete(body); + http_response_set_json_error(response, 401, "Unauthorized"); + return; + } + + stream_config_t cameras[INVESTIGATION_MAX_CAMERAS]; + fleet_camera_t fleet_cameras[INVESTIGATION_MAX_CAMERAS]; + memset(cameras, 0, sizeof(cameras)); + memset(fleet_cameras, 0, sizeof(fleet_cameras)); + + authorization_context_t *auth_context = authorization_context_create(); + if (!auth_context) { + cJSON_Delete(body); + http_response_set_json_error(response, 500, + "Authorization context unavailable"); + return; + } + + /* Resolve and authorize the complete fixed camera list before returning any + * timeline data. Explicit unauthorized UUIDs fail as one scoped request. */ + for (int i = 0; i < camera_count; i++) { + const cJSON *uuid = cJSON_GetArrayItem(camera_uuids, i); + if (!cJSON_IsString(uuid) || !uuid->valuestring || + strlen(uuid->valuestring) != CAMERA_UUID_STRING_SIZE - 1) { + authorization_context_free(auth_context); + cJSON_Delete(body); + http_response_set_json_error(response, 400, + "camera_uuids contains an invalid UUID"); + return; + } + for (int previous = 0; previous < i; previous++) { + if (strcmp(cameras[previous].camera_uuid, uuid->valuestring) == 0) { + authorization_context_free(auth_context); + cJSON_Delete(body); + http_response_set_json_error(response, 400, + "camera_uuids contains duplicates"); + return; + } + } + if (get_stream_config_by_uuid(uuid->valuestring, &cameras[i]) != 0 || + db_fleet_camera_find_by_name(cameras[i].name, + &fleet_cameras[i]) != 0) { + authorization_context_free(auth_context); + cJSON_Delete(body); + http_response_set_json_error(response, 404, "Camera not found"); + return; + } + authorization_evaluation_t evaluation; + memset(&evaluation, 0, sizeof(evaluation)); + if (authorization_evaluate_in_context( + auth_context, &user, AUTHZ_RECORDINGS_REPLAY, + &fleet_cameras[i], &evaluation) != 0) { + audit_log_authorization(request, &user, AUTHZ_RECORDINGS_REPLAY, + &fleet_cameras[i], NULL, "error"); + authorization_context_free(auth_context); + cJSON_Delete(body); + http_response_set_json_error( + response, 500, "Authorization policy evaluation failed"); + return; + } + if (evaluation.decision != AUTHZ_DECISION_ALLOW) { + audit_log_authorization(request, &user, AUTHZ_RECORDINGS_REPLAY, + &fleet_cameras[i], &evaluation, "denied"); + authorization_context_free(auth_context); + cJSON_Delete(body); + http_response_set_json_error(response, 403, "Forbidden"); + return; + } + } + authorization_context_free(auth_context); + + cJSON *root = cJSON_CreateObject(); + cJSON *tracks = cJSON_CreateArray(); + if (!root || !tracks) { + cJSON_Delete(root); + cJSON_Delete(tracks); + cJSON_Delete(body); + http_response_set_json_error(response, 500, + "Failed to create investigation timeline"); + return; + } + cJSON_AddNumberToObject(root, "start_time", (double)start_time); + cJSON_AddNumberToObject(root, "end_time", (double)end_time); + cJSON_AddNumberToObject(root, "camera_count", camera_count); + cJSON_AddNumberToObject(root, "max_active_decoders", + INVESTIGATION_ACTIVE_DECODER_LIMIT); + cJSON_AddItemToObject(root, "tracks", tracks); + + timeline_segment_t *segments = calloc( + INVESTIGATION_MAX_SEGMENTS_PER_CAMERA, sizeof(*segments)); + if (!segments) { + cJSON_Delete(root); + cJSON_Delete(body); + http_response_set_json_error(response, 500, + "Failed to allocate timeline buffer"); + return; + } + + for (int i = 0; i < camera_count; i++) { + int segment_count = get_timeline_segments_by_camera_uuid( + cameras[i].camera_uuid, start_time, end_time, segments, + INVESTIGATION_MAX_SEGMENTS_PER_CAMERA); + if (segment_count < 0) { + free(segments); + cJSON_Delete(root); + cJSON_Delete(body); + http_response_set_json_error(response, 500, + "Failed to query investigation timeline"); + return; + } + + cJSON *track = cJSON_CreateObject(); + cJSON *track_segments = cJSON_CreateArray(); + cJSON *coverage = cJSON_CreateObject(); + if (!track || !track_segments || !coverage) { + cJSON_Delete(track); + cJSON_Delete(track_segments); + cJSON_Delete(coverage); + free(segments); + cJSON_Delete(root); + cJSON_Delete(body); + http_response_set_json_error(response, 500, + "Failed to create timeline track"); + return; + } + cJSON_AddStringToObject(track, "camera_uuid", cameras[i].camera_uuid); + cJSON_AddStringToObject(track, "name", cameras[i].name); + cJSON_AddStringToObject(track, "stream_name", cameras[i].name); + cJSON_AddNumberToObject(track, "segment_count", segment_count); + cJSON_AddBoolToObject( + track, "truncated", + segment_count == INVESTIGATION_MAX_SEGMENTS_PER_CAMERA); + cJSON_AddBoolToObject(coverage, "identity_resolved", true); + cJSON_AddNumberToObject(coverage, "requested_start", + (double)start_time); + cJSON_AddNumberToObject(coverage, "requested_end", (double)end_time); + cJSON_AddItemToObject(track, "coverage", coverage); + cJSON_AddItemToObject(track, "segments", track_segments); + + for (int j = 0; j < segment_count; j++) { + cJSON *item = segment_json(&segments[j]); + if (item) cJSON_AddItemToArray(track_segments, item); + } + cJSON_AddItemToArray(tracks, track); + } + + free(segments); + set_json_response(response, root); + cJSON_Delete(root); + cJSON_Delete(body); +} diff --git a/src/web/api_handlers_recordings_backend_agnostic.c b/src/web/api_handlers_recordings_backend_agnostic.c index 957469d21..9dffd5659 100644 --- a/src/web/api_handlers_recordings_backend_agnostic.c +++ b/src/web/api_handlers_recordings_backend_agnostic.c @@ -90,6 +90,15 @@ void handle_get_recording(const http_request_t *req, http_response_t *res) { http_response_set_json_error(res, 404, "Recording not found"); return; } + + user_t user; + fleet_camera_t camera; + authorization_evaluation_t evaluation; + if (!httpd_authorize_camera_identity_action_with_context( + req, res, AUTHZ_RECORDINGS_REPLAY, recording.camera_uuid, + recording.stream_name, &user, &camera, &evaluation)) { + return; + } // Create JSON object cJSON *recording_obj = cJSON_CreateObject(); @@ -133,6 +142,12 @@ void handle_get_recording(const http_request_t *req, http_response_t *res) { // Add recording properties cJSON_AddNumberToObject(recording_obj, "id", (double)recording.id); cJSON_AddStringToObject(recording_obj, "stream", recording.stream_name); + if (recording.camera_uuid[0] != '\0') { + cJSON_AddStringToObject(recording_obj, "camera_uuid", + recording.camera_uuid); + } else { + cJSON_AddNullToObject(recording_obj, "camera_uuid"); + } cJSON_AddStringToObject(recording_obj, "file_path", recording.file_path); cJSON_AddStringToObject(recording_obj, "start_time", start_time_str); cJSON_AddStringToObject(recording_obj, "end_time", end_time_str); diff --git a/src/web/api_handlers_recordings_download.c b/src/web/api_handlers_recordings_download.c index 2920547d5..2da665412 100644 --- a/src/web/api_handlers_recordings_download.c +++ b/src/web/api_handlers_recordings_download.c @@ -88,9 +88,9 @@ void handle_recordings_download(const http_request_t *req, http_response_t *res) } fleet_camera_t camera; authorization_evaluation_t evaluation; - if (!httpd_authorize_stream_action_with_context( - req, res, AUTHZ_RECORDINGS_EXPORT, recording.stream_name, &user, - &camera, &evaluation)) { + if (!httpd_authorize_camera_identity_action_with_context( + req, res, AUTHZ_RECORDINGS_EXPORT, recording.camera_uuid, + recording.stream_name, &user, &camera, &evaluation)) { return; } diff --git a/src/web/api_handlers_recordings_list.c b/src/web/api_handlers_recordings_list.c index 647016fd0..35043f07e 100644 --- a/src/web/api_handlers_recordings_list.c +++ b/src/web/api_handlers_recordings_list.c @@ -529,6 +529,12 @@ void handle_get_recordings(const http_request_t *req, http_response_t *res) { cJSON_AddNumberToObject(recording, "id", (double)recordings[i].id); cJSON_AddStringToObject(recording, "stream", recordings[i].stream_name); + if (recordings[i].camera_uuid[0] != '\0') { + cJSON_AddStringToObject(recording, "camera_uuid", + recordings[i].camera_uuid); + } else { + cJSON_AddNullToObject(recording, "camera_uuid"); + } cJSON_AddStringToObject(recording, "file_path", recordings[i].file_path); cJSON_AddStringToObject(recording, "start_time", start_time_formatted); cJSON_AddStringToObject(recording, "end_time", end_time_formatted); diff --git a/src/web/api_handlers_recordings_playback.c b/src/web/api_handlers_recordings_playback.c index 68fa92a67..a04b9a62f 100644 --- a/src/web/api_handlers_recordings_playback.c +++ b/src/web/api_handlers_recordings_playback.c @@ -28,25 +28,6 @@ void handle_recordings_playback(const http_request_t *req, http_response_t *res) return; } - // Check authentication if enabled - // In demo mode, allow unauthenticated viewer access to play recordings - if (g_config.web_auth_enabled) { - user_t user; - if (g_config.demo_mode) { - if (!httpd_check_viewer_access(req, &user)) { - log_error("Authentication failed for GET /api/recordings/play request"); - http_response_set_json_error(res, 401, "Unauthorized"); - return; - } - } else { - if (!httpd_get_authenticated_user(req, &user)) { - log_error("Authentication failed for GET /api/recordings/play request"); - http_response_set_json_error(res, 401, "Unauthorized"); - return; - } - } - } - // Extract recording ID from URL char id_str[32]; if (http_request_extract_path_param(req, "/api/recordings/play/", id_str, sizeof(id_str)) != 0) { @@ -72,6 +53,14 @@ void handle_recordings_playback(const http_request_t *req, http_response_t *res) http_response_set_json_error(res, 404, "Recording not found"); return; } + user_t user; + fleet_camera_t camera; + authorization_evaluation_t evaluation; + if (!httpd_authorize_camera_identity_action_with_context( + req, res, AUTHZ_RECORDINGS_REPLAY, recording.camera_uuid, + recording.stream_name, &user, &camera, &evaluation)) { + return; + } // Validate file path if (recording.file_path[0] == '\0') { @@ -131,4 +120,3 @@ void handle_recordings_playback(const http_request_t *req, http_response_t *res) log_info("File serving initiated for GET /api/recordings/play/%llu", (unsigned long long)id); } - diff --git a/src/web/api_handlers_recordings_thumbnail.c b/src/web/api_handlers_recordings_thumbnail.c index 8623ef370..f22df5c59 100644 --- a/src/web/api_handlers_recordings_thumbnail.c +++ b/src/web/api_handlers_recordings_thumbnail.c @@ -83,22 +83,6 @@ void handle_recordings_thumbnail(const http_request_t *req, http_response_t *res return; } - // Check authentication if enabled - if (g_config.web_auth_enabled) { - user_t user; - if (g_config.demo_mode) { - if (!httpd_check_viewer_access(req, &user)) { - http_response_set_json_error(res, 401, "Unauthorized"); - return; - } - } else { - if (!httpd_get_authenticated_user(req, &user)) { - http_response_set_json_error(res, 401, "Unauthorized"); - return; - } - } - } - // Check if thumbnails are enabled if (!g_config.generate_thumbnails) { http_response_set_json_error(res, 403, "Thumbnail generation is disabled"); @@ -143,6 +127,20 @@ void handle_recordings_thumbnail(const http_request_t *req, http_response_t *res return; } + recording_metadata_t recording = {0}; + if (get_recording_metadata_by_id(id, &recording) != 0) { + http_response_set_json_error(res, 404, "Recording not found"); + return; + } + user_t user; + fleet_camera_t camera; + authorization_evaluation_t evaluation; + if (!httpd_authorize_camera_identity_action_with_context( + req, res, AUTHZ_RECORDINGS_REPLAY, recording.camera_uuid, + recording.stream_name, &user, &camera, &evaluation)) { + return; + } + // Build thumbnail path char thumb_path[MAX_PATH_LENGTH]; snprintf(thumb_path, sizeof(thumb_path), "%s/thumbnails/%llu_%d.jpg", @@ -161,13 +159,6 @@ void handle_recordings_thumbnail(const http_request_t *req, http_response_t *res } // Thumbnail doesn't exist - need to generate it - // Get recording metadata - recording_metadata_t recording = {0}; - if (get_recording_metadata_by_id(id, &recording) != 0) { - http_response_set_json_error(res, 404, "Recording not found"); - return; - } - // Check recording file exists if (stat(recording.file_path, &st) != 0) { http_response_set_json_error(res, 404, "Recording file not found"); @@ -251,4 +242,3 @@ void delete_recording_thumbnails(uint64_t recording_id) { // Silently ignore if thumbnail doesn't exist (ENOENT) } } - diff --git a/src/web/api_handlers_timeline.c b/src/web/api_handlers_timeline.c index 0af7f7f1a..f1a541277 100644 --- a/src/web/api_handlers_timeline.c +++ b/src/web/api_handlers_timeline.c @@ -45,12 +45,10 @@ // Mutex for manifest creation static pthread_mutex_t manifest_mutex = PTHREAD_MUTEX_INITIALIZER; -/** - * Get timeline segments for a specific stream and time range - */ -int get_timeline_segments(const char *stream_name, time_t start_time, time_t end_time, - timeline_segment_t *segments, int max_segments) { - if (!stream_name || !segments || max_segments <= 0) { +static int get_timeline_segments_for_identity( + const char *identity, bool by_camera_uuid, time_t start_time, + time_t end_time, timeline_segment_t *segments, int max_segments) { + if (!identity || identity[0] == '\0' || !segments || max_segments <= 0) { log_error("Invalid parameters for get_timeline_segments"); return -1; } @@ -77,23 +75,28 @@ int get_timeline_segments(const char *stream_name, time_t start_time, time_t end * * Also populate has_detection by checking trigger_type or the detections table. */ - const char *sql = - "SELECT r.id, r.stream_name, r.file_path, r.start_time, r.end_time, " + const char *stream_sql = + "SELECT r.id, r.stream_name, r.camera_uuid, r.file_path, " + "r.start_time, r.end_time, " "r.size_bytes, " "CASE WHEN r.trigger_type = 'detection' THEN 1 " " WHEN EXISTS (SELECT 1 FROM detections d WHERE d.recording_id = r.id) THEN 1 " " WHEN EXISTS (SELECT 1 FROM detections d " - " WHERE d.stream_name = r.stream_name " + " WHERE ((r.camera_uuid IS NOT NULL AND d.camera_uuid = r.camera_uuid) " + " OR (r.camera_uuid IS NULL AND d.camera_uuid IS NULL " + " AND d.stream_name = r.stream_name)) " " AND d.source != 'external_motion' " " AND d.timestamp >= r.start_time " " AND d.timestamp <= r.end_time) THEN 1 " " WHEN EXISTS (SELECT 1 FROM detections d " - " WHERE d.stream_name = r.stream_name " + " WHERE ((r.camera_uuid IS NOT NULL AND d.camera_uuid = r.camera_uuid) " + " OR (r.camera_uuid IS NULL AND d.camera_uuid IS NULL " + " AND d.stream_name = r.stream_name)) " " AND d.source = 'external_motion' " " AND d.timestamp <= r.end_time " " AND COALESCE(d.event_end_time, CAST(strftime('%s','now') AS INTEGER)) >= r.start_time) THEN 1 " " ELSE 0 END AS has_detection, " - "r.schedule_restricted " + "r.trigger_type, r.schedule_restricted " "FROM recordings r " "WHERE r.is_complete = 1 " " AND r.end_time IS NOT NULL " @@ -103,36 +106,75 @@ int get_timeline_segments(const char *stream_name, time_t start_time, time_t end "ORDER BY r.start_time ASC " "LIMIT ?;"; + const char *camera_sql = + "SELECT r.id, r.stream_name, r.camera_uuid, r.file_path, " + "r.start_time, r.end_time, " + "r.size_bytes, " + "CASE WHEN r.trigger_type = 'detection' THEN 1 " + " WHEN EXISTS (SELECT 1 FROM detections d WHERE d.recording_id = r.id) THEN 1 " + " WHEN EXISTS (SELECT 1 FROM detections d " + " WHERE d.camera_uuid = r.camera_uuid " + " AND d.source != 'external_motion' " + " AND d.timestamp >= r.start_time " + " AND d.timestamp <= r.end_time) THEN 1 " + " WHEN EXISTS (SELECT 1 FROM detections d " + " WHERE d.camera_uuid = r.camera_uuid " + " AND d.source = 'external_motion' " + " AND d.timestamp <= r.end_time " + " AND COALESCE(d.event_end_time, CAST(strftime('%s','now') AS INTEGER)) >= r.start_time) THEN 1 " + " ELSE 0 END AS has_detection, " + "r.trigger_type, r.schedule_restricted " + "FROM recordings r " + "WHERE r.is_complete = 1 " + " AND r.end_time IS NOT NULL " + " AND r.camera_uuid = ? " + " AND r.start_time <= ? " + " AND r.end_time >= ? " + "ORDER BY r.start_time ASC " + "LIMIT ?;"; + sqlite3_stmt *stmt = NULL; - int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + int rc = sqlite3_prepare_v2( + db, by_camera_uuid ? camera_sql : stream_sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { log_error("Failed to prepare timeline segments query: %s", sqlite3_errmsg(db)); pthread_mutex_unlock(db_mutex); return -1; } - sqlite3_bind_text(stmt, 1, stream_name, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 1, identity, -1, SQLITE_STATIC); sqlite3_bind_int64(stmt, 2, (sqlite3_int64)end_time); sqlite3_bind_int64(stmt, 3, (sqlite3_int64)start_time); sqlite3_bind_int(stmt, 4, max_segments); int count = 0; while (sqlite3_step(stmt) == SQLITE_ROW && count < max_segments) { + memset(&segments[count], 0, sizeof(segments[count])); segments[count].id = (uint64_t)sqlite3_column_int64(stmt, 0); const char *sname = (const char *)sqlite3_column_text(stmt, 1); if (sname) safe_strcpy(segments[count].stream_name, sname, sizeof(segments[count].stream_name), 0); - const char *fpath = (const char *)sqlite3_column_text(stmt, 2); + const char *camera_uuid = (const char *)sqlite3_column_text(stmt, 2); + if (camera_uuid) { + safe_strcpy(segments[count].camera_uuid, camera_uuid, + sizeof(segments[count].camera_uuid), 0); + } + + const char *fpath = (const char *)sqlite3_column_text(stmt, 3); if (fpath) safe_strcpy(segments[count].file_path, fpath, sizeof(segments[count].file_path), 0); - segments[count].start_time = (time_t)sqlite3_column_int64(stmt, 3); - segments[count].end_time = (time_t)sqlite3_column_int64(stmt, 4); - segments[count].size_bytes = (uint64_t)sqlite3_column_int64(stmt, 5); - segments[count].has_detection = sqlite3_column_int(stmt, 6) != 0; + segments[count].start_time = (time_t)sqlite3_column_int64(stmt, 4); + segments[count].end_time = (time_t)sqlite3_column_int64(stmt, 5); + segments[count].size_bytes = (uint64_t)sqlite3_column_int64(stmt, 6); + segments[count].has_detection = sqlite3_column_int(stmt, 7) != 0; + const char *trigger_type = (const char *)sqlite3_column_text(stmt, 8); + safe_strcpy(segments[count].trigger_type, + trigger_type ? trigger_type : "scheduled", + sizeof(segments[count].trigger_type), 0); segments[count].schedule_restricted = - (sqlite3_column_type(stmt, 7) != SQLITE_NULL) - ? (sqlite3_column_int(stmt, 7) != 0) : -1; + (sqlite3_column_type(stmt, 9) != SQLITE_NULL) + ? (sqlite3_column_int(stmt, 9) != 0) : -1; count++; } @@ -140,11 +182,39 @@ int get_timeline_segments(const char *stream_name, time_t start_time, time_t end sqlite3_finalize(stmt); pthread_mutex_unlock(db_mutex); - log_info("get_timeline_segments: found %d segments for stream '%s' in range [%ld, %ld]", - count, stream_name, (long)start_time, (long)end_time); + log_info("get_timeline_segments: found %d segments for %s '%s' in range [%ld, %ld]", + count, by_camera_uuid ? "camera" : "stream", identity, + (long)start_time, (long)end_time); return count; } +/** + * Get timeline segments for a specific stream and time range. + */ +int get_timeline_segments(const char *stream_name, time_t start_time, + time_t end_time, timeline_segment_t *segments, + int max_segments) { + return get_timeline_segments_for_identity( + stream_name, false, start_time, end_time, segments, max_segments); +} + +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) { + return get_timeline_segments_for_identity( + camera_uuid, true, start_time, end_time, segments, max_segments); +} + +static const char *timeline_segment_capture_method( + const timeline_segment_t *segment) { + if (!segment || segment->trigger_type[0] == '\0') return "scheduled"; + if (strcmp(segment->trigger_type, "scheduled") == 0 && + segment->schedule_restricted == 0) { + return "continuous"; + } + return segment->trigger_type; +} + /** * @brief Helper function to parse ISO 8601 time string to time_t */ @@ -373,10 +443,18 @@ void handle_get_timeline_segments(const http_request_t *req, http_response_t *re cJSON_AddNumberToObject(segment, "id", (double)segments[i].id); cJSON_AddStringToObject(segment, "stream", segments[i].stream_name); + if (segments[i].camera_uuid[0] != '\0') { + cJSON_AddStringToObject(segment, "camera_uuid", + segments[i].camera_uuid); + } else { + cJSON_AddNullToObject(segment, "camera_uuid"); + } cJSON_AddStringToObject(segment, "start_time", segment_start_time); cJSON_AddStringToObject(segment, "end_time", segment_end_time); cJSON_AddNumberToObject(segment, "duration", duration); cJSON_AddStringToObject(segment, "size", size_str); + cJSON_AddStringToObject(segment, "capture_method", + timeline_segment_capture_method(&segments[i])); cJSON_AddBoolToObject(segment, "has_detection", segments[i].has_detection); if (segments[i].schedule_restricted < 0) { cJSON_AddNullToObject(segment, "schedule_restricted"); @@ -815,10 +893,17 @@ void handle_get_timeline_segments_by_ids(const http_request_t *req, http_respons cJSON_AddNumberToObject(segment, "id", (double)rec.id); cJSON_AddStringToObject(segment, "stream", rec.stream_name); + if (rec.camera_uuid[0] != '\0') { + cJSON_AddStringToObject(segment, "camera_uuid", rec.camera_uuid); + } else { + cJSON_AddNullToObject(segment, "camera_uuid"); + } cJSON_AddStringToObject(segment, "start_time", seg_start); cJSON_AddStringToObject(segment, "end_time", seg_end); cJSON_AddNumberToObject(segment, "duration", duration); cJSON_AddStringToObject(segment, "size", size_str); + cJSON_AddStringToObject(segment, "capture_method", + recording_capture_method(&rec)); cJSON_AddBoolToObject(segment, "has_detection", has_det); if (rec.schedule_restricted < 0) { cJSON_AddNullToObject(segment, "schedule_restricted"); diff --git a/src/web/httpd_utils.c b/src/web/httpd_utils.c index 598b7d736..9ac9099f2 100644 --- a/src/web/httpd_utils.c +++ b/src/web/httpd_utils.c @@ -18,6 +18,7 @@ #include "database/db_auth.h" #include "database/db_api_tokens.h" #include "database/db_fleet_query.h" +#include "database/db_streams.h" #include "web/audit_log.h" cJSON* httpd_parse_json_body(const http_request_t *req) { @@ -633,6 +634,25 @@ int httpd_authorize_stream_action_with_context( return 1; } +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) { + if (camera_uuid && camera_uuid[0] != '\0') { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + if (get_stream_config_by_uuid(camera_uuid, &stream) != 0) { + http_response_set_json_error(res, 404, "Camera not found"); + return 0; + } + return httpd_authorize_stream_action_with_context( + req, res, action, stream.name, user, camera, evaluation); + } + return httpd_authorize_stream_action_with_context( + req, res, action, legacy_stream_name, user, camera, evaluation); +} + void httpd_sanitize_attachment_filename(const char *input, char *output, size_t output_size) { if (!output || output_size == 0) return; diff --git a/src/web/libuv_api_handlers.c b/src/web/libuv_api_handlers.c index bd550acaf..c4ead5666 100644 --- a/src/web/libuv_api_handlers.c +++ b/src/web/libuv_api_handlers.c @@ -26,6 +26,7 @@ #include "web/api_handlers_recordings.h" #include "web/api_handlers_recordings_batch_download.h" #include "web/api_handlers_timeline.h" +#include "web/api_handlers_investigations.h" #include "web/api_handlers_onvif.h" #include "web/api_handlers_users.h" #include "web/api_handlers_totp.h" @@ -348,6 +349,7 @@ int register_all_libuv_handlers(http_server_handle_t server) { http_server_register_handler(server, "/api/timeline/segments", "GET", handle_get_timeline_segments); http_server_register_handler(server, "/api/timeline/manifest", "GET", handle_timeline_manifest); http_server_register_handler(server, "/api/timeline/play", "GET", handle_timeline_playback); + http_server_register_handler(server, "/api/investigations/timeline", "POST", handle_post_investigation_timeline); // HLS Streaming (backend-agnostic handler) // Pattern uses # for single-segment wildcards: /hls/{stream_name}/{filename} diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index ae22627f2..d3c0813b0 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -142,6 +142,7 @@ add_layer2_test(test_db_camera_tags) add_layer2_test(test_api_handlers_camera_tags) add_layer2_test(test_camera_selector) add_layer2_test(test_api_handlers_fleet) +add_layer2_test(test_api_handlers_investigations) add_layer2_test(test_db_camera_collections) add_layer2_test(test_api_handlers_camera_collections) add_layer2_test(test_db_recordings_extended) diff --git a/tests/unit/test_api_handlers_investigations.c b/tests/unit/test_api_handlers_investigations.c new file mode 100644 index 000000000..566c4dcb5 --- /dev/null +++ b/tests/unit/test_api_handlers_investigations.c @@ -0,0 +1,214 @@ +/** + * @file test_api_handlers_investigations.c + * @brief Capture-time identity and multi-camera timeline API tests. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include + +#include +#include + +#include "unity.h" +#include "core/config.h" +#include "database/db_core.h" +#include "database/db_detections.h" +#include "database/db_recordings.h" +#include "database/db_streams.h" +#include "utils/strings.h" +#include "video/detection_result.h" +#include "web/api_handlers_investigations.h" +#include "web/request_response.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_investigations_test.db" + +static stream_config_t create_camera(const char *name) { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + safe_strcpy(stream.name, name, sizeof(stream.name), 0); + safe_strcpy(stream.url, "rtsp://camera.example/live", + sizeof(stream.url), 0); + safe_strcpy(stream.codec, "h264", sizeof(stream.codec), 0); + stream.enabled = true; + stream.streaming_enabled = true; + stream.record = true; + stream.width = 1920; + stream.height = 1080; + stream.fps = 25; + stream.segment_duration = 60; + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name(name, &stream)); + return stream; +} + +static uint64_t create_recording(const char *camera_uuid, + const char *stream_name, + time_t start_time) { + recording_metadata_t recording; + memset(&recording, 0, sizeof(recording)); + safe_strcpy(recording.stream_name, stream_name, + sizeof(recording.stream_name), 0); + if (camera_uuid) { + safe_strcpy(recording.camera_uuid, camera_uuid, + sizeof(recording.camera_uuid), 0); + } + safe_strcpy(recording.file_path, "/tmp/investigation.mp4", + sizeof(recording.file_path), 0); + safe_strcpy(recording.codec, "h264", sizeof(recording.codec), 0); + safe_strcpy(recording.trigger_type, "scheduled", + sizeof(recording.trigger_type), 0); + recording.start_time = start_time; + recording.end_time = start_time + 60; + recording.is_complete = true; + recording.schedule_restricted = 0; + recording.disk_pressure_eligible = true; + return add_recording_metadata(&recording); +} + +static cJSON *call_timeline(const char *body, int expected_status) { + http_request_t request; + http_response_t response; + http_request_init(&request); + http_response_init(&response); + request.method = HTTP_METHOD_POST; + safe_strcpy(request.method_str, "POST", sizeof(request.method_str), 0); + safe_strcpy(request.path, "/api/investigations/timeline", + sizeof(request.path), 0); + safe_strcpy(request.client_ip, "127.0.0.1", + sizeof(request.client_ip), 0); + request.body = (void *)body; + request.body_len = strlen(body); + handle_post_investigation_timeline(&request, &response); + TEST_ASSERT_EQUAL_INT(expected_status, response.status_code); + cJSON *json = response.body + ? cJSON_Parse((const char *)response.body) : NULL; + TEST_ASSERT_NOT_NULL(json); + http_response_free(&response); + return json; +} + +void setUp(void) { + sqlite3 *db = get_db_handle(); + g_config.web_auth_enabled = false; + g_config.demo_mode = false; + sqlite3_exec(db, "DELETE FROM detections;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM recordings;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); +} + +void tearDown(void) { + g_config.web_auth_enabled = false; + g_config.demo_mode = false; +} + +void test_capture_identity_survives_camera_rename_and_drives_timeline(void) { + time_t now = time(NULL); + stream_config_t camera = create_camera("North Door"); + char camera_uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(camera_uuid, camera.camera_uuid, sizeof(camera_uuid), 0); + + uint64_t recording_id = create_recording(NULL, camera.name, now - 120); + TEST_ASSERT_NOT_EQUAL(0, recording_id); + + detection_result_t detection; + memset(&detection, 0, sizeof(detection)); + detection.count = 1; + safe_strcpy(detection.detections[0].label, "person", + sizeof(detection.detections[0].label), 0); + detection.detections[0].confidence = 0.9f; + TEST_ASSERT_EQUAL_INT(0, store_detections_in_db( + camera.name, &detection, now - 100, recording_id)); + + safe_strcpy(camera.name, "North Entrance", sizeof(camera.name), 0); + TEST_ASSERT_EQUAL_INT(0, update_stream_config("North Door", &camera)); + + /* A recorder that began before the rename may still have the old display + * name. Its captured UUID must remain authoritative. */ + uint64_t post_rename_id = create_recording( + camera_uuid, "North Door", now - 50); + TEST_ASSERT_NOT_EQUAL(0, post_rename_id); + + detection_result_t unlinked_detection = detection; + safe_strcpy(unlinked_detection.detections[0].label, "vehicle", + sizeof(unlinked_detection.detections[0].label), 0); + TEST_ASSERT_EQUAL_INT(0, store_detections_in_db_for_camera( + camera_uuid, "North Door", &unlinked_detection, now - 40, 0)); + + recording_metadata_t stored; + memset(&stored, 0, sizeof(stored)); + TEST_ASSERT_EQUAL_INT( + 0, get_recording_metadata_by_id(recording_id, &stored)); + TEST_ASSERT_EQUAL_STRING(camera_uuid, stored.camera_uuid); + TEST_ASSERT_EQUAL_STRING("North Door", stored.stream_name); + + sqlite3_stmt *statement = NULL; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_prepare_v2( + get_db_handle(), + "SELECT camera_uuid FROM detections WHERE recording_id = ?;", + -1, &statement, NULL)); + sqlite3_bind_int64(statement, 1, (sqlite3_int64)recording_id); + TEST_ASSERT_EQUAL_INT(SQLITE_ROW, sqlite3_step(statement)); + TEST_ASSERT_EQUAL_STRING( + camera_uuid, (const char *)sqlite3_column_text(statement, 0)); + sqlite3_finalize(statement); + + char body[512]; + snprintf(body, sizeof(body), + "{\"camera_uuids\":[\"%s\"],\"start_time\":%lld," + "\"end_time\":%lld}", + camera_uuid, (long long)(now - 300), (long long)now); + cJSON *json = call_timeline(body, 200); + cJSON *tracks = cJSON_GetObjectItemCaseSensitive(json, "tracks"); + TEST_ASSERT_TRUE(cJSON_IsArray(tracks)); + TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(tracks)); + cJSON *track = cJSON_GetArrayItem(tracks, 0); + TEST_ASSERT_EQUAL_STRING( + "North Entrance", + cJSON_GetObjectItemCaseSensitive(track, "name")->valuestring); + TEST_ASSERT_EQUAL_STRING( + camera_uuid, + cJSON_GetObjectItemCaseSensitive(track, "camera_uuid")->valuestring); + cJSON *segments = cJSON_GetObjectItemCaseSensitive(track, "segments"); + TEST_ASSERT_EQUAL_INT(2, cJSON_GetArraySize(segments)); + TEST_ASSERT_EQUAL_UINT64( + recording_id, + (uint64_t)cJSON_GetObjectItemCaseSensitive( + cJSON_GetArrayItem(segments, 0), "id")->valuedouble); + TEST_ASSERT_EQUAL_UINT64( + post_rename_id, + (uint64_t)cJSON_GetObjectItemCaseSensitive( + cJSON_GetArrayItem(segments, 1), "id")->valuedouble); + TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive( + cJSON_GetArrayItem(segments, 1), "has_detection"))); + cJSON_Delete(json); +} + +void test_timeline_rejects_duplicate_camera_ids(void) { + stream_config_t camera = create_camera("Duplicate Camera"); + char body[512]; + snprintf(body, sizeof(body), + "{\"camera_uuids\":[\"%s\",\"%s\"]," + "\"start_time\":100,\"end_time\":200}", + camera.camera_uuid, camera.camera_uuid); + cJSON *json = call_timeline(body, 400); + TEST_ASSERT_NOT_NULL(cJSON_GetObjectItemCaseSensitive(json, "error")); + cJSON_Delete(json); +} + +int main(void) { + unlink(TEST_DB_PATH); + if (init_database(TEST_DB_PATH) != 0) { + fprintf(stderr, "FATAL: init_database failed\n"); + return 1; + } + UNITY_BEGIN(); + RUN_TEST(test_capture_identity_survives_camera_rename_and_drives_timeline); + RUN_TEST(test_timeline_rejects_duplicate_camera_ids); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} diff --git a/web/css/investigation.css b/web/css/investigation.css new file mode 100644 index 000000000..ece227a98 --- /dev/null +++ b/web/css/investigation.css @@ -0,0 +1,396 @@ +.investigation-page { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 2rem; +} + +.investigation-heading, +.investigation-query-header, +.investigation-controls, +.investigation-player header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.investigation-heading h1, +.investigation-query-card h2 { + margin: 0; +} + +.investigation-heading p, +.investigation-query-header p { + margin: 0.25rem 0 0; + color: hsl(var(--muted-foreground)); +} + +.investigation-eyebrow { + display: inline-block; + margin-bottom: 0.25rem; + color: hsl(var(--primary)); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.investigation-subnav { + display: flex; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + white-space: nowrap; +} + +.investigation-subnav a { + padding: 0.5rem 0.75rem; + color: hsl(var(--muted-foreground)); + text-decoration: none; +} + +.investigation-subnav a + a { + border-left: 1px solid hsl(var(--border)); +} + +.investigation-subnav a.is-active { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.investigation-query-card, +.investigation-controls, +.investigation-timeline, +.investigation-player, +.investigation-empty { + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + background: hsl(var(--card)); + color: hsl(var(--card-foreground)); +} + +.investigation-query-card { + padding: 1rem; +} + +.investigation-query-grid { + display: grid; + grid-template-columns: minmax(15rem, 1fr) minmax(16rem, 0.75fr); + gap: 1rem; + margin-top: 1rem; +} + +.investigation-camera-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: 0.4rem 0.75rem; + max-height: 13rem; + margin: 0; + padding: 0.75rem; + overflow-y: auto; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; +} + +.investigation-camera-picker legend { + padding: 0 0.3rem; + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; +} + +.investigation-camera-picker label, +.investigation-track-label label { + display: flex; + align-items: center; + gap: 0.45rem; + min-height: 2rem; +} + +.investigation-time-fields { + display: grid; + align-content: start; + gap: 0.75rem; +} + +.investigation-time-fields label, +.investigation-controls label, +.investigation-cursor-time { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.investigation-time-fields label > span, +.investigation-controls label > span, +.investigation-cursor-time > span { + color: hsl(var(--muted-foreground)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.investigation-time-fields input, +.investigation-controls select { + min-height: 2.75rem; + padding: 0.45rem 0.6rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.4rem; + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +.investigation-error { + margin-top: 0.75rem; + padding: 0.65rem 0.75rem; + border: 1px solid hsl(var(--destructive) / 0.45); + border-radius: 0.45rem; + background: hsl(var(--destructive) / 0.08); + color: hsl(var(--destructive)); +} + +.investigation-controls { + flex-wrap: wrap; + justify-content: flex-start; + padding: 0.75rem; +} + +.investigation-play-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: 2.75rem; + min-width: 7rem; + justify-content: center; +} + +.investigation-cursor-time { + min-width: 15rem; +} + +.investigation-cursor-time strong { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.investigation-decoder-count { + margin-left: auto; + padding: 0.35rem 0.55rem; + border-radius: 999px; + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + font-size: 0.75rem; +} + +.investigation-scrubber { + width: 100%; + min-height: 2rem; + accent-color: hsl(var(--primary)); +} + +.investigation-timeline { + padding: 0.5rem; + overflow-x: auto; +} + +.investigation-track { + display: grid; + grid-template-columns: minmax(10rem, 16rem) minmax(32rem, 1fr); + align-items: center; + gap: 0.75rem; + min-width: 46rem; + padding: 0.45rem; + border-radius: 0.4rem; +} + +.investigation-track + .investigation-track { + border-top: 1px solid hsl(var(--border)); +} + +.investigation-track.is-active { + background: hsl(var(--primary) / 0.05); +} + +.investigation-track-label { + min-width: 0; +} + +.investigation-track-label span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.investigation-track-label small { + display: block; + margin-left: 1.4rem; + color: hsl(var(--muted-foreground)); +} + +.investigation-track-bar { + position: relative; + height: 2.25rem; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 0.35rem; + background: repeating-linear-gradient( + -45deg, + hsl(var(--muted)), + hsl(var(--muted)) 6px, + hsl(var(--secondary)) 6px, + hsl(var(--secondary)) 12px + ); + cursor: crosshair; +} + +.investigation-track-bar:focus-visible { + outline: 2px solid hsl(var(--primary)); + outline-offset: 2px; +} + +.investigation-track-segment { + position: absolute; + top: 0.35rem; + bottom: 0.35rem; + min-width: 2px; + border-radius: 0.2rem; + background: hsl(var(--primary)); +} + +.investigation-track-segment.has-detection { + background: hsl(var(--success)); + box-shadow: inset 0 0 0 2px hsl(var(--foreground) / 0.35); +} + +.investigation-track-cursor { + position: absolute; + z-index: 3; + top: 0; + bottom: 0; + width: 2px; + background: hsl(var(--destructive)); + transform: translateX(-1px); +} + +.investigation-track-gap-label { + position: absolute; + z-index: 2; + right: 0.3rem; + bottom: 0.15rem; + padding: 0 0.25rem; + border-radius: 0.2rem; + background: hsl(var(--background) / 0.8); + color: hsl(var(--muted-foreground)); + font-size: 0.65rem; +} + +.investigation-player-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.investigation-player { + min-width: 0; + overflow: hidden; +} + +.investigation-player header { + padding: 0.6rem 0.75rem; +} + +.investigation-player header > div { + display: flex; + min-width: 0; + flex-direction: column; +} + +.investigation-player-status { + color: hsl(var(--muted-foreground)); + font-size: 0.72rem; +} + +.investigation-player header button { + padding: 0.35rem 0.55rem; + font-size: 0.72rem; +} + +.investigation-video-frame { + display: grid; + aspect-ratio: 16 / 9; + place-items: center; + background: #000; +} + +.investigation-video-frame video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.investigation-gap-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + color: #fff; +} + +.investigation-gap-state time { + color: #aaa; + font-size: 0.75rem; +} + +.investigation-empty { + padding: 2rem; + color: hsl(var(--muted-foreground)); + text-align: center; +} + +@media (max-width: 900px) { + .investigation-heading { + align-items: flex-start; + flex-direction: column; + } + + .investigation-query-grid, + .investigation-player-grid { + grid-template-columns: 1fr; + } + + .investigation-subnav { + width: 100%; + } + + .investigation-subnav a { + flex: 1; + text-align: center; + } +} + +@media (max-width: 640px) { + .investigation-page { + gap: 0.75rem; + } + + .investigation-query-header { + align-items: flex-start; + } + + .investigation-camera-picker { + grid-template-columns: 1fr; + } + + .investigation-cursor-time { + order: 5; + width: 100%; + } + + .investigation-decoder-count { + margin-left: 0; + } + + .investigation-player-grid { + grid-template-columns: 1fr; + } +} diff --git a/web/investigation.html b/web/investigation.html new file mode 100644 index 000000000..4ea57fa04 --- /dev/null +++ b/web/investigation.html @@ -0,0 +1,25 @@ + + + + + + Investigation - LightNVR + + + + + + + + + + +
+
+
+ +
+
+ + + diff --git a/web/js/components/preact/RecordingsView.jsx b/web/js/components/preact/RecordingsView.jsx index 9bfdd74ca..7af5edbb7 100644 --- a/web/js/components/preact/RecordingsView.jsx +++ b/web/js/components/preact/RecordingsView.jsx @@ -31,6 +31,8 @@ import { validateSession } from '../../utils/auth-utils.js'; const RECORDINGS_RETURN_URL_KEY = 'lightnvr_recordings_return_url'; const RECORDINGS_SELECTED_IDS_KEY = 'lightnvr_selected_recording_ids'; const RECORDINGS_RESTORE_SELECTION_KEY = 'lightnvr_restore_recording_selection'; +const MAX_INVESTIGATION_CAMERAS = 16; +const MAX_INVESTIGATION_WINDOW_SECONDS = 31 * 24 * 60 * 60; function getRestoredSelectedRecordings() { try { @@ -722,6 +724,63 @@ export function RecordingsView() { window.location.href = `timeline.html?ids=${selectedIds.join(',')}`; }; + const investigateSelected = async () => { + const selectedIds = Object.entries(selectedRecordings) + .filter(([_, selected]) => selected) + .map(([id]) => id); + if (selectedIds.length === 0) { + showStatusMessage(t('investigation.selectRecordingError'), 'warning'); + return; + } + + try { + const visibleById = new Map(recordings.map((recording) => + [String(recording.id), recording])); + const selected = await Promise.all(selectedIds.map((id) => + visibleById.get(String(id)) || recordingsAPI.getRecording(id))); + const unidentified = selected.filter((recording) => !recording.camera_uuid); + if (unidentified.length > 0) { + showStatusMessage(t('investigation.missingCameraIdentity', { + count: unidentified.length, + }), 'warning'); + return; + } + + const cameraUuids = [...new Set(selected.map((recording) => recording.camera_uuid))]; + if (cameraUuids.length > MAX_INVESTIGATION_CAMERAS) { + showStatusMessage(t('investigation.cameraLimit', { + count: MAX_INVESTIGATION_CAMERAS, + }), 'warning'); + return; + } + + const start = Math.min(...selected.map((recording) => + Number(recording.start_time_unix))); + const end = Math.max(...selected.map((recording) => + Number(recording.end_time_unix))); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + showStatusMessage(t('investigation.recordingTimeError'), 'warning'); + return; + } + const paddedStart = Math.max(1, Math.floor(start) - 30); + const paddedEnd = Math.ceil(end) + 30; + if (paddedEnd - paddedStart > MAX_INVESTIGATION_WINDOW_SECONDS) { + showStatusMessage(t('investigation.windowLimit'), 'warning'); + return; + } + + const params = new URLSearchParams({ + cameras: cameraUuids.join(','), + start: String(paddedStart), + end: String(paddedEnd), + cursor: String(Math.floor(start)), + }); + window.location.href = `investigation.html?${params.toString()}`; + } catch (requestError) { + showStatusMessage(requestError.message, 'error'); + } + }; + // Open download modal const openDownloadModal = () => setIsDownloadModalOpen(true); @@ -923,13 +982,22 @@ export function RecordingsView() { /> {/* Contextual action — only shown when recordings are selected */} {getSelectedCount() > 0 && ( - + <> + + + )} @@ -972,6 +1040,12 @@ export function RecordingsView() { > {t('nav.timeline')} + + {t('nav.investigation')} + diff --git a/web/js/components/preact/investigation/InvestigationView.jsx b/web/js/components/preact/investigation/InvestigationView.jsx new file mode 100644 index 000000000..d4ad78196 --- /dev/null +++ b/web/js/components/preact/investigation/InvestigationView.jsx @@ -0,0 +1,569 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; + +import { fetchJSON, useQuery } from '../../../query-client.js'; +import { useI18n } from '../../../i18n.js'; +import { LoadingIndicator } from '../LoadingIndicator.jsx'; +import { + MAX_ACTIVE_INVESTIGATION_PLAYERS, + MAX_INVESTIGATION_CAMERAS, + advanceInvestigationCursor, + findSegmentAt, + formatCursorTime, + formatDateTimeLocal, + parseDateTimeLocal, + segmentTrackPosition, +} from './investigationUtils.js'; + +function initialTimeState() { + const params = new URLSearchParams(window.location.search); + const now = Math.floor(Date.now() / 1000); + const parsedStart = Number(params.get('start')); + const parsedEnd = Number(params.get('end')); + const start = Number.isFinite(parsedStart) && parsedStart > 0 + ? parsedStart : now - 5 * 60; + const end = Number.isFinite(parsedEnd) && parsedEnd > start + ? parsedEnd : now; + return { start, end }; +} + +function InvestigationPlayer({ + track, + cursor, + playing, + speed, + primary, + onMakePrimary, + t, +}) { + const videoRef = useRef(null); + const cursorRef = useRef(cursor); + const segment = findSegmentAt(track.segments, cursor); + const [status, setStatus] = useState(segment ? 'loading' : 'gap'); + + useEffect(() => { + cursorRef.current = cursor; + }, [cursor]); + + const seekToCursor = useCallback(() => { + const video = videoRef.current; + if (!video || !segment || video.readyState < 1) return; + const expected = Math.max( + 0, + Math.min(cursorRef.current - segment.start_time, video.duration || Infinity), + ); + const drift = Math.abs(video.currentTime - expected); + if (drift > 0.5) { + setStatus('synchronizing'); + try { + video.currentTime = expected; + } catch (_error) { + setStatus('late'); + return; + } + } else { + setStatus((current) => current === 'error' ? current : 'ready'); + } + }, [segment?.id, segment?.start_time]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return undefined; + if (!segment) { + video.pause(); + video.removeAttribute('src'); + video.load(); + setStatus('gap'); + return undefined; + } + + setStatus('loading'); + const loaded = () => { + seekToCursor(); + video.playbackRate = speed; + if (playing) { + video.play().catch(() => setStatus('paused-by-browser')); + } + }; + video.addEventListener('loadedmetadata', loaded); + video.src = `/api/recordings/play/${segment.id}`; + video.load(); + return () => video.removeEventListener('loadedmetadata', loaded); + }, [segment?.id]); + + useEffect(() => { + seekToCursor(); + }, [cursor, seekToCursor]); + + useEffect(() => { + const video = videoRef.current; + if (!video || !segment) return; + video.playbackRate = speed; + if (playing) { + video.play().catch(() => setStatus('paused-by-browser')); + } else { + video.pause(); + } + }, [playing, speed, segment?.id]); + + return ( +
+
+
+ {track.name} + + {status === 'gap' ? t('investigation.noFootage') : t(`investigation.status.${status}`)} + +
+ +
+
+ {segment ? ( +
+
+ ); +} + +function InvestigationTrack({ + track, + startTime, + endTime, + cursor, + active, + onToggleActive, + onSeek, + t, +}) { + const cursorPercent = Math.max( + 0, + Math.min(100, ((cursor - startTime) / Math.max(endTime - startTime, 1)) * 100), + ); + const hasFootage = Boolean(findSegmentAt(track.segments, cursor)); + + const seekFromPointer = (event) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)); + onSeek(startTime + ratio * (endTime - startTime)); + }; + + return ( +
+
+ + + {track.segment_count} {t('investigation.segments')} + {track.truncated ? ` · ${t('investigation.truncated')}` : ''} + +
+
{ + if (event.key === 'ArrowLeft') onSeek(Math.max(startTime, cursor - 1)); + if (event.key === 'ArrowRight') onSeek(Math.min(endTime, cursor + 1)); + }} + > + {(track.segments || []).map((segment) => ( + + ))} + + {!hasFootage && {t('investigation.gap')}} +
+
+ ); +} + +export function InvestigationView() { + const { t } = useI18n(); + const initialTimes = useMemo(initialTimeState, []); + const [startTime, setStartTime] = useState(initialTimes.start); + const [endTime, setEndTime] = useState(initialTimes.end); + const [selectedCameraUuids, setSelectedCameraUuids] = useState([]); + const [timeline, setTimeline] = useState(null); + const [cursor, setCursor] = useState(initialTimes.start); + const [activeCameraUuids, setActiveCameraUuids] = useState([]); + const [primaryCameraUuid, setPrimaryCameraUuid] = useState(null); + const [playing, setPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const [playbackMode, setPlaybackMode] = useState('wall-clock'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const initialSelectionApplied = useRef(false); + const initialQueryLoaded = useRef(false); + const requestController = useRef(null); + const lastUrlCursor = useRef(null); + + const { data: streamData, isLoading: streamsLoading, error: streamsError } = + useQuery('investigation-streams', '/api/streams', { + timeout: 15000, + retries: 1, + }); + const streams = Array.isArray(streamData) ? streamData : []; + + useEffect(() => { + if (initialSelectionApplied.current || streams.length === 0) return; + const params = new URLSearchParams(window.location.search); + const requestedUuids = (params.get('cameras') || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const requestedNames = (params.get('stream') || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const valid = streams.filter((stream) => + requestedUuids.includes(stream.camera_uuid) || requestedNames.includes(stream.name)); + const initial = (valid.length > 0 ? valid : streams.slice(0, 1)) + .slice(0, MAX_INVESTIGATION_CAMERAS) + .map((stream) => stream.camera_uuid); + setSelectedCameraUuids(initial); + initialSelectionApplied.current = true; + }, [streams]); + + const loadTimeline = useCallback(async () => { + if (selectedCameraUuids.length === 0) { + setError(t('investigation.selectCameraError')); + return; + } + if (endTime <= startTime) { + setError(t('investigation.timeRangeError')); + return; + } + requestController.current?.abort(); + const controller = new AbortController(); + requestController.current = controller; + setLoading(true); + setError(''); + setPlaying(false); + try { + const data = await fetchJSON('/api/investigations/timeline', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + camera_uuids: selectedCameraUuids, + start_time: Math.floor(startTime), + end_time: Math.floor(endTime), + }), + signal: controller.signal, + timeout: 30000, + retries: 0, + }); + setTimeline(data); + const tracks = data.tracks || []; + const nextActive = tracks + .slice(0, data.max_active_decoders || MAX_ACTIVE_INVESTIGATION_PLAYERS) + .map((track) => track.camera_uuid); + setActiveCameraUuids(nextActive); + setPrimaryCameraUuid(nextActive[0] || null); + const params = new URLSearchParams(window.location.search); + const requestedCursor = Number(params.get('cursor')); + const firstSegmentStart = tracks + .flatMap((track) => track.segments || []) + .reduce((earliest, segment) => + earliest === null || segment.start_time < earliest + ? segment.start_time : earliest, null); + const nextCursor = Number.isFinite(requestedCursor) && + requestedCursor >= data.start_time && requestedCursor <= data.end_time + ? requestedCursor + : firstSegmentStart ?? data.start_time; + setCursor(nextCursor); + + const url = new URL(window.location.href); + url.searchParams.set('cameras', selectedCameraUuids.join(',')); + url.searchParams.set('start', String(Math.floor(startTime))); + url.searchParams.set('end', String(Math.floor(endTime))); + url.searchParams.set('cursor', String(Math.floor(nextCursor))); + url.searchParams.delete('stream'); + window.history.replaceState({}, '', url); + } catch (requestError) { + if (!controller.signal.aborted) setError(requestError.message); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, [selectedCameraUuids, startTime, endTime, t]); + + useEffect(() => { + if (!initialSelectionApplied.current || initialQueryLoaded.current || + selectedCameraUuids.length === 0) return; + initialQueryLoaded.current = true; + void loadTimeline(); + }, [selectedCameraUuids, loadTimeline]); + + useEffect(() => () => requestController.current?.abort(), []); + + const tracks = timeline?.tracks || []; + const activeTracks = tracks.filter((track) => + activeCameraUuids.includes(track.camera_uuid)); + const activeKey = activeCameraUuids.join(','); + + useEffect(() => { + if (!playing || !timeline) return undefined; + let previous = performance.now(); + const timer = window.setInterval(() => { + const current = performance.now(); + const elapsedSeconds = (current - previous) / 1000; + previous = current; + setCursor((value) => advanceInvestigationCursor({ + cursor: value, + elapsedSeconds, + speed, + endTime: timeline.end_time, + mode: playbackMode, + tracks, + activeCameraUuids, + })); + }, 100); + return () => window.clearInterval(timer); + }, [playing, speed, playbackMode, timeline, activeKey]); + + useEffect(() => { + if (timeline && cursor >= timeline.end_time) setPlaying(false); + if (!timeline) return; + const roundedCursor = Math.floor(cursor); + if (lastUrlCursor.current === roundedCursor) return; + lastUrlCursor.current = roundedCursor; + const url = new URL(window.location.href); + url.searchParams.set('cursor', String(roundedCursor)); + window.history.replaceState({}, '', url); + }, [cursor, timeline]); + + const toggleSelectedCamera = (cameraUuid) => { + setSelectedCameraUuids((current) => { + if (current.includes(cameraUuid)) { + return current.filter((uuid) => uuid !== cameraUuid); + } + if (current.length >= MAX_INVESTIGATION_CAMERAS) { + setError(t('investigation.cameraLimit', { count: MAX_INVESTIGATION_CAMERAS })); + return current; + } + setError(''); + return [...current, cameraUuid]; + }); + }; + + const toggleActiveCamera = (cameraUuid) => { + setActiveCameraUuids((current) => { + if (current.includes(cameraUuid)) { + const next = current.filter((uuid) => uuid !== cameraUuid); + if (primaryCameraUuid === cameraUuid) setPrimaryCameraUuid(next[0] || null); + return next; + } + const limit = timeline?.max_active_decoders || MAX_ACTIVE_INVESTIGATION_PLAYERS; + if (current.length >= limit) { + setError(t('investigation.decoderLimit', { count: limit })); + return current; + } + setError(''); + return [...current, cameraUuid]; + }); + }; + + return ( +
+
+
+ {t('investigation.experimental')} +

{t('investigation.title')}

+

{t('investigation.description')}

+
+ +
+ +
+
+
+

{t('investigation.query')}

+

{t('investigation.queryHelp')}

+
+ {selectedCameraUuids.length}/{MAX_INVESTIGATION_CAMERAS} {t('investigation.cameras')} +
+
+
+ {t('investigation.cameraSelection')} + {streamsLoading && } + {streamsError &&

{streamsError.message}

} + {!streamsLoading && streams.map((stream) => ( + + ))} +
+
+ + + +
+
+ {error &&
{error}
} +
+ + {loading && } + + {timeline && !loading && ( + <> +
+ + + +
+ {t('investigation.sharedCursor')} + {formatCursorTime(cursor)} +
+ + {activeTracks.length}/{timeline.max_active_decoders} {t('investigation.activePlayers')} + +
+ + { + setPlaying(false); + setCursor(Number(event.target.value)); + }} + /> + +
+ {tracks.map((track) => ( + toggleActiveCamera(track.camera_uuid)} + onSeek={(value) => { + setPlaying(false); + setCursor(value); + }} + t={t} + /> + ))} +
+ + {activeTracks.length > 0 ? ( +
+ {activeTracks.map((track) => ( + setPrimaryCameraUuid(track.camera_uuid)} + t={t} + /> + ))} +
+ ) : ( +
{t('investigation.activateCamera')}
+ )} + + )} +
+ ); +} diff --git a/web/js/components/preact/investigation/investigationUtils.js b/web/js/components/preact/investigation/investigationUtils.js new file mode 100644 index 000000000..8b2109eda --- /dev/null +++ b/web/js/components/preact/investigation/investigationUtils.js @@ -0,0 +1,92 @@ +export const MAX_INVESTIGATION_CAMERAS = 16; +export const MAX_ACTIVE_INVESTIGATION_PLAYERS = 4; + +export function findSegmentAt(segments, timestamp) { + if (!Array.isArray(segments) || !Number.isFinite(timestamp)) return null; + let low = 0; + let high = segments.length - 1; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const segment = segments[middle]; + if (timestamp < segment.start_time) { + high = middle - 1; + } else if (timestamp > segment.end_time) { + low = middle + 1; + } else { + return segment; + } + } + return null; +} + +export function nextAvailableTimestamp(tracks, activeCameraUuids, timestamp) { + const active = new Set(activeCameraUuids || []); + let next = null; + for (const track of tracks || []) { + if (!active.has(track.camera_uuid)) continue; + for (const segment of track.segments || []) { + if (segment.end_time < timestamp) continue; + if (segment.start_time <= timestamp) return timestamp; + if (next === null || segment.start_time < next) next = segment.start_time; + break; + } + } + return next; +} + +export function advanceInvestigationCursor({ + cursor, + elapsedSeconds, + speed, + endTime, + mode, + tracks, + activeCameraUuids, +}) { + const candidate = Math.min(endTime, cursor + elapsedSeconds * speed); + if (mode !== 'skip-common-gaps') return candidate; + if ((tracks || []).some((track) => + (activeCameraUuids || []).includes(track.camera_uuid) && + findSegmentAt(track.segments, candidate))) { + return candidate; + } + return Math.min( + endTime, + nextAvailableTimestamp(tracks, activeCameraUuids, candidate) ?? endTime, + ); +} + +export function segmentTrackPosition(segment, startTime, endTime) { + const duration = Math.max(endTime - startTime, 1); + const clippedStart = Math.max(segment.start_time, startTime); + const clippedEnd = Math.min(segment.end_time, endTime); + return { + left: `${Math.max(0, ((clippedStart - startTime) / duration) * 100)}%`, + width: `${Math.max(0.2, ((clippedEnd - clippedStart) / duration) * 100)}%`, + }; +} + +export function formatDateTimeLocal(timestamp) { + if (!Number.isFinite(timestamp)) return ''; + const date = new Date(timestamp * 1000); + const offset = date.getTimezoneOffset() * 60 * 1000; + return new Date(date.getTime() - offset).toISOString().slice(0, 16); +} + +export function parseDateTimeLocal(value) { + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? Math.floor(timestamp / 1000) : null; +} + +export function formatCursorTime(timestamp) { + if (!Number.isFinite(timestamp)) return '—'; + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + }).format(new Date(timestamp * 1000)); +} diff --git a/web/js/components/preact/recordings/recordingsAPI.jsx b/web/js/components/preact/recordings/recordingsAPI.jsx index c9d88fced..ed601fd28 100644 --- a/web/js/components/preact/recordings/recordingsAPI.jsx +++ b/web/js/components/preact/recordings/recordingsAPI.jsx @@ -160,6 +160,12 @@ const buildFilterObject = (filters) => { export const recordingsAPI = { parseRecordingTimestamp, + getRecording: async (recordingId) => fetchJSON(`/api/recordings/${recordingId}`, { + timeout: DEFAULT_TIMEOUT, + retries: 1, + retryDelay: DEFAULT_RETRY_DELAY, + }), + /** * Custom hooks for preact-query */ diff --git a/web/js/components/preact/timeline/TimelinePage.jsx b/web/js/components/preact/timeline/TimelinePage.jsx index 3ab5eb482..8215b6cef 100644 --- a/web/js/components/preact/timeline/TimelinePage.jsx +++ b/web/js/components/preact/timeline/TimelinePage.jsx @@ -1258,6 +1258,17 @@ export function TimelinePage() { // Get return URL for "Refine Selections" link const returnUrl = idsMode ? (sessionStorage.getItem(RECORDINGS_RETURN_URL_KEY) || 'recordings.html') : null; + const investigationParams = new URLSearchParams(); + if (selectedStream) investigationParams.set('stream', selectedStream); + const investigationBounds = getLocalDayBounds(selectedDate); + if (investigationBounds) { + investigationParams.set('start', String(investigationBounds.startTimestamp)); + investigationParams.set('end', String(investigationBounds.endTimestamp)); + } + if (Number.isFinite(timelineState.currentTime)) { + investigationParams.set('cursor', String(Math.floor(timelineState.currentTime))); + } + const investigationHref = `investigation.html?${investigationParams.toString()}`; return ( diff --git a/web/js/pages/investigation-page.jsx b/web/js/pages/investigation-page.jsx new file mode 100644 index 000000000..8b533d159 --- /dev/null +++ b/web/js/pages/investigation-page.jsx @@ -0,0 +1,25 @@ +import { render } from 'preact'; + +import { InvestigationView } from '../components/preact/investigation/InvestigationView.jsx'; +import { Footer } from '../components/preact/Footer.jsx'; +import { Header } from '../components/preact/Header.jsx'; +import { ToastContainer } from '../components/preact/ToastContainer.jsx'; +import { initI18n } from '../i18n.js'; +import { QueryClientProvider, queryClient } from '../query-client.js'; +import { setupSessionValidation } from '../utils/auth-utils.js'; + +document.addEventListener('DOMContentLoaded', async () => { + await initI18n(); + setupSessionValidation(); + const container = document.getElementById('main-content'); + if (!container) return; + render( + +
+ + +