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_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/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..263f6ef3 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; } @@ -273,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"; @@ -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_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/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/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/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..27b05eb5 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'); @@ -962,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/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/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 f9f033cf..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); @@ -1447,6 +1448,12 @@ export function TimelinePage() { /> {t('timeline.legend.recording')} + + + {t('timeline.legend.inProgress')} + ); }); 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/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", 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);