From e300b8298de89283a4fabbca6361d1d9d8c82674 Mon Sep 17 00:00:00 2001 From: lightNVR agent Date: Sat, 17 Oct 2026 18:16:23 +0000 Subject: [PATCH 1/2] Agent: Show the still-recording segment in the overview and timeline (#482) With long segment durations, a stream's current segment was invisible in the recordings overview and timeline until rotation finalized it. The recorder inserts the segment's DB row at its first keyframe, so the read paths were hiding it behind the is_complete filter for the whole segment lifetime. Read-side changes: - get_recording_count()/get_recording_metadata_paginated() gain an include_in_progress opt-in that also matches incomplete rows while their start_time is fresh (2x the stream's segment_duration, schema default 900 s when the config row is missing), so zombie rows left behind by a killed writer thread self-expire out of the views. - GET /api/recordings opts in and exposes the live segment with in_progress: true, end_time/end_time_unix: null and a live elapsed duration; GET /api/recordings/:id reports the same. - Timeline segment and coverage queries include the live segment with an effective end of "now" and return in_progress per segment. - Playback of an unfinished segment fails fast with HTTP 409, since the MP4 moov atom only exists once the segment is finalized. - Batch-delete/retention/storage-cache callers keep passing false so a segment that is still being written is never touched mid-write. Web UI: a pulsing "Recording" state in the recordings grid/table with a live elapsed duration, a clear message instead of a broken modal when playing a live segment, and the timeline renders the live segment as a pulsing bar through "now" with a matching legend entry. Tests: unit coverage for the opt-in predicate, zombie self-expiry, the recordings-list contract, the playback 409 and timeline segment inclusion. --- include/database/db_recordings.h | 17 +- include/web/api_handlers_recordings.h | 11 +- include/web/api_handlers_timeline.h | 3 +- src/database/db_recordings.c | 74 +++++-- src/storage/storage_manager_streams.c | 6 +- ...api_handlers_recordings_backend_agnostic.c | 44 +++-- src/web/api_handlers_recordings_list.c | 43 +++- src/web/api_handlers_recordings_playback.c | 8 + src/web/api_handlers_system.c | 2 +- src/web/api_handlers_timeline.c | 87 ++++++-- .../unit/test_api_handlers_recordings_list.c | 71 +++++++ .../test_api_handlers_recordings_playback.c | 17 ++ tests/unit/test_db_recordings_extended.c | 187 ++++++++++++++++-- web/css/timeline.css | 27 +++ web/js/components/preact/RecordingsView.jsx | 8 + .../preact/recordings/RecordingsGrid.jsx | 49 ++++- .../preact/recordings/RecordingsTable.jsx | 12 +- .../preact/recordings/formatUtils.js | 20 +- .../preact/timeline/TimelinePage.jsx | 6 + .../preact/timeline/TimelineSegments.jsx | 5 +- web/public/locales/en.json | 4 + 21 files changed, 607 insertions(+), 94 deletions(-) diff --git a/include/database/db_recordings.h b/include/database/db_recordings.h index 8ba53646..3f95d5a6 100644 --- a/include/database/db_recordings.h +++ b/include/database/db_recordings.h @@ -141,13 +141,21 @@ int get_recording_metadata(time_t start_time, time_t end_time, * @param allowed_streams_count Number of entries in allowed_streams (0 for no restriction) * @param tag_filter Filter by recording tag as a single value or comma-separated list (NULL for all) * @param capture_method_filter Filter by capture method as a single value or comma-separated list (NULL for all) + * @param include_in_progress When true, also count segments that are still + * being recorded (is_complete = 0, no end_time yet) while their + * start_time is fresh enough to belong to a live segment — stale + * incomplete rows left behind by a crashed writer self-expire from + * the result (#482). Callers that *mutate* recordings (retention, + * batch delete) must pass false so the active segment is never + * touched mid-write. * @return Total count of matching recordings, or -1 on error */ int get_recording_count(time_t start_time, time_t end_time, const char *stream_name, int has_detection, const char *detection_label, int protected_filter, const char * const *allowed_streams, int allowed_streams_count, - const char *tag_filter, const char *capture_method_filter); + const char *tag_filter, const char *capture_method_filter, + bool include_in_progress); /** * Get paginated recording metadata from the database with sorting. @@ -170,6 +178,10 @@ int get_recording_count(time_t start_time, time_t end_time, * @param allowed_streams_count Number of entries in allowed_streams (0 for no restriction) * @param tag_filter Filter by recording tag as a single value or comma-separated list (NULL for all) * @param capture_method_filter Filter by capture method as a single value or comma-separated list (NULL for all) + * @param include_in_progress When true, also return segments that are still + * being recorded (see get_recording_count). The caller exposes them + * separately (e.g. as an "in progress" flag) since their end_time is + * 0 until the segment finishes (#482). * @return Number of recordings found, or -1 on error */ int get_recording_metadata_paginated(time_t start_time, time_t end_time, @@ -180,7 +192,8 @@ int get_recording_metadata_paginated(time_t start_time, time_t end_time, recording_metadata_t *metadata, int limit, int offset, const char * const *allowed_streams, int allowed_streams_count, - const char *tag_filter, const char *capture_method_filter); + const char *tag_filter, const char *capture_method_filter, + bool include_in_progress); /** * Get recording metadata by ID diff --git a/include/web/api_handlers_recordings.h b/include/web/api_handlers_recordings.h index 874452be..8c20501e 100644 --- a/include/web/api_handlers_recordings.h +++ b/include/web/api_handlers_recordings.h @@ -4,6 +4,7 @@ #include /* for off_t */ #include /* for time_t */ #include +#include /* for bool */ #include "database/database_manager.h" /* for recording_metadata_t */ /* @@ -62,13 +63,16 @@ void recordings_count_cache_reset(void); * @param detection_only Filter to only include recordings with detection events * @param detection_label Filter by specific detection label (NULL for all) * @param protected_filter Filter by protection status (-1 for all, 0 for unprotected, 1 for protected) + * @param include_in_progress Also count segments that are still being + * recorded while their start_time is fresh enough to be live (#482) * * @return Total number of matching recordings, or -1 on error */ int get_recording_count(time_t start_time, time_t end_time, const char *stream_name, int detection_only, const char *detection_label, int protected_filter, const char * const *allowed_streams, int allowed_streams_count, - const char *tag_filter, const char *capture_method_filter); + const char *tag_filter, const char *capture_method_filter, + bool include_in_progress); /** * Get paginated recording metadata from the database with sorting @@ -87,6 +91,8 @@ int get_recording_count(time_t start_time, time_t end_time, const char *stream_n * @param offset Offset for pagination * @param allowed_streams Optional whitelist of stream names for tag-based RBAC (NULL or count=0 for no restriction) * @param allowed_streams_count Number of entries in allowed_streams (0 for no restriction) + * @param include_in_progress Also return segments that are still being + * recorded (the caller exposes them as in-progress) (#482) * * @return Number of recordings fetched, or -1 on error */ @@ -98,7 +104,8 @@ int get_recording_metadata_paginated(time_t start_time, time_t end_time, recording_metadata_t *metadata, int limit, int offset, const char * const *allowed_streams, int allowed_streams_count, - const char *tag_filter, const char *capture_method_filter); + const char *tag_filter, const char *capture_method_filter, + bool include_in_progress); /** diff --git a/include/web/api_handlers_timeline.h b/include/web/api_handlers_timeline.h index f9d011ab..41a8d58f 100644 --- a/include/web/api_handlers_timeline.h +++ b/include/web/api_handlers_timeline.h @@ -13,10 +13,11 @@ typedef struct { char camera_uuid[CAMERA_UUID_STRING_SIZE]; char file_path[MAX_PATH_LENGTH]; time_t start_time; - time_t end_time; + time_t end_time; // for in_progress segments: effective end ("now") uint64_t size_bytes; bool has_detection; bool protected; + bool in_progress; // segment is still being recorded (#482) char trigger_type[16]; int schedule_restricted; // -1 = unknown/legacy, 0 = no, 1 = yes } timeline_segment_t; diff --git a/src/database/db_recordings.c b/src/database/db_recordings.c index 5f28c85e..5e2a973c 100644 --- a/src/database/db_recordings.c +++ b/src/database/db_recordings.c @@ -22,6 +22,29 @@ #define MAX_MULTI_FILTER_VALUES 32 #define MAX_MULTI_FILTER_VALUE_LEN 128 +/* + * "Visible" predicate shared by the overview/timeline read paths. + * + * Complete segments are always visible. A segment that is still being + * recorded (is_complete = 0, end_time not yet set — issue #482) is visible + * only while its start_time is fresh enough to belong to a live segment: + * an active segment's start_time can age by at most its configured + * segment_duration before rotation, so 2x that duration is a safe grace + * window. This also self-expires incomplete "zombie" rows left behind when + * a writer thread is killed mid-segment (those keep their fixed, aging + * start_time and drop out of the view once the window passes; the startup + * reconcile pass in storage_manager.c finalizes them for good). + * + * When the per-stream config row is missing we fall back to the schema + * default of 900 s. The LEFT JOIN on streams is indexed on streams.name + * (UNIQUE), so it is one cache-warm lookup per candidate row. + */ +#define RECORDINGS_VISIBLE_PREDICATE \ + "((r.is_complete = 1 AND r.end_time IS NOT NULL)" \ + " OR (r.is_complete = 0 AND COALESCE(r.end_time, 0) = 0" \ + " AND r.start_time >= CAST(strftime('%s','now') AS INTEGER)" \ + " - (2 * COALESCE(NULLIF(s.segment_duration, 0), 900))))" + /* * In-process change generation for the recordings table. Every mutation that * can alter what the recording list / count queries return bumps it, so @@ -837,7 +860,8 @@ int get_recording_count(time_t start_time, time_t end_time, const char *stream_name, int has_detection, const char *detection_label, int protected_filter, const char * const *allowed_streams, int allowed_streams_count, - const char *tag_filter, const char *capture_method_filter) { + const char *tag_filter, const char *capture_method_filter, + bool include_in_progress) { int rc; sqlite3_stmt *stmt; int count = 0; @@ -854,7 +878,18 @@ int get_recording_count(time_t start_time, time_t end_time, char sql[8192]; // Use trigger_type and/or detections table to filter detection-based recordings - safe_strcpy(sql, "SELECT COUNT(*) FROM recordings r WHERE r.is_complete = 1 AND r.end_time IS NOT NULL", sizeof(sql), 0); + if (include_in_progress) { + safe_strcpy(sql, + "SELECT COUNT(*) FROM recordings r " + "LEFT JOIN streams s ON s.name = r.stream_name " + "WHERE " RECORDINGS_VISIBLE_PREDICATE, + sizeof(sql), 0); + } else { + safe_strcpy(sql, + "SELECT COUNT(*) FROM recordings r " + "WHERE r.is_complete = 1 AND r.end_time IS NOT NULL", + sizeof(sql), 0); + } if (has_detection == 1) { // Filter by trigger_type = 'detection' OR existence of linked detections via recording_id (fast index lookup) @@ -1067,8 +1102,9 @@ int get_recording_metadata_paginated(time_t start_time, time_t end_time, const char *sort_field, const char *sort_order, recording_metadata_t *metadata, int limit, int offset, - const char * const *allowed_streams, int allowed_streams_count, - const char *tag_filter, const char *capture_method_filter) { + const char * const *allowed_streams, int allowed_streams_count, + const char *tag_filter, const char *capture_method_filter, + bool include_in_progress) { int rc; sqlite3_stmt *stmt; int count = 0; @@ -1115,13 +1151,29 @@ int get_recording_metadata_paginated(time_t start_time, time_t end_time, // Build query based on filters char sql[8192]; - // Use trigger_type and/or detections table to filter detection-based recordings - snprintf(sql, sizeof(sql), - "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.camera_uuid " - "FROM recordings r WHERE r.is_complete = 1 AND r.end_time IS NOT NULL"); + // Use trigger_type and/or detections table to filter detection-based recordings. + // Built with safe_strcpy (never snprintf): RECORDINGS_VISIBLE_PREDICATE + // contains strftime('%s', ...) placeholders that snprintf would misread + // as conversion specifiers. + if (include_in_progress) { + safe_strcpy(sql, + "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.camera_uuid " + "FROM recordings r " + "LEFT JOIN streams s ON s.name = r.stream_name " + "WHERE " RECORDINGS_VISIBLE_PREDICATE, + sizeof(sql), 0); + } else { + safe_strcpy(sql, + "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.camera_uuid " + "FROM recordings r WHERE r.is_complete = 1 AND r.end_time IS NOT NULL", + sizeof(sql), 0); + } if (has_detection == 1) { // Filter by trigger_type = 'detection' OR existence of linked detections via recording_id (fast index lookup) diff --git a/src/storage/storage_manager_streams.c b/src/storage/storage_manager_streams.c index 93561cda..fa31f33e 100644 --- a/src/storage/storage_manager_streams.c +++ b/src/storage/storage_manager_streams.c @@ -61,8 +61,10 @@ int get_stream_storage_usage(const char *storage_path, stream_storage_info_t *st total_bytes = 0; // Treat errors as zero } - // Get recording count from DB - int rec_count = get_recording_count(0, 0, stream_names[i], 0, NULL, -1, NULL, 0, NULL, NULL); + // Get recording count from DB (completed segments only — matches the + // completed-only size accounting above) + int rec_count = get_recording_count(0, 0, stream_names[i], 0, NULL, -1, + NULL, 0, NULL, NULL, false); if (rec_count < 0) { rec_count = 0; // Treat errors as zero } diff --git a/src/web/api_handlers_recordings_backend_agnostic.c b/src/web/api_handlers_recordings_backend_agnostic.c index c0aaa772..06ce1418 100644 --- a/src/web/api_handlers_recordings_backend_agnostic.c +++ b/src/web/api_handlers_recordings_backend_agnostic.c @@ -110,6 +110,12 @@ void handle_get_recording(const http_request_t *req, http_response_t *res) { return; } + // A segment that is still recording has no committed end_time yet; use + // "now" as its effective end for display and detection checks (#482). + const bool in_progress = !recording.is_complete; + const time_t effective_end = + (in_progress && recording.end_time <= 0) ? time(NULL) : recording.end_time; + // Format timestamps as ISO 8601 UTC (compatible with all browsers including Safari) char start_time_str[32] = {0}; char end_time_str[32] = {0}; @@ -121,13 +127,16 @@ void handle_get_recording(const http_request_t *req, http_response_t *res) { strftime(start_time_str, sizeof(start_time_str), "%Y-%m-%dT%H:%M:%SZ", tm_info); } - tm_info = gmtime_r(&recording.end_time, &tm_info_buf); - if (tm_info) { - strftime(end_time_str, sizeof(end_time_str), "%Y-%m-%dT%H:%M:%SZ", tm_info); + if (!in_progress) { + tm_info = gmtime_r(&recording.end_time, &tm_info_buf); + if (tm_info) { + strftime(end_time_str, sizeof(end_time_str), "%Y-%m-%dT%H:%M:%SZ", tm_info); + } } - + // Calculate duration in seconds - int duration = (int)difftime(recording.end_time, recording.start_time); + int duration = (int)difftime(effective_end, recording.start_time); + if (duration < 0) duration = 0; // Format file size for display char size_str[32] = {0}; @@ -152,9 +161,15 @@ void handle_get_recording(const http_request_t *req, http_response_t *res) { } 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); + if (in_progress) { + cJSON_AddNullToObject(recording_obj, "end_time"); + cJSON_AddNullToObject(recording_obj, "end_time_unix"); + } else { + cJSON_AddStringToObject(recording_obj, "end_time", end_time_str); + cJSON_AddNumberToObject(recording_obj, "end_time_unix", (double)recording.end_time); + } cJSON_AddNumberToObject(recording_obj, "start_time_unix", (double)recording.start_time); - cJSON_AddNumberToObject(recording_obj, "end_time_unix", (double)recording.end_time); + cJSON_AddBoolToObject(recording_obj, "in_progress", in_progress); cJSON_AddNumberToObject(recording_obj, "duration", duration); cJSON_AddStringToObject(recording_obj, "size", size_str); cJSON_AddStringToObject(recording_obj, "capture_method", @@ -171,11 +186,11 @@ void handle_get_recording(const http_request_t *req, http_response_t *res) { detection_label_summary_t labels[MAX_DETECTION_LABELS]; int label_count = 0; - if (recording.start_time > 0 && recording.end_time > 0) { + if (recording.start_time > 0 && effective_end > 0) { // Get detection labels summary for this recording's time range label_count = get_detection_labels_summary(recording.stream_name, recording.start_time, - recording.end_time, + effective_end, labels, MAX_DETECTION_LABELS); if (label_count > 0) { has_detection_flag = true; @@ -183,7 +198,7 @@ void handle_get_recording(const http_request_t *req, http_response_t *res) { // Fall back to simple check if get_detection_labels_summary returned 0 int det_result = has_detections_in_time_range(recording.stream_name, recording.start_time, - recording.end_time); + effective_end); if (det_result > 0) { has_detection_flag = true; } @@ -553,7 +568,8 @@ static void *batch_delete_worker_thread(void *arg) { } } - // Get total count + // Get total count. Batch delete must never match a segment that is + // still being written, so in-progress rows are excluded here (#482). int total_count = get_recording_count(start_time, end_time, stream_name[0] != '\0' ? stream_name : NULL, has_detection, @@ -564,7 +580,8 @@ static void *batch_delete_worker_thread(void *arg) { : NULL, collection_uuid[0] ? collection_stream_count : 0, tag_filter[0] != '\0' ? tag_filter : NULL, - capture_method_filter[0] != '\0' ? capture_method_filter : NULL); + capture_method_filter[0] != '\0' ? capture_method_filter : NULL, + false); if (total_count <= 0) { camera_collection_filter_free_stream_names( @@ -608,7 +625,8 @@ static void *batch_delete_worker_thread(void *arg) { : NULL, collection_uuid[0] ? collection_stream_count : 0, tag_filter[0] != '\0' ? tag_filter : NULL, - capture_method_filter[0] != '\0' ? capture_method_filter : NULL); + capture_method_filter[0] != '\0' ? capture_method_filter : NULL, + false); if (count <= 0) { free(recordings); diff --git a/src/web/api_handlers_recordings_list.c b/src/web/api_handlers_recordings_list.c index d0a667da..9b1ee11d 100644 --- a/src/web/api_handlers_recordings_list.c +++ b/src/web/api_handlers_recordings_list.c @@ -556,11 +556,14 @@ void handle_get_recordings(const http_request_t *req, http_response_t *res) { if (!count_key || !recordings_count_cache_lookup(count_key, count_now, recordings_generation, &total_count)) { + /* Show segments that are still being recorded too (#482): a stream + * with a long segment duration must appear in the overview + * immediately, not only after the segment finishes. */ total_count = get_recording_count(start_time, end_time, stream_filt, has_detection, label_filter, protected_filter, streams_filter, streams_filter_count, tag_filt, - capture_filt); + capture_filt, true); if (count_key && total_count >= 0) { recordings_count_cache_store(count_key, count_now, recordings_generation, total_count); @@ -600,7 +603,7 @@ void handle_get_recordings(const http_request_t *req, http_response_t *res) { sort_field, sort_order, recordings, limit, offset, streams_filter, streams_filter_count, - tag_filt, capture_filt); + tag_filt, capture_filt, true); if (count < 0) { log_error("Failed to get recordings from database"); @@ -659,6 +662,16 @@ void handle_get_recordings(const http_request_t *req, http_response_t *res) { // Add pagination object to response cJSON_AddItemToObject(response, "pagination", pagination); + // In-progress segments have no committed end_time yet; use "now" as the + // effective end for detection enrichment and display-duration purposes + // (#482). The response itself still reports end_time as null. + const time_t live_end = time(NULL); + for (int i = 0; i < count; i++) { + if (!recordings[i].is_complete) { + recordings[i].end_time = live_end; + } + } + // Enrich the whole page in bounded database batches. Historically this // loop issued one detection aggregation and one tag query per recording, // making the endpoint increasingly sensitive to SQLite mutex contention. @@ -702,6 +715,10 @@ void handle_get_recordings(const http_request_t *req, http_response_t *res) { continue; } + // A segment that is still recording has no real end_time; its end_time + // field was prefilled with the effective end ("now") above (#482). + const bool in_progress = !recordings[i].is_complete; + // Format timestamps as ISO 8601 UTC (compatible with all browsers including Safari) char start_time_formatted[32] = {0}; char end_time_formatted[32] = {0}; @@ -713,13 +730,17 @@ void handle_get_recordings(const http_request_t *req, http_response_t *res) { strftime(start_time_formatted, sizeof(start_time_formatted), "%Y-%m-%dT%H:%M:%SZ", tm_info); } - tm_info = gmtime_r(&recordings[i].end_time, &tm_info_buf); - if (tm_info) { - strftime(end_time_formatted, sizeof(end_time_formatted), "%Y-%m-%dT%H:%M:%SZ", tm_info); + if (!in_progress) { + tm_info = gmtime_r(&recordings[i].end_time, &tm_info_buf); + if (tm_info) { + strftime(end_time_formatted, sizeof(end_time_formatted), "%Y-%m-%dT%H:%M:%SZ", tm_info); + } } - // Calculate duration in seconds + // Calculate duration in seconds. For an in-progress segment this is + // the elapsed recording time so far (snapshot at request time). int duration = (int)difftime(recordings[i].end_time, recordings[i].start_time); + if (duration < 0) duration = 0; // Format file size for display (e.g., "1.8 MB") char size_str[32] = {0}; @@ -743,9 +764,15 @@ void handle_get_recordings(const http_request_t *req, http_response_t *res) { } 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); + if (in_progress) { + cJSON_AddNullToObject(recording, "end_time"); + cJSON_AddNullToObject(recording, "end_time_unix"); + } else { + cJSON_AddStringToObject(recording, "end_time", end_time_formatted); + cJSON_AddNumberToObject(recording, "end_time_unix", (double)recordings[i].end_time); + } cJSON_AddNumberToObject(recording, "start_time_unix", (double)recordings[i].start_time); - cJSON_AddNumberToObject(recording, "end_time_unix", (double)recordings[i].end_time); + cJSON_AddBoolToObject(recording, "in_progress", in_progress); cJSON_AddNumberToObject(recording, "duration", duration); cJSON_AddStringToObject(recording, "size", size_str); cJSON_AddStringToObject(recording, "capture_method", diff --git a/src/web/api_handlers_recordings_playback.c b/src/web/api_handlers_recordings_playback.c index b6e324ad..1d4a1005 100644 --- a/src/web/api_handlers_recordings_playback.c +++ b/src/web/api_handlers_recordings_playback.c @@ -66,6 +66,14 @@ void handle_recordings_playback(const http_request_t *req, http_response_t *res) return; } + // A segment that is still being recorded has no finalized moov atom, so + // browsers cannot play it. Fail fast with a clear, actionable error + // instead of serving an undecodable partial file (#482). + if (!recording.is_complete) { + http_response_set_json_error(res, 409, "Recording is still in progress"); + return; + } + if (recording_archive_serve(req, res, id, false)) return; if (!recording_source_for_request(req, res, id, recording.file_path)) return; diff --git a/src/web/api_handlers_system.c b/src/web/api_handlers_system.c index 9dbd6504..23477fe1 100644 --- a/src/web/api_handlers_system.c +++ b/src/web/api_handlers_system.c @@ -161,7 +161,7 @@ static void get_cached_recording_aggregates(int *count, int64_t *bytes) { now - recording_aggregate_cache.refreshed_at >= SYSTEM_RECORDING_AGGREGATE_TTL_SECONDS) { int refreshed_count = get_recording_count( - 0, 0, NULL, 0, NULL, -1, NULL, 0, NULL, NULL); + 0, 0, NULL, 0, NULL, -1, NULL, 0, NULL, NULL, false); int64_t refreshed_bytes = get_stream_storage_bytes(NULL); if (refreshed_count >= 0) recording_aggregate_cache.count = refreshed_count; if (refreshed_bytes >= 0) recording_aggregate_cache.bytes = refreshed_bytes; diff --git a/src/web/api_handlers_timeline.c b/src/web/api_handlers_timeline.c index 58b7f7c8..02a20482 100644 --- a/src/web/api_handlers_timeline.c +++ b/src/web/api_handlers_timeline.c @@ -71,6 +71,15 @@ static int get_timeline_segments_for_identity( * that extends past midnight). * * Also populate has_detection by checking trigger_type or the detections table. + * + * Segments that are still being recorded (is_complete = 0, no end_time + * yet — issue #482) are included so a stream with a long segment + * duration shows its current segment in the timeline immediately. Their + * effective end is "now" (via COALESCE below) and they are only visible + * while their start_time is fresh enough to belong to a live segment, + * which also self-expires incomplete rows left behind by a killed writer + * thread. The LEFT JOIN on streams reads the per-stream segment + * duration; the column is UNIQUE-indexed so it costs one lookup per row. */ const char *stream_sql = "SELECT r.id, r.stream_name, r.camera_uuid, r.file_path, " @@ -84,22 +93,25 @@ static int get_timeline_segments_for_identity( " 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 " + " AND d.timestamp <= COALESCE(NULLIF(r.end_time, 0), CAST(strftime('%s','now') AS INTEGER))) THEN 1 " " WHEN EXISTS (SELECT 1 FROM detections d " " 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 d.timestamp <= COALESCE(NULLIF(r.end_time, 0), CAST(strftime('%s','now') AS INTEGER)) " " 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, r.protected " + "r.trigger_type, r.schedule_restricted, r.protected, r.is_complete " "FROM recordings r " - "WHERE r.is_complete = 1 " - " AND r.end_time IS NOT NULL " + "LEFT JOIN streams s ON s.name = r.stream_name " + "WHERE ((r.is_complete = 1 AND r.end_time IS NOT NULL) " + " OR (r.is_complete = 0 AND COALESCE(r.end_time, 0) = 0 " + " AND r.start_time >= CAST(strftime('%s','now') AS INTEGER)" + " - (2 * COALESCE(NULLIF(s.segment_duration, 0), 900))))" " AND r.stream_name = ? " " AND r.start_time < ? " - " AND r.end_time > ? " + " AND COALESCE(NULLIF(r.end_time, 0), CAST(strftime('%s','now') AS INTEGER)) > ? " "ORDER BY r.start_time ASC " "LIMIT ?;"; @@ -113,20 +125,23 @@ static int get_timeline_segments_for_identity( " 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 " + " AND d.timestamp <= COALESCE(NULLIF(r.end_time, 0), CAST(strftime('%s','now') AS INTEGER))) 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 d.timestamp <= COALESCE(NULLIF(r.end_time, 0), CAST(strftime('%s','now') AS INTEGER)) " " 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, r.protected " + "r.trigger_type, r.schedule_restricted, r.protected, r.is_complete " "FROM recordings r " - "WHERE r.is_complete = 1 " - " AND r.end_time IS NOT NULL " + "LEFT JOIN streams s ON s.name = r.stream_name " + "WHERE ((r.is_complete = 1 AND r.end_time IS NOT NULL) " + " OR (r.is_complete = 0 AND COALESCE(r.end_time, 0) = 0 " + " AND r.start_time >= CAST(strftime('%s','now') AS INTEGER)" + " - (2 * COALESCE(NULLIF(s.segment_duration, 0), 900))))" " AND r.camera_uuid = ? " " AND r.start_time < ? " - " AND r.end_time > ? " + " AND COALESCE(NULLIF(r.end_time, 0), CAST(strftime('%s','now') AS INTEGER)) > ? " "ORDER BY r.start_time ASC " "LIMIT ?;"; @@ -146,6 +161,7 @@ static int get_timeline_segments_for_identity( sqlite3_bind_int64(stmt, 3, (sqlite3_int64)start_time); sqlite3_bind_int(stmt, 4, max_segments); + const time_t live_now = time(NULL); int count = 0; while (sqlite3_step(stmt) == SQLITE_ROW && count < max_segments) { memset(&segments[count], 0, sizeof(segments[count])); @@ -175,6 +191,13 @@ static int get_timeline_segments_for_identity( (sqlite3_column_type(stmt, 9) != SQLITE_NULL) ? (sqlite3_column_int(stmt, 9) != 0) : -1; segments[count].protected = sqlite3_column_int(stmt, 10) != 0; + segments[count].in_progress = sqlite3_column_int(stmt, 11) == 0; + + // A segment that is still recording has no committed end_time; use + // "now" as its effective end for display and hit-testing (#482). + if (segments[count].in_progress && segments[count].end_time <= 0) { + segments[count].end_time = live_now; + } count++; } @@ -242,14 +265,22 @@ int get_timeline_coverage_by_camera_uuid( free(buckets); return -1; } + /* Include the segment that is still being recorded (#482) so the + * coverage bar extends to "now" instead of showing a false gap. See + * get_timeline_segments for the freshness/self-expiry rationale. */ const char *sql = "SELECT r.id, r.start_time, r.end_time, r.size_bytes, " "r.trigger_type = 'detection' OR EXISTS (" " SELECT 1 FROM detections d WHERE d.recording_id = r.id), " - "r.protected " + "r.protected, r.is_complete " "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 > ? " + "LEFT JOIN streams s ON s.name = r.stream_name " + "WHERE ((r.is_complete = 1 AND r.end_time IS NOT NULL) " + " OR (r.is_complete = 0 AND COALESCE(r.end_time, 0) = 0 " + " AND r.start_time >= CAST(strftime('%s','now') AS INTEGER)" + " - (2 * COALESCE(NULLIF(s.segment_duration, 0), 900))))" + " AND r.camera_uuid = ? AND r.start_time < ? " + " AND COALESCE(NULLIF(r.end_time, 0), CAST(strftime('%s','now') AS INTEGER)) > ? " "ORDER BY r.start_time ASC;"; sqlite3_stmt *stmt = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); @@ -264,6 +295,7 @@ int get_timeline_coverage_by_camera_uuid( sqlite3_bind_int64(stmt, 2, (sqlite3_int64)end_time); sqlite3_bind_int64(stmt, 3, (sqlite3_int64)start_time); + const time_t live_now = time(NULL); while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { uint64_t id = (uint64_t)sqlite3_column_int64(stmt, 0); time_t segment_start = (time_t)sqlite3_column_int64(stmt, 1); @@ -271,6 +303,11 @@ int get_timeline_coverage_by_camera_uuid( uint64_t bytes = (uint64_t)sqlite3_column_int64(stmt, 3); bool has_detection = sqlite3_column_int(stmt, 4) != 0; bool is_protected = sqlite3_column_int(stmt, 5) != 0; + bool is_complete = sqlite3_column_int(stmt, 6) != 0; + if (!is_complete && segment_end <= 0) { + // Effective end of a still-recording segment is "now" (#482). + segment_end = live_now; + } (*source_segment_count)++; time_t clipped_start = segment_start < start_time @@ -602,6 +639,7 @@ void handle_get_timeline_segments(const http_request_t *req, http_response_t *re cJSON_AddStringToObject(segment, "capture_method", timeline_segment_capture_method(&segments[i])); cJSON_AddBoolToObject(segment, "has_detection", segments[i].has_detection); + cJSON_AddBoolToObject(segment, "in_progress", segments[i].in_progress); if (segments[i].schedule_restricted < 0) { cJSON_AddNullToObject(segment, "schedule_restricted"); } else { @@ -1010,19 +1048,25 @@ void handle_get_timeline_segments_by_ids(const http_request_t *req, http_respons } if (replay_evaluation.decision != AUTHZ_DECISION_ALLOW) continue; + /* A segment that is still recording has no committed end_time; use + * "now" as its effective end for display and detection checks (#482). */ + const bool in_progress = !rec.is_complete; + const time_t effective_end = + (in_progress && rec.end_time <= 0) ? time(NULL) : rec.end_time; + /* Check for detections */ bool has_det = false; if (rec.trigger_type[0] != '\0' && strcmp(rec.trigger_type, "detection") == 0) { has_det = true; } else if (has_detections_in_time_range(rec.stream_name, rec.start_time, - rec.end_time) > 0) { + effective_end) > 0) { has_det = true; } /* Track overall time range */ if (overall_start == 0 || rec.start_time < overall_start) overall_start = rec.start_time; - if (rec.end_time > overall_end) overall_end = rec.end_time; + if (effective_end > overall_end) overall_end = effective_end; /* Build segment JSON (same format as handle_get_timeline_segments) */ cJSON *segment = cJSON_CreateObject(); @@ -1033,10 +1077,10 @@ void handle_get_timeline_segments_by_ids(const http_request_t *req, http_respons char seg_start[32] = {0}, seg_end[32] = {0}; tm_info = localtime_r(&rec.start_time, &tm_buf); if (tm_info) strftime(seg_start, sizeof(seg_start), "%Y-%m-%d %H:%M:%S", tm_info); - tm_info = localtime_r(&rec.end_time, &tm_buf); + tm_info = localtime_r(&effective_end, &tm_buf); if (tm_info) strftime(seg_end, sizeof(seg_end), "%Y-%m-%d %H:%M:%S", tm_info); - int duration = (int)difftime(rec.end_time, rec.start_time); + int duration = (int)difftime(effective_end, rec.start_time); char size_str[32] = {0}; if (rec.size_bytes < 1024) @@ -1062,6 +1106,7 @@ void handle_get_timeline_segments_by_ids(const http_request_t *req, http_respons cJSON_AddStringToObject(segment, "capture_method", recording_capture_method(&rec)); cJSON_AddBoolToObject(segment, "has_detection", has_det); + cJSON_AddBoolToObject(segment, "in_progress", in_progress); if (rec.schedule_restricted < 0) { cJSON_AddNullToObject(segment, "schedule_restricted"); } else { @@ -1069,9 +1114,9 @@ void handle_get_timeline_segments_by_ids(const http_request_t *req, http_respons rec.schedule_restricted != 0); } cJSON_AddNumberToObject(segment, "start_timestamp", (double)rec.start_time); - cJSON_AddNumberToObject(segment, "end_timestamp", (double)rec.end_time); + cJSON_AddNumberToObject(segment, "end_timestamp", (double)effective_end); cJSON_AddNumberToObject(segment, "local_start_timestamp", (double)rec.start_time); - cJSON_AddNumberToObject(segment, "local_end_timestamp", (double)rec.end_time); + cJSON_AddNumberToObject(segment, "local_end_timestamp", (double)effective_end); cJSON_AddItemToArray(segments_array, segment); seg_count++; diff --git a/tests/unit/test_api_handlers_recordings_list.c b/tests/unit/test_api_handlers_recordings_list.c index 696a686b..13eae520 100644 --- a/tests/unit/test_api_handlers_recordings_list.c +++ b/tests/unit/test_api_handlers_recordings_list.c @@ -331,6 +331,76 @@ void test_handle_get_recordings_serves_cached_total_until_generation_or_ttl_chan recordings_count_cache_reset(); } +/* Issue #482: the overview must list the segment that is still recording, + * flagged as in_progress and with a null end_time, alongside completed + * segments. */ +void test_handle_get_recordings_includes_still_recording_segment(void) { + g_config.web_auth_enabled = false; + recordings_count_cache_reset(); + create_stream("inprog_cam"); + time_t now = time(NULL); + + /* Exactly what the recorder inserts at a segment's first keyframe. */ + recording_metadata_t live; + memset(&live, 0, sizeof(live)); + safe_strcpy(live.stream_name, "inprog_cam", sizeof(live.stream_name), 0); + safe_strcpy(live.file_path, "/tmp/inprog-live.mp4", sizeof(live.file_path), 0); + safe_strcpy(live.codec, "h264", sizeof(live.codec), 0); + safe_strcpy(live.trigger_type, "scheduled", sizeof(live.trigger_type), 0); + live.start_time = now - 15; + live.end_time = 0; + live.is_complete = false; + live.schedule_restricted = 1; + live.disk_pressure_eligible = true; + TEST_ASSERT_NOT_EQUAL(0, add_recording_metadata(&live)); + create_recording("inprog_cam", "/tmp/inprog-done.mp4", now - 300); + + http_request_t req; + http_request_init(&req); + req.method = HTTP_METHOD_GET; + safe_strcpy(req.path, "/api/recordings", sizeof(req.path), 0); + safe_strcpy(req.query_string, "stream=inprog_cam&limit=10&sort=start_time&order=desc", + sizeof(req.query_string), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + + http_response_t res; + http_response_init(&res); + handle_get_recordings(&req, &res); + TEST_ASSERT_EQUAL_INT(200, res.status_code); + TEST_ASSERT_NOT_NULL(res.body); + + cJSON *root = cJSON_Parse((const char *)res.body); + TEST_ASSERT_NOT_NULL(root); + cJSON *total = cJSON_GetObjectItem(cJSON_GetObjectItem(root, "pagination"), "total"); + TEST_ASSERT_TRUE(cJSON_IsNumber(total)); + TEST_ASSERT_EQUAL_INT(2, total->valueint); + + cJSON *items = cJSON_GetObjectItem(root, "recordings"); + TEST_ASSERT_TRUE(cJSON_IsArray(items)); + TEST_ASSERT_EQUAL_INT(2, cJSON_GetArraySize(items)); + + /* Newest first: the still-recording segment. */ + cJSON *first = cJSON_GetArrayItem(items, 0); + TEST_ASSERT_NOT_NULL(first); + TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(first, "in_progress"))); + TEST_ASSERT_TRUE(cJSON_IsNull(cJSON_GetObjectItem(first, "end_time"))); + TEST_ASSERT_TRUE(cJSON_IsNull(cJSON_GetObjectItem(first, "end_time_unix"))); + cJSON *live_duration = cJSON_GetObjectItem(first, "duration"); + TEST_ASSERT_TRUE(cJSON_IsNumber(live_duration)); + TEST_ASSERT_GREATER_THAN(0, live_duration->valueint); + TEST_ASSERT_EQUAL_STRING("/tmp/inprog-live.mp4", + cJSON_GetObjectItem(first, "file_path")->valuestring); + + cJSON *second = cJSON_GetArrayItem(items, 1); + TEST_ASSERT_NOT_NULL(second); + TEST_ASSERT_TRUE(cJSON_IsFalse(cJSON_GetObjectItem(second, "in_progress"))); + TEST_ASSERT_TRUE(cJSON_IsString(cJSON_GetObjectItem(second, "end_time"))); + + cJSON_Delete(root); + http_response_free(&res); + recordings_count_cache_reset(); +} + int main(void) { unlink(TEST_DB_PATH); if (init_database(TEST_DB_PATH) != 0) { @@ -343,6 +413,7 @@ int main(void) { RUN_TEST(test_count_cache_invalidates_on_generation_change); RUN_TEST(test_count_cache_skips_errors_bounds_entries_and_resets); RUN_TEST(test_handle_get_recordings_serves_cached_total_until_generation_or_ttl_changes); + RUN_TEST(test_handle_get_recordings_includes_still_recording_segment); int result = UNITY_END(); shutdown_database(); unlink(TEST_DB_PATH); diff --git a/tests/unit/test_api_handlers_recordings_playback.c b/tests/unit/test_api_handlers_recordings_playback.c index 1407be39..364d60c1 100644 --- a/tests/unit/test_api_handlers_recordings_playback.c +++ b/tests/unit/test_api_handlers_recordings_playback.c @@ -221,6 +221,20 @@ void test_source_only_preparation_never_probes_or_transcodes_hevc(void) { TEST_ASSERT_EQUAL_INT(0, serve_calls); } +/* A segment that is still being recorded cannot be played yet: the MP4 + * index (moov) is only written when the segment is finalized, so playback + * must fail fast with a clear HTTP 409 instead of serving an undecodable + * partial file (#482). */ +void test_in_progress_recording_is_rejected_with_conflict(void) { + bool saved_is_complete = recording.is_complete; + recording.is_complete = false; + handle_recordings_playback(&request, &response); + recording.is_complete = saved_is_complete; + TEST_ASSERT_EQUAL_INT(409, response.status_code); + TEST_ASSERT_EQUAL_INT(0, serve_calls); + TEST_ASSERT_EQUAL_INT(0, prepare_calls); +} + int main(void) { if (!mkdtemp(directory)) return 1; strcpy(g_config.storage_path, directory); @@ -229,6 +243,8 @@ int main(void) { if (!file) return 1; fputs("source video", file); fclose(file); + /* The fixtures below all represent completed, playable recordings. */ + recording.is_complete = true; UNITY_BEGIN(); RUN_TEST(test_hevc_preparation_returns_retryable_json_without_serving_media); RUN_TEST(test_uncached_media_request_returns_prompt_retry_instead_of_waiting); @@ -241,6 +257,7 @@ int main(void) { RUN_TEST(test_archive_preparation_returns_retry_without_starting_transcode); RUN_TEST(test_archive_media_preparation_failure_and_deletion_are_not_served); RUN_TEST(test_source_only_preparation_never_probes_or_transcodes_hevc); + RUN_TEST(test_in_progress_recording_is_rejected_with_conflict); int result = UNITY_END(); unlink(recording.file_path); rmdir(directory); diff --git a/tests/unit/test_db_recordings_extended.c b/tests/unit/test_db_recordings_extended.c index aa6c14b8..603455ac 100644 --- a/tests/unit/test_db_recordings_extended.c +++ b/tests/unit/test_db_recordings_extended.c @@ -20,6 +20,7 @@ #include "database/db_recordings.h" #include "video/detection_result.h" #include "utils/strings.h" +#include "web/api_handlers_timeline.h" #define TEST_DB_PATH "/tmp/lightnvr_unit_recordings_ext_test.db" @@ -162,7 +163,7 @@ void test_get_recording_count(void) { time_t now = time(NULL); recording_metadata_t m = make_rec("cam1", "/rec/cnt.mp4", now); add_recording_metadata(&m); - int cnt = get_recording_count(0, 0, "cam1", 0, NULL, -1, NULL, 0, NULL, NULL); + int cnt = get_recording_count(0, 0, "cam1", 0, NULL, -1, NULL, 0, NULL, NULL, false); TEST_ASSERT_EQUAL_INT(1, cnt); } @@ -184,7 +185,7 @@ void test_get_recording_count_supports_multi_value_stream_tag_and_capture_filter TEST_ASSERT_EQUAL_INT(0, db_recording_tag_add(manual_id, "urgent")); int cnt = get_recording_count(0, 0, "cam1,cam3", 0, NULL, -1, NULL, 0, - "important,urgent", "scheduled,manual"); + "important,urgent", "scheduled,manual", false); TEST_ASSERT_EQUAL_INT(2, cnt); } @@ -275,15 +276,15 @@ void test_capture_filters_distinguish_continuous_from_scheduled(void) { TEST_ASSERT_EQUAL_INT(1, get_recording_count(0, 0, NULL, 0, NULL, -1, NULL, 0, - NULL, "continuous")); + NULL, "continuous", false)); TEST_ASSERT_EQUAL_INT(1, get_recording_count(0, 0, NULL, 0, NULL, -1, NULL, 0, - NULL, "scheduled")); + NULL, "scheduled", false)); recording_metadata_t out[2]; int count = get_recording_metadata_paginated( 0, 0, NULL, 0, NULL, -1, "id", "asc", out, 2, 0, - NULL, 0, NULL, "continuous"); + NULL, 0, NULL, "continuous", false); TEST_ASSERT_EQUAL_INT(1, count); TEST_ASSERT_EQUAL_STRING("always_on", out[0].stream_name); } @@ -300,7 +301,7 @@ void test_get_recording_metadata_paginated(void) { recording_metadata_t out[10]; int n = get_recording_metadata_paginated(0, 0, "cam1", 0, NULL, -1, "start_time", "desc", out, 3, 0, - NULL, 0, NULL, NULL); + NULL, 0, NULL, NULL, false); TEST_ASSERT_EQUAL_INT(3, n); } @@ -329,7 +330,7 @@ void test_get_recording_metadata_paginated_supports_multi_value_detection_labels recording_metadata_t out[10]; int n = get_recording_metadata_paginated(0, 0, NULL, 1, "person,car", -1, "id", "asc", out, 10, 0, - NULL, 0, "alpha,beta", "scheduled"); + NULL, 0, "alpha,beta", "scheduled", false); TEST_ASSERT_EQUAL_INT(2, n); TEST_ASSERT_EQUAL_STRING("cam1", out[0].stream_name); @@ -357,14 +358,14 @@ void test_recording_filters_match_unlinked_points_and_spanning_intervals(void) { "cam_interval", now - 10)); TEST_ASSERT_EQUAL_INT(1, get_recording_count( - 0, 0, "cam_point", 1, "person", -1, NULL, 0, NULL, NULL)); + 0, 0, "cam_point", 1, "person", -1, NULL, 0, NULL, NULL, false)); TEST_ASSERT_EQUAL_INT(1, get_recording_count( - 0, 0, "cam_interval", 1, "vehicle", -1, NULL, 0, NULL, NULL)); + 0, 0, "cam_interval", 1, "vehicle", -1, NULL, 0, NULL, NULL, false)); recording_metadata_t out[2]; int count = get_recording_metadata_paginated( 0, 0, "cam_interval", 1, "vehicle", -1, - "start_time", "desc", out, 2, 0, NULL, 0, NULL, NULL); + "start_time", "desc", out, 2, 0, NULL, 0, NULL, NULL, false); TEST_ASSERT_EQUAL_INT(1, count); TEST_ASSERT_EQUAL_STRING("cam_interval", out[0].stream_name); } @@ -405,12 +406,12 @@ void test_readonly_list_reads_see_rows_committed_on_shared_handle(void) { raw_insert_recording("wal_cam", "/rec/wal-open.mp4", now + 100, 0); TEST_ASSERT_EQUAL_INT(4, get_recording_count(0, 0, "wal_cam", 0, NULL, -1, - NULL, 0, NULL, NULL)); + NULL, 0, NULL, NULL, false)); recording_metadata_t out[8]; int n = get_recording_metadata_paginated(0, 0, "wal_cam", 0, NULL, -1, "start_time", "desc", out, 8, 0, - NULL, 0, NULL, NULL); + NULL, 0, NULL, NULL, false); TEST_ASSERT_EQUAL_INT(4, n); TEST_ASSERT_EQUAL_STRING("/rec/wal-raw.mp4", out[0].file_path); TEST_ASSERT_EQUAL_STRING("/rec/wal-api-0.mp4", out[1].file_path); @@ -450,23 +451,23 @@ void test_recording_count_matches_paginated_rows_for_combined_filters(void) { time_t window_start = now - 480; time_t window_end = now - 100; int count = get_recording_count(window_start, window_end, "cam_a,cam_b", 0, - NULL, 0, scope, 2, NULL, NULL); + NULL, 0, scope, 2, NULL, NULL, false); recording_metadata_t out[16]; int rows = get_recording_metadata_paginated(window_start, window_end, "cam_a,cam_b", 0, NULL, 0, "start_time", "asc", out, 16, 0, - scope, 2, NULL, NULL); + scope, 2, NULL, NULL, false); TEST_ASSERT_EQUAL_INT(1, count); TEST_ASSERT_EQUAL_INT(count, rows); TEST_ASSERT_EQUAL_STRING("/rec/f-a0.mp4", out[0].file_path); /* No scope: the explicit predicate alone. */ count = get_recording_count(window_start, window_end, "cam_a,cam_b", 0, - NULL, -1, NULL, 0, NULL, NULL); + NULL, -1, NULL, 0, NULL, NULL, false); rows = get_recording_metadata_paginated(window_start, window_end, "cam_a,cam_b", 0, NULL, -1, "start_time", "asc", out, 16, 0, - NULL, 0, NULL, NULL); + NULL, 0, NULL, NULL, false); TEST_ASSERT_EQUAL_INT(3, count); TEST_ASSERT_EQUAL_INT(count, rows); } @@ -486,11 +487,11 @@ void test_readonly_list_reads_do_not_block_on_open_write_transaction(void) { time_t started = time(NULL); TEST_ASSERT_EQUAL_INT(1, get_recording_count(0, 0, "txn_cam", 0, NULL, -1, - NULL, 0, NULL, NULL)); + NULL, 0, NULL, NULL, false)); recording_metadata_t out[4]; TEST_ASSERT_EQUAL_INT(1, get_recording_metadata_paginated( 0, 0, "txn_cam", 0, NULL, -1, "start_time", "desc", out, 4, 0, - NULL, 0, NULL, NULL)); + NULL, 0, NULL, NULL, false)); TEST_ASSERT_EQUAL_STRING("/rec/txn-1.mp4", out[0].file_path); /* busy_timeout on the read-only connection is 10 s; a reader that had * queued behind the writer would take at least that long. */ @@ -498,7 +499,7 @@ void test_readonly_list_reads_do_not_block_on_open_write_transaction(void) { TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL)); TEST_ASSERT_EQUAL_INT(1, get_recording_count(0, 0, "txn_cam", 0, NULL, -1, - NULL, 0, NULL, NULL)); + NULL, 0, NULL, NULL, false)); } /* The generation feeds the list handler's total cache: every mutation that @@ -534,7 +535,7 @@ void test_recordings_generation_bumps_only_on_list_affecting_mutations(void) { recording_metadata_t got; TEST_ASSERT_EQUAL_INT(0, get_recording_metadata_by_id(id, &got)); TEST_ASSERT_EQUAL_INT(1, get_recording_count(0, 0, "gen_cam", 0, NULL, -1, - NULL, 0, NULL, NULL)); + NULL, 0, NULL, NULL, false)); TEST_ASSERT_EQUAL_UINT64(after_start, db_recordings_generation()); } @@ -650,6 +651,149 @@ void test_recent_recording_storage_stats_excludes_stale_archive(void) { TEST_ASSERT_EQUAL_INT64(recent2.end_time, newest); } +/* Insert/replace a streams config row (only the columns the JOIN reads). + * camera_uuid carries a UNIQUE index, so every stream needs a distinct one. */ +static void create_stream_row(const char *name, int segment_duration) { + sqlite3_stmt *stmt = NULL; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_prepare_v2( + get_db_handle(), + "INSERT OR REPLACE INTO streams " + "(name, url, enabled, streaming_enabled, width, height, fps, codec, " + " priority, record, segment_duration, camera_uuid) " + "VALUES (?, 'rtsp://test.stream/live', 1, 1, 1280, 720, 25, 'h264', " + " 5, 1, ?, ? || '-uuid');", + -1, &stmt, NULL)); + sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC); + sqlite3_bind_int(stmt, 2, segment_duration); + sqlite3_bind_text(stmt, 3, name, -1, SQLITE_STATIC); + TEST_ASSERT_EQUAL_INT(SQLITE_DONE, sqlite3_step(stmt)); + sqlite3_finalize(stmt); +} + +/* Issue #482: a segment that is still recording (is_complete = 0, no + * end_time yet) must only be returned when the caller explicitly opts in; + * completed segments are unaffected either way. */ +void test_in_progress_segment_visible_only_with_opt_in(void) { + time_t now = time(NULL); + + /* The currently-recording segment, exactly as the recorder inserts it at + * the first keyframe of the segment (end_time NULL, size 0). */ + recording_metadata_t live = make_rec("prg_cam", "/rec/prg-live.mp4", now - 30); + live.end_time = 0; + live.is_complete = false; + live.size_bytes = 0; + TEST_ASSERT_TRUE(add_recording_metadata(&live) > 0); + + /* Its already-finished predecessor. */ + recording_metadata_t done = make_rec("prg_cam", "/rec/prg-done.mp4", + now - 10 * 60); + TEST_ASSERT_TRUE(add_recording_metadata(&done) > 0); + + create_stream_row("prg_cam", 900); + + /* Callers that did not opt in must not see the open segment. */ + TEST_ASSERT_EQUAL_INT(1, get_recording_count(0, 0, "prg_cam", 0, NULL, -1, + NULL, 0, NULL, NULL, false)); + /* The overview/timeline readers opt in and now see both. */ + TEST_ASSERT_EQUAL_INT(2, get_recording_count(0, 0, "prg_cam", 0, NULL, -1, + NULL, 0, NULL, NULL, true)); + + recording_metadata_t out[4]; + int n = get_recording_metadata_paginated(0, 0, "prg_cam", 0, NULL, -1, + "start_time", "desc", out, 4, 0, + NULL, 0, NULL, NULL, true); + TEST_ASSERT_EQUAL_INT(2, n); + TEST_ASSERT_EQUAL_STRING("/rec/prg-live.mp4", out[0].file_path); + TEST_ASSERT_FALSE(out[0].is_complete); + TEST_ASSERT_EQUAL_INT(0, out[0].end_time); + + n = get_recording_metadata_paginated(0, 0, "prg_cam", 0, NULL, -1, + "start_time", "desc", out, 4, 0, + NULL, 0, NULL, NULL, false); + TEST_ASSERT_EQUAL_INT(1, n); + TEST_ASSERT_EQUAL_STRING("/rec/prg-done.mp4", out[0].file_path); + TEST_ASSERT_TRUE(out[0].is_complete); +} + +/* Issue #482: the in-progress view trusts a row only while its start_time is + * fresh relative to the stream's segment_duration (2x). A zombie row left + * behind by a killed writer thread self-expires from the view, while the + * user's real use case — a segment that legitimately runs for an hour or + * more — stays visible for its whole lifetime. */ +void test_in_progress_stale_segments_self_expire(void) { + time_t now = time(NULL); + + create_stream_row("zmb_short_cam", 30); /* freshness window: 1 min */ + create_stream_row("zmb_long_cam", 3600); /* freshness window: 2 h */ + + /* Zombie: incomplete and far older than the stream's short segments. */ + recording_metadata_t zombie = make_rec("zmb_short_cam", "/rec/zmb-zombie.mp4", + now - 3600); + zombie.end_time = 0; + zombie.is_complete = false; + TEST_ASSERT_TRUE(add_recording_metadata(&zombie) > 0); + + /* Fresh live segment on the same short-duration stream. */ + recording_metadata_t fresh = make_rec("zmb_short_cam", "/rec/zmb-live.mp4", + now - 29); + fresh.end_time = 0; + fresh.is_complete = false; + TEST_ASSERT_TRUE(add_recording_metadata(&fresh) > 0); + + /* Long-duration stream (#482 use case): a segment started an hour ago is + * exactly what the user wants to see. */ + recording_metadata_t longlive = make_rec("zmb_long_cam", "/rec/zmb-long.mp4", + now - 3500); + longlive.end_time = 0; + longlive.is_complete = false; + TEST_ASSERT_TRUE(add_recording_metadata(&longlive) > 0); + + TEST_ASSERT_EQUAL_INT(1, get_recording_count(0, 0, "zmb_short_cam", 0, NULL, + -1, NULL, 0, NULL, NULL, true)); + TEST_ASSERT_EQUAL_INT(1, get_recording_count(0, 0, "zmb_long_cam", 0, NULL, + -1, NULL, 0, NULL, NULL, true)); + + /* A stream whose config row does not exist falls back to the schema + * default 900 s (window 1800 s). */ + recording_metadata_t ghost = make_rec("zmb_ghost_cam", "/rec/zmb-ghost.mp4", + now - 3000); + ghost.end_time = 0; + ghost.is_complete = false; + TEST_ASSERT_TRUE(add_recording_metadata(&ghost) > 0); + TEST_ASSERT_EQUAL_INT(0, get_recording_count(0, 0, "zmb_ghost_cam", 0, NULL, + -1, NULL, 0, NULL, NULL, true)); +} + +/* Issue #482: the timeline must surface the segment that is still recording + * as an in-progress entry whose effective end is "now", right next to the + * completed segments before it. */ +void test_timeline_segments_include_still_recording_segment(void) { + time_t now = time(NULL); + create_stream_row("tl_cam", 60); + + recording_metadata_t done = make_rec("tl_cam", "/rec/tl-done.mp4", now - 300); + TEST_ASSERT_TRUE(add_recording_metadata(&done) > 0); + + recording_metadata_t live = make_rec("tl_cam", "/rec/tl-live.mp4", now - 20); + live.end_time = 0; + live.is_complete = false; + live.size_bytes = 0; + TEST_ASSERT_TRUE(add_recording_metadata(&live) > 0); + + timeline_segment_t segs[4]; + int n = get_timeline_segments("tl_cam", now - 600, now + 60, segs, 4); + TEST_ASSERT_EQUAL_INT(2, n); + + /* Ordered by start_time: the finished segment first. */ + TEST_ASSERT_EQUAL_STRING("/rec/tl-done.mp4", segs[0].file_path); + TEST_ASSERT_FALSE(segs[0].in_progress); + + TEST_ASSERT_EQUAL_STRING("/rec/tl-live.mp4", segs[1].file_path); + TEST_ASSERT_TRUE(segs[1].in_progress); + /* The effective end of the live segment tracks "now". */ + TEST_ASSERT_GREATER_OR_EQUAL(now - 2, segs[1].end_time); +} + int main(void) { unlink(TEST_DB_PATH); if (init_database(TEST_DB_PATH) != 0) { @@ -683,6 +827,9 @@ int main(void) { RUN_TEST(test_set_recording_retention_override); RUN_TEST(test_get_stream_storage_bytes); RUN_TEST(test_recent_recording_storage_stats_excludes_stale_archive); + RUN_TEST(test_in_progress_segment_visible_only_with_opt_in); + RUN_TEST(test_in_progress_stale_segments_self_expire); + RUN_TEST(test_timeline_segments_include_still_recording_segment); int result = UNITY_END(); shutdown_database(); unlink(TEST_DB_PATH); diff --git a/web/css/timeline.css b/web/css/timeline.css index cd838618..a0f80a69 100644 --- a/web/css/timeline.css +++ b/web/css/timeline.css @@ -88,6 +88,33 @@ 0 1px 6px rgba(0, 0, 0, 0.25); } +/* Segment that is still being recorded (#482): rendered with the same + * primary colour as a completed segment but pulsing so a live recording is + * distinguishable (and not mistaken for a gap or an already-finished file). + * Motion is suppressed for users who prefer reduced motion. */ +@keyframes timeline-rec-pulse { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +.timeline-segment.in-progress { + animation: timeline-rec-pulse 1.6s ease-in-out infinite; + outline: 1.5px dashed hsl(var(--background) / 0.65); + outline-offset: -1.5px; +} + +.timeline-legend-in-progress { + background-color: hsl(var(--primary)); + animation: timeline-rec-pulse 1.6s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .timeline-segment.in-progress, + .timeline-legend-in-progress { + animation: none; + } +} + /* Exact start/stop interval for externally-triggered motion. This sits over * the blue recording bar, avoiding the misleading full-segment highlight * when segment duration is much longer than the event (#483). */ diff --git a/web/js/components/preact/RecordingsView.jsx b/web/js/components/preact/RecordingsView.jsx index 5bbd9302..b7fe1362 100644 --- a/web/js/components/preact/RecordingsView.jsx +++ b/web/js/components/preact/RecordingsView.jsx @@ -944,6 +944,14 @@ export function RecordingsView() { const playRecording = (recording) => { console.log('RecordingsView.playRecording called with:', recording); + // A segment that is still recording has no finalized MP4 index yet, so + // playback cannot work (the backend rejects it with HTTP 409). Tell the + // user instead of opening a broken video modal (#482). + if (recording && recording.in_progress) { + showStatusMessage(t('recordings.inProgressToast'), 'info'); + return; + } + // Use the modal context if available, otherwise fall back to the imported function if (modalContext && modalContext.showVideoModal) { console.log('Using modal context showVideoModal'); diff --git a/web/js/components/preact/recordings/RecordingsGrid.jsx b/web/js/components/preact/recordings/RecordingsGrid.jsx index 4c9d8e42..9ed7071b 100644 --- a/web/js/components/preact/recordings/RecordingsGrid.jsx +++ b/web/js/components/preact/recordings/RecordingsGrid.jsx @@ -120,15 +120,21 @@ function RecordingCard({ const imgErrorCountRef = useRef(0); const hoverTimerRef = useRef(null); // debounce timer for hover + // A still-recording segment shows a live placeholder instead of a thumbnail: + // the MP4 index is not finalized until the segment completes, so the + // thumbnail extractor cannot serve it yet (#482). + const inProgress = !!recording.in_progress; + /** Load (or reload) the mount-time (first-frame) thumbnail. */ const loadThumbnail = useCallback(() => { + if (inProgress) return; const url = `/api/recordings/thumbnail/${recording.id}/0`; setLoadState('loading'); imgErrorCountRef.current = 0; queueThumbnailLoad(url, Priority.HIGH) .then(() => setLoadState('loaded')) .catch(() => setLoadState('error')); - }, [recording.id]); + }, [inProgress, recording.id]); // Handle element load errors — if the decoded image was evicted // from the browser cache between queueThumbnailLoad success and render, @@ -172,7 +178,7 @@ function RecordingCard({ // request queue and cause a stampede). // Skipped entirely when hoverFramesEnabled is false (#364 single-thumb mode). useEffect(() => { - if (!hoverFramesEnabled) return; + if (!hoverFramesEnabled || inProgress) return; if (isHovering && !preloadedRef.current) { preloadedRef.current = true; const promises = [1, 2].map(i => { @@ -228,8 +234,19 @@ function RecordingCard({
playRecording(recording)} + title={inProgress ? t('recordings.inProgressToast') : undefined} > - {loadState === 'loaded' ? ( + {inProgress ? ( +
+
+ ) : loadState === 'loaded' ? ( {`${recording.stream} )} - {/* Duration badge */} + {/* Duration badge — live elapsed time for a still-recording segment (#482) */} {show('duration') && ( -
- {formatUtils.formatDuration(recording.duration)} -
+ inProgress ? ( +
+
+ ) : ( +
+ {formatUtils.formatDuration(recording.duration)} +
+ ) )} {/* Selection checkbox — only visible in selection mode */} @@ -298,9 +322,18 @@ function RecordingCard({
)} + {/* In-progress badge (#482) */} + {inProgress && ( +
+
+ )} + {/* Protected badge */} {show('protected') && recording.protected && ( -
+
diff --git a/web/js/components/preact/recordings/RecordingsTable.jsx b/web/js/components/preact/recordings/RecordingsTable.jsx index 88c0d910..87d081b2 100644 --- a/web/js/components/preact/recordings/RecordingsTable.jsx +++ b/web/js/components/preact/recordings/RecordingsTable.jsx @@ -364,7 +364,17 @@ export function RecordingsTable({ )} {formatUtils.formatDateTime(recording.start_time_unix ?? recording.start_time)} {isColumnVisible('duration') && ( - {formatUtils.formatDuration(recording.duration)} + + {recording.in_progress ? ( + + + ) : ( + formatUtils.formatDuration(recording.duration) + )} + )} {isColumnVisible('size') && ( {recording.size || t('common.unknown')} diff --git a/web/js/components/preact/recordings/formatUtils.js b/web/js/components/preact/recordings/formatUtils.js index f59aff5b..f37c10a8 100644 --- a/web/js/components/preact/recordings/formatUtils.js +++ b/web/js/components/preact/recordings/formatUtils.js @@ -87,17 +87,33 @@ export const formatUtils = { */ formatDuration: (seconds) => { if (seconds == null || seconds < 0) return '00:00:00'; - + const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); - + return [ hours.toString().padStart(2, '0'), minutes.toString().padStart(2, '0'), secs.toString().padStart(2, '0') ].join(':'); }, + + /** + * Elapsed seconds of a still-recording segment (#482), computed at call + * time so the badge ticks up on every re-render/poll. Returns 0 for + * recordings that are already finished. + * @param {Object} recording Recording entry from /api/recordings + * @returns {number} Elapsed seconds since start_time + */ + liveRecordingDuration: (recording) => { + if (!recording || !recording.in_progress) return 0; + const start = typeof recording.start_time_unix === 'number' + ? recording.start_time_unix + : NaN; + if (!Number.isFinite(start)) return 0; + return Math.max(0, Math.floor(Date.now() / 1000) - start); + }, /** * Build a timeline URL for a recording, converting the UTC start_time to local date/time. diff --git a/web/js/components/preact/timeline/TimelinePage.jsx b/web/js/components/preact/timeline/TimelinePage.jsx index f9f033cf..87661885 100644 --- a/web/js/components/preact/timeline/TimelinePage.jsx +++ b/web/js/components/preact/timeline/TimelinePage.jsx @@ -1447,6 +1447,12 @@ export function TimelinePage() { /> {t('timeline.legend.recording')} + + + {t('timeline.legend.inProgress')} + ); }); diff --git a/web/public/locales/en.json b/web/public/locales/en.json index bb227044..8c36af1f 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -1095,6 +1095,8 @@ "recordings.enterSelectionMode": "Enter selection mode", "recordings.exitSelectionMode": "Exit selection mode", "recordings.play": "Play", + "recordings.inProgressBadge": "Recording", + "recordings.inProgressToast": "This segment is still being recorded and will be playable when it finishes.", "timeline.hideHelp": "Hide help", "timeline.howToUseTimeline": "How to use the timeline", "timeline.help.selectStreamAndDate": "Select a stream and date — recordings load automatically", @@ -1114,7 +1116,9 @@ "timeline.inlineHint": "Click video for 1s arrow-key seeking · Click timeline for recording-to-recording arrows · Shift+wheel pan · Ctrl/Cmd+wheel zoom", "timeline.legend.recording": "Recording", "timeline.legend.detection": "Detection event", + "timeline.legend.inProgress": "Recording now", "timeline.detectionEvent": "Detection event", + "timeline.recordingInProgress": "Recording in progress", "timeline.selectedRecordingsTimeline": "Selected Recordings Timeline", "timeline.timelinePlayback": "Timeline Playback", "timeline.recordingsCount": "{count} recordings", From a3364515e085575f11b9c42982d371847e3bf10e Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 17 Oct 2026 20:41:20 -0400 Subject: [PATCH 2/2] Fix live recording deletion and timeline refresh --- src/storage/storage_deletion.c | 8 ++++++-- ...api_handlers_recordings_backend_agnostic.c | 2 +- .../api_handlers_recordings_batch_download.c | 5 +++++ src/web/api_handlers_recordings_download.c | 5 +++++ tests/unit/test_storage_deletion_ledger.c | 14 +++++++++++++ web/js/components/preact/RecordingsView.jsx | 4 ++++ .../preact/recordings/recordingsAPI.jsx | 4 +++- .../preact/timeline/TimelinePage.jsx | 3 ++- .../preact/timeline/TimelinePlayer.jsx | 20 ++++++++++++++++--- .../preact/timeline/timelineUtils.js | 11 ++++++++-- web/tests/timeline-utils.spec.js | 9 +++++++++ 11 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/storage/storage_deletion.c b/src/storage/storage_deletion.c index 4f472f04..889c8d48 100644 --- a/src/storage/storage_deletion.c +++ b/src/storage/storage_deletion.c @@ -372,13 +372,17 @@ static int recording_delete(uint64_t id, const char *reason, uint64_t *removed, } sqlite3_stmt *statement = NULL; int result = sqlite3_prepare_v2(db, "SELECT protected,EXISTS(SELECT 1 FROM storage_migration_jobs " - "WHERE recording_id=?1 AND state IN('copying','verifying','committing')),deletion_pending FROM recordings WHERE id=?1;", + "WHERE recording_id=?1 AND state IN('copying','verifying','committing')),deletion_pending,is_complete " + "FROM recordings WHERE id=?1;", -1, &statement, NULL); if (result == SQLITE_OK) { sqlite3_bind_int64(statement, 1, (sqlite3_int64)id); result = sqlite3_step(statement); } - bool blocked = result == SQLITE_ROW && (sqlite3_column_int(statement, 0) || sqlite3_column_int(statement, 1)); + /* Check inside the deletion transaction: an ID selected from the live + * recordings list must never remove a file the recorder is writing. */ + bool blocked = result == SQLITE_ROW && (sqlite3_column_int(statement, 0) || + sqlite3_column_int(statement, 1) || !sqlite3_column_int(statement, 3)); bool pending = result == SQLITE_ROW && sqlite3_column_int(statement, 2); if (statement) sqlite3_finalize(statement); if (blocked || pending || result != SQLITE_ROW) { diff --git a/src/web/api_handlers_recordings_backend_agnostic.c b/src/web/api_handlers_recordings_backend_agnostic.c index 06ce1418..263f6ef3 100644 --- a/src/web/api_handlers_recordings_backend_agnostic.c +++ b/src/web/api_handlers_recordings_backend_agnostic.c @@ -288,7 +288,7 @@ void handle_delete_recording(const http_request_t *req, http_response_t *res) { if (deletion < 0) { audit_recording_delete_operation(req, &user, &camera, id, "error", "deletion_blocked", "unchanged"); http_response_set_json_error(res, deletion == -2 ? 409 : 500, - deletion == -2 ? "Release protection or wait for the active transfer before deleting" : "Failed to queue recording deletion"); + deletion == -2 ? "Recording is protected, still recording, or has an active transfer" : "Failed to queue recording deletion"); return; } const char *file_cleanup = deletion == 0 ? "deleted" : "pending"; diff --git a/src/web/api_handlers_recordings_batch_download.c b/src/web/api_handlers_recordings_batch_download.c index fa27c05b..5380e119 100644 --- a/src/web/api_handlers_recordings_batch_download.c +++ b/src/web/api_handlers_recordings_batch_download.c @@ -536,6 +536,11 @@ void handle_batch_download_recordings(const http_request_t *req, http_response_t http_response_set_json_error(res, 403, "Forbidden"); return; } + if (!recording.is_complete) { + cJSON_Delete(json); + http_response_set_json_error(res, 409, "Recording is still in progress"); + return; + } } pthread_mutex_lock(&s_dl_mutex); diff --git a/src/web/api_handlers_recordings_download.c b/src/web/api_handlers_recordings_download.c index e7e09e21..e58fc40e 100644 --- a/src/web/api_handlers_recordings_download.c +++ b/src/web/api_handlers_recordings_download.c @@ -96,6 +96,11 @@ void handle_recordings_download(const http_request_t *req, http_response_t *res) return; } + if (!recording.is_complete) { + http_response_set_json_error(res, 409, "Recording is still in progress"); + return; + } + if (recording_archive_serve(req, res, id, true)) { audit_recording_export(req, &user, &camera, id, res->status_code >= 400 ? "failure" : "success", "archive_export_requested", 0, NULL); diff --git a/tests/unit/test_storage_deletion_ledger.c b/tests/unit/test_storage_deletion_ledger.c index b0036098..d93e9e77 100644 --- a/tests/unit/test_storage_deletion_ledger.c +++ b/tests/unit/test_storage_deletion_ledger.c @@ -151,6 +151,19 @@ static void test_missing_derivatives_create_one_object_row(void) { TEST_ASSERT_NOT_EQUAL_INT(0, access(path, F_OK)); } +static void test_live_recording_cannot_be_deleted_by_id(void) { + char path[MAX_PATH_LENGTH], statement[256]; + uint64_t id = add_recording("live-delete", path); + snprintf(statement, sizeof(statement), + "UPDATE recordings SET is_complete=0,end_time=NULL WHERE id=%llu;", + (unsigned long long)id); + sql(statement); + TEST_ASSERT_EQUAL_INT(-2, storage_recording_delete(id, "manual deletion", NULL)); + TEST_ASSERT_EQUAL_INT(0, scalar("SELECT count(*) FROM storage_deletions;")); + TEST_ASSERT_EQUAL_INT(1, scalar("SELECT count(*) FROM recordings;")); + TEST_ASSERT_EQUAL_INT(0, access(path, F_OK)); +} + static void test_existing_derivatives_are_journaled_and_removed(void) { char path[MAX_PATH_LENGTH], thumbnail[MAX_PATH_LENGTH], transcode[MAX_PATH_LENGTH]; uint64_t id = add_recording("derived", path); @@ -285,6 +298,7 @@ int main(void) { } UNITY_BEGIN(); RUN_TEST(test_missing_derivatives_create_one_object_row); + RUN_TEST(test_live_recording_cannot_be_deleted_by_id); RUN_TEST(test_existing_derivatives_are_journaled_and_removed); RUN_TEST(test_deletion_finalizes_only_its_own_ledger_entry); RUN_TEST(test_prune_completed_removes_old_rows_only); diff --git a/web/js/components/preact/RecordingsView.jsx b/web/js/components/preact/RecordingsView.jsx index b7fe1362..27b05eb5 100644 --- a/web/js/components/preact/RecordingsView.jsx +++ b/web/js/components/preact/RecordingsView.jsx @@ -970,6 +970,10 @@ export function RecordingsView() { // Download recording const downloadRecording = (recording) => { + if (recording?.in_progress) { + showStatusMessage(t('recordings.inProgressToast'), 'info'); + return; + } recordingsAPI.downloadRecording(recording); }; diff --git a/web/js/components/preact/recordings/recordingsAPI.jsx b/web/js/components/preact/recordings/recordingsAPI.jsx index 15198c32..93e758d1 100644 --- a/web/js/components/preact/recordings/recordingsAPI.jsx +++ b/web/js/components/preact/recordings/recordingsAPI.jsx @@ -206,7 +206,9 @@ export const recordingsAPI = { retries: 2, // Retry twice retryDelay: 1000 // 1 second between retries }, - // No special handling needed - we rely on the backend API for detection information + // Refresh live rows and discover newly started segments while this + // page is open. Query core pauses interval polling in hidden tabs. + { refetchInterval: 30000 }, ); }, diff --git a/web/js/components/preact/timeline/TimelinePage.jsx b/web/js/components/preact/timeline/TimelinePage.jsx index 87661885..ad9402bf 100644 --- a/web/js/components/preact/timeline/TimelinePage.jsx +++ b/web/js/components/preact/timeline/TimelinePage.jsx @@ -1240,10 +1240,11 @@ export function TimelinePage() { const { added: addedSegs, removed: removedSegs, + updated: updatedSegs, authoritative, } = reconcileTimelineSegments(currentSegs, polledSegments); - if (addedSegs.length === 0 && removedSegs.length === 0) return; + if (addedSegs.length === 0 && removedSegs.length === 0 && updatedSegs.length === 0) return; // Use polled segments as the authoritative list const updated = authoritative.sort((a, b) => a.start_timestamp - b.start_timestamp); diff --git a/web/js/components/preact/timeline/TimelinePlayer.jsx b/web/js/components/preact/timeline/TimelinePlayer.jsx index 5e6e6c72..60239603 100644 --- a/web/js/components/preact/timeline/TimelinePlayer.jsx +++ b/web/js/components/preact/timeline/TimelinePlayer.jsx @@ -159,7 +159,10 @@ export function TimelinePlayer({ if (!segment) return; // Check if we need to load a new segment - const segmentChanged = lastSegmentIdRef.current !== segment.id; + // A live segment keeps its ID when the recorder finalizes it. Treat that + // transition as a new source so playback becomes available after polling. + const segmentKey = `${segment.id}:${segment.in_progress ? 'live' : 'complete'}`; + const segmentChanged = lastSegmentIdRef.current !== segmentKey; // IMPORTANT: Only reload if the segment has actually changed // This prevents constant reloading @@ -196,8 +199,8 @@ export function TimelinePlayer({ // Update last segment ID if (segmentChanged) { - console.log(`Segment changed from ${lastSegmentIdRef.current} to ${segment.id}`); - lastSegmentIdRef.current = segment.id; + console.log(`Segment changed from ${lastSegmentIdRef.current} to ${segmentKey}`); + lastSegmentIdRef.current = segmentKey; } // Handle playback @@ -264,6 +267,17 @@ export function TimelinePlayer({ const video = videoRef.current; if (!video) return; + if (segment.in_progress) { + cleanupPlayback(); + suppressNativePlaybackSync(); + video.pause(); + video.removeAttribute('src'); + video.load(); + setPlaybackMessage(t('recordings.inProgressToast')); + if (timelineState.isPlaying) timelineState.setState({ isPlaying: false }); + return; + } + console.log(`Loading segment ${segment.id} at time ${seekTime}s, autoplay: ${autoplay}`); cleanupPlayback(); setPlaybackMessage(''); diff --git a/web/js/components/preact/timeline/timelineUtils.js b/web/js/components/preact/timeline/timelineUtils.js index 7cc5bb4b..4e84b859 100644 --- a/web/js/components/preact/timeline/timelineUtils.js +++ b/web/js/components/preact/timeline/timelineUtils.js @@ -21,11 +21,18 @@ export function timelineSegmentIdentity(segment) { } export function reconcileTimelineSegments(existing = [], incoming = []) { - const existingKeys = new Set(existing.map(timelineSegmentIdentity)); + const existingByKey = new Map(existing.map((segment) => + [timelineSegmentIdentity(segment), segment])); const incomingKeys = new Set(incoming.map(timelineSegmentIdentity)); return { - added: incoming.filter((segment) => !existingKeys.has(timelineSegmentIdentity(segment))), + added: incoming.filter((segment) => !existingByKey.has(timelineSegmentIdentity(segment))), removed: existing.filter((segment) => !incomingKeys.has(timelineSegmentIdentity(segment))), + updated: incoming.filter((segment) => { + const previous = existingByKey.get(timelineSegmentIdentity(segment)); + return previous && (previous.end_timestamp !== segment.end_timestamp || + previous.in_progress !== segment.in_progress || + previous.has_detection !== segment.has_detection); + }), authoritative: [...incoming], }; } diff --git a/web/tests/timeline-utils.spec.js b/web/tests/timeline-utils.spec.js index c7b4c2e6..a7dd9b7a 100644 --- a/web/tests/timeline-utils.spec.js +++ b/web/tests/timeline-utils.spec.js @@ -661,10 +661,19 @@ describe('timelineUtils', () => { expect(reconcileTimelineSegments([removed, retained], [incomingRetained, added])).toEqual({ added: [added], removed: [removed], + updated: [incomingRetained], authoritative: [incomingRetained, added], }); }); + test('refreshes the same live timeline segment as it grows and finishes', () => { + const live = { id: 7, start_timestamp: 100, end_timestamp: 110, in_progress: true }; + const grown = { ...live, end_timestamp: 120 }; + const finished = { ...grown, in_progress: false }; + expect(reconcileTimelineSegments([live], [grown]).updated).toEqual([grown]); + expect(reconcileTimelineSegments([grown], [finished]).updated).toEqual([finished]); + }); + test('selects the nearest available scrub preview frame', () => { const segment = { start_timestamp: 100, end_timestamp: 130 }; expect(getTimelinePreviewFrameIndex(segment, 100)).toBe(0);