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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions include/database/db_recordings.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions include/web/api_handlers_recordings.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <sys/types.h> /* for off_t */
#include <time.h> /* for time_t */
#include <stdint.h>
#include <stdbool.h> /* for bool */
#include "database/database_manager.h" /* for recording_metadata_t */

/*
Expand Down Expand Up @@ -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
Expand All @@ -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
*/
Expand All @@ -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);


/**
Expand Down
3 changes: 2 additions & 1 deletion include/web/api_handlers_timeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 63 additions & 11 deletions src/database/db_recordings.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/storage/storage_deletion.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions src/storage/storage_manager_streams.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading