Skip to content
Open
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
23 changes: 23 additions & 0 deletions db/migrations/0088_add_camera_favorites.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Per-user favorite cameras, surfaced as a filter on the Live view.
-- A NULL user_id holds the installation-wide set used while authentication is
-- disabled, mirroring live_saved_layouts.

-- migrate:up

CREATE TABLE camera_favorites (
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
camera_uuid TEXT NOT NULL REFERENCES streams(camera_uuid) ON DELETE CASCADE,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

CREATE UNIQUE INDEX idx_camera_favorites_user_camera
ON camera_favorites(COALESCE(user_id, 0), camera_uuid);

CREATE INDEX idx_camera_favorites_camera
ON camera_favorites(camera_uuid);

-- migrate:down

DROP INDEX IF EXISTS idx_camera_favorites_camera;
DROP INDEX IF EXISTS idx_camera_favorites_user_camera;
DROP TABLE IF EXISTS camera_favorites;
33 changes: 33 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1339,6 +1339,39 @@ POST /api/camera-collections/{collection_uuid}/preview
Returns the authorized `matched_count` and a sample of at most 50 camera UUIDs,
names, and location paths.

### Camera Favorites

Favorites are a personal, per-user set of cameras. The Live view shows them as
a **Favorites** filter and puts a star on each tile to add or remove a camera.
They are private to the signed-in user; while authentication is disabled there
is a single installation-wide set. Deleting a user or a camera removes its
entries.

```
GET /api/camera-favorites
PUT /api/camera-favorites/{camera_uuid}
DELETE /api/camera-favorites/{camera_uuid}
```

`GET` returns `favorites` (each with `camera_uuid` and `created_at`, oldest
first) and `can_modify`. Cameras outside the caller's current live-view scope
are omitted. Demo and scoped-token identities receive an empty list with
`can_modify: false`.

```json
{
"favorites": [
{"camera_uuid": "7c0b1b0e-4d2f-4c7a-9f1e-2e3a8b6c1d55", "created_at": 1759600000}
],
"can_modify": true
}
```

`PUT` adds a camera and returns its record; `DELETE` removes one and returns
`removed` to say whether it was present. Both are idempotent and require an
interactive user (403 for demo or scoped-token sessions). `PUT` answers 404 for
a camera that does not exist or that the caller cannot view.

### Storage Targets

Storage target endpoints require the global `storage.configure` action. They
Expand Down
50 changes: 50 additions & 0 deletions include/database/db_camera_favorites.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#ifndef LIGHTNVR_DB_CAMERA_FAVORITES_H
#define LIGHTNVR_DB_CAMERA_FAVORITES_H

#include <stdbool.h>
#include <stdint.h>

#include "core/config.h"

/* Upper bound on favorites returned for one user. */
#define CAMERA_FAVORITES_MAX_LIST 1024

typedef struct {
char camera_uuid[CAMERA_UUID_STRING_SIZE];
int64_t created_at;
} camera_favorite_t;

typedef enum {
DB_CAMERA_FAVORITE_OK = 0,
DB_CAMERA_FAVORITE_NOT_FOUND = -1, /* camera_uuid does not reference a stream */
DB_CAMERA_FAVORITE_INVALID = -2,
DB_CAMERA_FAVORITE_ERROR = -3
} db_camera_favorite_result_t;

/*
* Favorites are scoped by user_id. A user_id <= 0 addresses the single
* installation-wide set used while authentication is disabled (stored with a
* NULL user_id), the same convention live_saved_layouts uses for its owner.
*/

/** List a user's favorites, oldest first. Returns the count or -1. */
int db_camera_favorites_list(int64_t user_id, camera_favorite_t *favorites,
int max_count);

/**
* Add a camera to a user's favorites. Idempotent: repeating the call keeps the
* original created_at. On success favorite (optional) receives the stored row.
*/
db_camera_favorite_result_t db_camera_favorite_add(int64_t user_id,
const char *camera_uuid,
camera_favorite_t *favorite);

/**
* Remove a camera from a user's favorites. Idempotent; removed (optional)
* reports whether a row existed.
*/
db_camera_favorite_result_t db_camera_favorite_remove(int64_t user_id,
const char *camera_uuid,
bool *removed);

#endif /* LIGHTNVR_DB_CAMERA_FAVORITES_H */
27 changes: 26 additions & 1 deletion include/database/db_embedded_migrations.h
Original file line number Diff line number Diff line change
Expand Up @@ -2041,6 +2041,24 @@ static const char migration_0087_up[] =
static const char migration_0087_down[] =
"ALTER TABLE operator_floor_plans DROP COLUMN sketch_json;";

static const char migration_0088_up[] =
"CREATE TABLE camera_favorites (\n"
" user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n"
" camera_uuid TEXT NOT NULL REFERENCES streams(camera_uuid) ON DELETE CASCADE,\n"
" created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n"
");\n"
"\n"
"CREATE UNIQUE INDEX idx_camera_favorites_user_camera\n"
"ON camera_favorites(COALESCE(user_id, 0), camera_uuid);\n"
"\n"
"CREATE INDEX idx_camera_favorites_camera\n"
"ON camera_favorites(camera_uuid);";

static const char migration_0088_down[] =
"DROP INDEX IF EXISTS idx_camera_favorites_camera;\n"
"DROP INDEX IF EXISTS idx_camera_favorites_user_camera;\n"
"DROP TABLE IF EXISTS camera_favorites;";

static const migration_t embedded_migrations_data[] = {
{
.version = "0001",
Expand Down Expand Up @@ -2651,8 +2669,15 @@ static const migration_t embedded_migrations_data[] = {
.sql_down = migration_0087_down,
.is_embedded = true
},
{
.version = "0088",
.description = "add_camera_favorites",
.sql_up = migration_0088_up,
.sql_down = migration_0088_down,
.is_embedded = true
},
};

#define EMBEDDED_MIGRATIONS_COUNT 87
#define EMBEDDED_MIGRATIONS_COUNT 88

#endif /* DB_EMBEDDED_MIGRATIONS_H */
15 changes: 15 additions & 0 deletions include/web/api_handlers_camera_favorites.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#ifndef LIGHTNVR_API_HANDLERS_CAMERA_FAVORITES_H
#define LIGHTNVR_API_HANDLERS_CAMERA_FAVORITES_H

#include "web/request_response.h"

/** GET /api/camera-favorites: the caller's favorites within their live-view scope. */
void handle_get_camera_favorites(const http_request_t *req, http_response_t *res);

/** PUT /api/camera-favorites/{camera_uuid}: add a camera (idempotent). */
void handle_put_camera_favorite(const http_request_t *req, http_response_t *res);

/** DELETE /api/camera-favorites/{camera_uuid}: remove a camera (idempotent). */
void handle_delete_camera_favorite(const http_request_t *req, http_response_t *res);

#endif /* LIGHTNVR_API_HANDLERS_CAMERA_FAVORITES_H */
142 changes: 142 additions & 0 deletions src/database/db_camera_favorites.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#define _POSIX_C_SOURCE 200809L

#include "database/db_camera_favorites.h"

#include <pthread.h>
#include <sqlite3.h>
#include <string.h>

#include "core/logger.h"
#include "database/db_core.h"
#include "utils/strings.h"
#include "utils/uuid.h"

/* Parameter 1 is the caller's user id, with 0 selecting the NULL-owner set. */
#define OWNER_MATCH "((?1=0 AND user_id IS NULL) OR user_id=?1)"

static int64_t scope_id(int64_t user_id) {
return user_id > 0 ? user_id : 0;
}

int db_camera_favorites_list(int64_t user_id, camera_favorite_t *favorites,
int max_count) {
if (!favorites || max_count < 1) return -1;
sqlite3 *db = get_db_handle();
pthread_mutex_t *mutex = get_db_mutex();
if (!db || !mutex) return -1;

static const char *sql =
"SELECT camera_uuid,created_at FROM camera_favorites "
"WHERE " OWNER_MATCH " ORDER BY created_at,camera_uuid LIMIT ?2;";

pthread_mutex_lock(mutex);
sqlite3_stmt *statement = NULL;
int count = 0;
int rc = sqlite3_prepare_v2(db, sql, -1, &statement, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_int64(statement, 1, scope_id(user_id));
sqlite3_bind_int(statement, 2, max_count);
while ((rc = sqlite3_step(statement)) == SQLITE_ROW && count < max_count) {
const char *uuid = (const char *)sqlite3_column_text(statement, 0);
safe_strcpy(favorites[count].camera_uuid, uuid ? uuid : "",
sizeof(favorites[count].camera_uuid), 0);
favorites[count].created_at = sqlite3_column_int64(statement, 1);
count++;
}
if (rc == SQLITE_ROW) rc = SQLITE_DONE;
}
if (rc != SQLITE_DONE) {
log_error("Failed to list camera favorites: %s", sqlite3_errmsg(db));
}
if (statement) sqlite3_finalize(statement);
pthread_mutex_unlock(mutex);
return rc == SQLITE_DONE ? count : -1;
}

db_camera_favorite_result_t db_camera_favorite_add(int64_t user_id,
const char *camera_uuid,
camera_favorite_t *favorite) {
if (!lightnvr_uuid_is_valid(camera_uuid)) return DB_CAMERA_FAVORITE_INVALID;
sqlite3 *db = get_db_handle();
pthread_mutex_t *mutex = get_db_mutex();
if (!db || !mutex) return DB_CAMERA_FAVORITE_ERROR;

/* OR IGNORE swallows the unique-index hit on a repeat add but still
* raises the foreign key failure for a camera that does not exist. */
static const char *insert_sql =
"INSERT OR IGNORE INTO camera_favorites(user_id,camera_uuid) VALUES(?1,?2);";
static const char *select_sql =
"SELECT camera_uuid,created_at FROM camera_favorites "
"WHERE " OWNER_MATCH " AND camera_uuid=?2;";

db_camera_favorite_result_t result = DB_CAMERA_FAVORITE_ERROR;
pthread_mutex_lock(mutex);
sqlite3_stmt *statement = NULL;
int rc = sqlite3_prepare_v2(db, insert_sql, -1, &statement, NULL);
if (rc == SQLITE_OK) {
if (user_id > 0) sqlite3_bind_int64(statement, 1, user_id);
else sqlite3_bind_null(statement, 1);
sqlite3_bind_text(statement, 2, camera_uuid, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(statement);
}
if (statement) sqlite3_finalize(statement);
statement = NULL;

if (rc == SQLITE_CONSTRAINT) {
result = DB_CAMERA_FAVORITE_NOT_FOUND;
} else if (rc != SQLITE_DONE) {
log_error("Failed to add camera favorite: %s", sqlite3_errmsg(db));
} else {
rc = sqlite3_prepare_v2(db, select_sql, -1, &statement, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_int64(statement, 1, scope_id(user_id));
sqlite3_bind_text(statement, 2, camera_uuid, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(statement);
}
if (rc == SQLITE_ROW) {
if (favorite) {
memset(favorite, 0, sizeof(*favorite));
const char *uuid = (const char *)sqlite3_column_text(statement, 0);
safe_strcpy(favorite->camera_uuid, uuid ? uuid : "",
sizeof(favorite->camera_uuid), 0);
favorite->created_at = sqlite3_column_int64(statement, 1);
}
result = DB_CAMERA_FAVORITE_OK;
} else {
log_error("Failed to read back camera favorite: %s", sqlite3_errmsg(db));
}
if (statement) sqlite3_finalize(statement);
}
pthread_mutex_unlock(mutex);
return result;
}

db_camera_favorite_result_t db_camera_favorite_remove(int64_t user_id,
const char *camera_uuid,
bool *removed) {
if (removed) *removed = false;
if (!lightnvr_uuid_is_valid(camera_uuid)) return DB_CAMERA_FAVORITE_INVALID;
sqlite3 *db = get_db_handle();
pthread_mutex_t *mutex = get_db_mutex();
if (!db || !mutex) return DB_CAMERA_FAVORITE_ERROR;

static const char *sql =
"DELETE FROM camera_favorites WHERE " OWNER_MATCH " AND camera_uuid=?2;";

pthread_mutex_lock(mutex);
sqlite3_stmt *statement = NULL;
int rc = sqlite3_prepare_v2(db, sql, -1, &statement, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_int64(statement, 1, scope_id(user_id));
sqlite3_bind_text(statement, 2, camera_uuid, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(statement);
}
if (rc == SQLITE_DONE) {
if (removed) *removed = sqlite3_changes(db) > 0;
} else {
log_error("Failed to remove camera favorite: %s", sqlite3_errmsg(db));
}
if (statement) sqlite3_finalize(statement);
pthread_mutex_unlock(mutex);
return rc == SQLITE_DONE ? DB_CAMERA_FAVORITE_OK : DB_CAMERA_FAVORITE_ERROR;
}
Loading
Loading