From ab61570910b4e5b475d738ceb001ea3976cb8588 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Mon, 5 Oct 2026 06:04:00 -0400 Subject: [PATCH] feat(live): per-user favorite cameras with a Live view filter Add a personal set of favorite cameras for each signed-in user. The Live view gets a star on every tile to add or remove a camera and a Favorites toggle in the toolbar that filters the grid, persisted in the URL like the collection and tag filters. Backend - Migration 0088 adds camera_favorites(user_id, camera_uuid, created_at) with cascading deletes from users and streams. A NULL user_id holds the installation-wide set used while authentication is disabled, the same convention as live_saved_layouts. - GET /api/camera-favorites lists the caller's favorites filtered to the cameras in their live-view scope; PUT and DELETE /api/camera-favorites/{camera_uuid} add and remove idempotently. Demo and scoped-token identities are read-only (403 on writes). PUT answers 404 for unknown and out-of-scope cameras alike. - Favorites are a personal preference, not camera configuration, so they are not audited. Frontend - live/cameraFavorites.js exposes pure helpers plus a hook over the query cache; the tile star and toolbar toggle live inside the existing LiveView rather than a separate surface. All strings go through i18n. Replaces the favorites half of #624 by zirocool93 with an implementation that fits the existing live view and the project's language conventions. Co-Authored-By: Claude Fable 5.1 --- db/migrations/0088_add_camera_favorites.sql | 23 ++ docs/API.md | 33 +++ include/database/db_camera_favorites.h | 50 ++++ include/database/db_embedded_migrations.h | 27 ++- include/web/api_handlers_camera_favorites.h | 15 ++ src/database/db_camera_favorites.c | 142 ++++++++++++ src/web/api_handlers_camera_favorites.c | 213 ++++++++++++++++++ src/web/libuv_api_handlers.c | 9 + tests/unit/CMakeLists.txt | 2 + .../unit/test_api_handlers_camera_favorites.c | 181 +++++++++++++++ tests/unit/test_db_camera_favorites.c | 185 +++++++++++++++ web/css/live.css | 43 ++++ web/js/components/preact/LiveView.jsx | 65 +++++- .../components/preact/live/cameraFavorites.js | 68 ++++++ web/public/locales/en.json | 6 + web/tests/cameraFavorites.spec.js | 67 ++++++ 16 files changed, 1125 insertions(+), 4 deletions(-) create mode 100644 db/migrations/0088_add_camera_favorites.sql create mode 100644 include/database/db_camera_favorites.h create mode 100644 include/web/api_handlers_camera_favorites.h create mode 100644 src/database/db_camera_favorites.c create mode 100644 src/web/api_handlers_camera_favorites.c create mode 100644 tests/unit/test_api_handlers_camera_favorites.c create mode 100644 tests/unit/test_db_camera_favorites.c create mode 100644 web/js/components/preact/live/cameraFavorites.js create mode 100644 web/tests/cameraFavorites.spec.js diff --git a/db/migrations/0088_add_camera_favorites.sql b/db/migrations/0088_add_camera_favorites.sql new file mode 100644 index 00000000..60642e6f --- /dev/null +++ b/db/migrations/0088_add_camera_favorites.sql @@ -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; diff --git a/docs/API.md b/docs/API.md index e0847dfc..4cbe8a5b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/include/database/db_camera_favorites.h b/include/database/db_camera_favorites.h new file mode 100644 index 00000000..97fbac16 --- /dev/null +++ b/include/database/db_camera_favorites.h @@ -0,0 +1,50 @@ +#ifndef LIGHTNVR_DB_CAMERA_FAVORITES_H +#define LIGHTNVR_DB_CAMERA_FAVORITES_H + +#include +#include + +#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 */ diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index d6b6f549..223b1d25 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -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", @@ -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 */ diff --git a/include/web/api_handlers_camera_favorites.h b/include/web/api_handlers_camera_favorites.h new file mode 100644 index 00000000..8ea1e9c7 --- /dev/null +++ b/include/web/api_handlers_camera_favorites.h @@ -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 */ diff --git a/src/database/db_camera_favorites.c b/src/database/db_camera_favorites.c new file mode 100644 index 00000000..9143a33e --- /dev/null +++ b/src/database/db_camera_favorites.c @@ -0,0 +1,142 @@ +#define _POSIX_C_SOURCE 200809L + +#include "database/db_camera_favorites.h" + +#include +#include +#include + +#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; +} diff --git a/src/web/api_handlers_camera_favorites.c b/src/web/api_handlers_camera_favorites.c new file mode 100644 index 00000000..0d4b5430 --- /dev/null +++ b/src/web/api_handlers_camera_favorites.c @@ -0,0 +1,213 @@ +/** + * Per-user favorite cameras. + * + * Favorites are a personal preference rather than camera configuration, so + * they are not audited. Reads are limited to cameras inside the caller's + * current live-view scope; writes require an interactive user. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "web/api_handlers_camera_favorites.h" + +#include +#include +#include +#include + +#include "core/authorization.h" +#include "database/db_auth.h" +#include "database/db_camera_favorites.h" +#include "database/db_fleet_query.h" +#include "utils/strings.h" +#include "utils/uuid.h" +#include "web/httpd_utils.h" + +#define FAVORITES_PATH_PREFIX "/api/camera-favorites/" + +static bool authenticate(const http_request_t *req, http_response_t *res, + user_t *user) { + memset(user, 0, sizeof(*user)); + if (!httpd_check_action_access(req, user)) { + http_response_set_json_error(res, 401, "Unauthorized"); + return false; + } + return true; +} + +static bool read_only_identity(const user_t *user) { + return user->authenticated_via_scoped_token || + strcmp(user->authentication_method, "demo") == 0; +} + +static bool require_interactive_user(const user_t *user, http_response_t *res) { + if (!read_only_identity(user)) return true; + http_response_set_json_error(res, 403, + "Camera favorites require an interactive user"); + return false; +} + +static bool extract_camera_uuid(const http_request_t *req, http_response_t *res, + char *uuid, size_t size) { + if (http_request_extract_path_param(req, FAVORITES_PATH_PREFIX, uuid, + size) != 0 || + !lightnvr_uuid_is_valid(uuid)) { + http_response_set_json_error(res, 400, "Invalid camera UUID"); + return false; + } + return true; +} + +static bool load_authorized_cameras(const user_t *user, + fleet_camera_t **cameras, + int *camera_count) { + *cameras = NULL; + *camera_count = 0; + return db_fleet_camera_load(cameras, camera_count) == 0 && + authorization_filter_cameras(user, AUTHZ_LIVE_VIEW, + *cameras, camera_count) == 0; +} + +static bool camera_allowed(const char *camera_uuid, + const fleet_camera_t *cameras, int camera_count) { + for (int index = 0; index < camera_count; index++) { + if (strcmp(cameras[index].camera_uuid, camera_uuid) == 0) return true; + } + return false; +} + +static cJSON *favorite_json(const camera_favorite_t *favorite) { + cJSON *object = cJSON_CreateObject(); + if (!object) return NULL; + cJSON_AddStringToObject(object, "camera_uuid", favorite->camera_uuid); + cJSON_AddNumberToObject(object, "created_at", (double)favorite->created_at); + return object; +} + +/* Serialises and frees root. */ +static void send_json(http_response_t *res, int status, cJSON *root) { + char *encoded = root ? cJSON_PrintUnformatted(root) : NULL; + cJSON_Delete(root); + if (!encoded) { + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + http_response_set_json(res, status, encoded); + free(encoded); +} + +static void send_db_error(http_response_t *res, + db_camera_favorite_result_t result) { + switch (result) { + case DB_CAMERA_FAVORITE_INVALID: + http_response_set_json_error(res, 400, "Invalid camera UUID"); + break; + case DB_CAMERA_FAVORITE_NOT_FOUND: + http_response_set_json_error(res, 404, "Camera not found"); + break; + default: + http_response_set_json_error(res, 500, "Failed to update camera favorites"); + break; + } +} + +void handle_get_camera_favorites(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + + cJSON *root = cJSON_CreateObject(); + cJSON *items = root ? cJSON_AddArrayToObject(root, "favorites") : NULL; + if (!root || !items) { + cJSON_Delete(root); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + bool can_modify = !read_only_identity(&user); + cJSON_AddBoolToObject(root, "can_modify", can_modify); + + if (can_modify) { + camera_favorite_t *favorites = calloc(CAMERA_FAVORITES_MAX_LIST, + sizeof(*favorites)); + fleet_camera_t *cameras = NULL; + int camera_count = 0; + int count = favorites + ? db_camera_favorites_list(user.id, favorites, CAMERA_FAVORITES_MAX_LIST) + : -1; + bool cameras_ok = count >= 0 && + load_authorized_cameras(&user, &cameras, &camera_count); + if (count < 0 || !cameras_ok) { + free(favorites); + free(cameras); + cJSON_Delete(root); + http_response_set_json_error(res, 500, "Failed to load camera favorites"); + return; + } + for (int index = 0; index < count; index++) { + if (!camera_allowed(favorites[index].camera_uuid, cameras, + camera_count)) continue; + cJSON *item = favorite_json(&favorites[index]); + if (item) cJSON_AddItemToArray(items, item); + } + free(favorites); + free(cameras); + } + + send_json(res, 200, root); +} + +void handle_put_camera_favorite(const http_request_t *req, + http_response_t *res) { + user_t user; + char camera_uuid[CAMERA_UUID_STRING_SIZE] = {0}; + if (!authenticate(req, res, &user)) return; + if (!require_interactive_user(&user, res)) return; + if (!extract_camera_uuid(req, res, camera_uuid, sizeof(camera_uuid))) return; + + fleet_camera_t *cameras = NULL; + int camera_count = 0; + if (!load_authorized_cameras(&user, &cameras, &camera_count)) { + free(cameras); + http_response_set_json_error(res, 500, "Failed to check camera access"); + return; + } + bool allowed = camera_allowed(camera_uuid, cameras, camera_count); + free(cameras); + if (!allowed) { + /* Unknown and out-of-scope cameras look the same to the caller. */ + http_response_set_json_error(res, 404, "Camera not found"); + return; + } + + camera_favorite_t favorite; + db_camera_favorite_result_t result = + db_camera_favorite_add(user.id, camera_uuid, &favorite); + if (result != DB_CAMERA_FAVORITE_OK) { + send_db_error(res, result); + return; + } + send_json(res, 200, favorite_json(&favorite)); +} + +void handle_delete_camera_favorite(const http_request_t *req, + http_response_t *res) { + user_t user; + char camera_uuid[CAMERA_UUID_STRING_SIZE] = {0}; + if (!authenticate(req, res, &user)) return; + if (!require_interactive_user(&user, res)) return; + if (!extract_camera_uuid(req, res, camera_uuid, sizeof(camera_uuid))) return; + + bool removed = false; + db_camera_favorite_result_t result = + db_camera_favorite_remove(user.id, camera_uuid, &removed); + if (result != DB_CAMERA_FAVORITE_OK) { + send_db_error(res, result); + return; + } + cJSON *root = cJSON_CreateObject(); + if (root) { + cJSON_AddBoolToObject(root, "success", true); + cJSON_AddBoolToObject(root, "removed", removed); + } + send_json(res, 200, root); +} diff --git a/src/web/libuv_api_handlers.c b/src/web/libuv_api_handlers.c index d94d62ae..2daf97df 100644 --- a/src/web/libuv_api_handlers.c +++ b/src/web/libuv_api_handlers.c @@ -55,6 +55,7 @@ #include "web/api_handlers_storage_compliance.h" #include "web/api_handlers_workspaces.h" #include "web/api_handlers_live_layouts.h" +#include "web/api_handlers_camera_favorites.h" #include "web/api_handlers_operator_floor_plans.h" #include "web/api_handlers_lpr.h" #include "web/api_handlers_detection_engines.h" @@ -176,6 +177,14 @@ int register_all_libuv_handlers(http_server_handle_t server) { http_server_register_handler(server, "/api/live/layouts/#", "DELETE", handle_delete_live_layout); + // Per-user favorite cameras, surfaced as a Live view filter + http_server_register_handler(server, "/api/camera-favorites", "GET", + handle_get_camera_favorites); + http_server_register_handler(server, "/api/camera-favorites/#", "PUT", + handle_put_camera_favorite); + http_server_register_handler(server, "/api/camera-favorites/#", "DELETE", + handle_delete_camera_favorite); + // Saved static and selector-backed smart camera collections http_server_register_handler(server, "/api/camera-collections", "GET", handle_get_camera_collections); diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 4f7b52ad..473ff664 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -183,6 +183,8 @@ add_layer2_test(test_api_handlers_fleet) add_layer2_test(test_api_handlers_workspaces) add_layer2_test(test_db_live_saved_layouts) add_layer2_test(test_api_handlers_live_layouts) +add_layer2_test(test_db_camera_favorites) +add_layer2_test(test_api_handlers_camera_favorites) add_layer2_test(test_db_operator_floor_plans) add_layer2_test(test_api_handlers_operator_floor_plans) add_layer2_test(test_api_handlers_investigations) diff --git a/tests/unit/test_api_handlers_camera_favorites.c b/tests/unit/test_api_handlers_camera_favorites.c new file mode 100644 index 00000000..df02605f --- /dev/null +++ b/tests/unit/test_api_handlers_camera_favorites.c @@ -0,0 +1,181 @@ +/** + * @file test_api_handlers_camera_favorites.c + * @brief Camera favorites API tests: idempotent writes, scope, read-only identities. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include +#include + +#include "unity.h" +#include "core/config.h" +#include "database/db_core.h" +#include "database/db_streams.h" +#include "utils/strings.h" +#include "utils/uuid.h" +#include "web/api_handlers_camera_favorites.h" +#include "web/request_response.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_api_camera_favorites.db" + +static cJSON *call(void (*handler)(const http_request_t *, http_response_t *), + http_method_t method, const char *path, int expected_status) { + http_request_t request; + http_response_t response; + http_request_init(&request); + http_response_init(&response); + request.method = method; + safe_strcpy(request.path, path, sizeof(request.path), 0); + safe_strcpy(request.client_ip, "127.0.0.1", sizeof(request.client_ip), 0); + request.body = (void *)""; + request.body_len = 0; + handler(&request, &response); + if (response.status_code != expected_status) { + fprintf(stderr, "camera favorites API expected %d, got %d: %s\n", + expected_status, response.status_code, + response.body ? (const char *)response.body : "(empty)"); + } + TEST_ASSERT_EQUAL_INT(expected_status, response.status_code); + cJSON *json = response.body ? cJSON_Parse((const char *)response.body) : NULL; + TEST_ASSERT_NOT_NULL(json); + http_response_free(&response); + return json; +} + +static stream_config_t create_camera(void) { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + safe_strcpy(stream.name, "Lobby", sizeof(stream.name), 0); + safe_strcpy(stream.url, "rtsp://camera/lobby", sizeof(stream.url), 0); + safe_strcpy(stream.codec, "h264", sizeof(stream.codec), 0); + stream.enabled = true; + stream.streaming_enabled = true; + stream.width = 1920; + stream.height = 1080; + stream.fps = 25; + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name(stream.name, &stream)); + return stream; +} + +static void favorite_path(char *buffer, size_t size, const char *camera_uuid) { + snprintf(buffer, size, "/api/camera-favorites/%s", camera_uuid); +} + +static int favorites_count(void) { + cJSON *listed = call(handle_get_camera_favorites, HTTP_METHOD_GET, + "/api/camera-favorites", 200); + int count = cJSON_GetArraySize(cJSON_GetObjectItemCaseSensitive(listed, "favorites")); + cJSON_Delete(listed); + return count; +} + +void setUp(void) { + g_config.web_auth_enabled = false; + g_config.demo_mode = false; + sqlite3_exec(get_db_handle(), "DELETE FROM camera_favorites;", NULL, NULL, NULL); + sqlite3_exec(get_db_handle(), "DELETE FROM streams;", NULL, NULL, NULL); +} + +void tearDown(void) {} + +void test_put_list_and_delete_favorite(void) { + stream_config_t camera = create_camera(); + char path[256]; + favorite_path(path, sizeof(path), camera.camera_uuid); + + cJSON *added = call(handle_put_camera_favorite, HTTP_METHOD_PUT, path, 200); + TEST_ASSERT_EQUAL_STRING(camera.camera_uuid, + cJSON_GetObjectItemCaseSensitive(added, "camera_uuid")->valuestring); + double created_at = cJSON_GetObjectItemCaseSensitive(added, "created_at")->valuedouble; + TEST_ASSERT_TRUE(created_at > 0); + cJSON_Delete(added); + + cJSON *listed = call(handle_get_camera_favorites, HTTP_METHOD_GET, + "/api/camera-favorites", 200); + cJSON *favorites = cJSON_GetObjectItemCaseSensitive(listed, "favorites"); + TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(favorites)); + TEST_ASSERT_EQUAL_STRING(camera.camera_uuid, + cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(favorites, 0), + "camera_uuid")->valuestring); + TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(listed, "can_modify"))); + cJSON_Delete(listed); + + /* Repeating the PUT keeps the original record. */ + cJSON *repeat = call(handle_put_camera_favorite, HTTP_METHOD_PUT, path, 200); + TEST_ASSERT_EQUAL_INT64((int64_t)created_at, + (int64_t)cJSON_GetObjectItemCaseSensitive(repeat, "created_at")->valuedouble); + cJSON_Delete(repeat); + TEST_ASSERT_EQUAL_INT(1, favorites_count()); + + cJSON *deleted = call(handle_delete_camera_favorite, HTTP_METHOD_DELETE, path, 200); + TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(deleted, "removed"))); + cJSON_Delete(deleted); + TEST_ASSERT_EQUAL_INT(0, favorites_count()); + + /* Deleting again is a harmless no-op. */ + deleted = call(handle_delete_camera_favorite, HTTP_METHOD_DELETE, path, 200); + TEST_ASSERT_FALSE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(deleted, "removed"))); + cJSON_Delete(deleted); +} + +void test_rejects_invalid_and_unknown_cameras(void) { + char unknown[CAMERA_UUID_STRING_SIZE]; + char path[256]; + TEST_ASSERT_EQUAL_INT(0, lightnvr_uuid_generate_v4(unknown)); + + cJSON *json = call(handle_put_camera_favorite, HTTP_METHOD_PUT, + "/api/camera-favorites/not-a-uuid", 400); + cJSON_Delete(json); + json = call(handle_delete_camera_favorite, HTTP_METHOD_DELETE, + "/api/camera-favorites/not-a-uuid", 400); + cJSON_Delete(json); + + favorite_path(path, sizeof(path), unknown); + json = call(handle_put_camera_favorite, HTTP_METHOD_PUT, path, 404); + cJSON_Delete(json); +} + +void test_demo_identity_is_read_only(void) { + stream_config_t camera = create_camera(); + char path[256]; + favorite_path(path, sizeof(path), camera.camera_uuid); + + /* Unauthenticated requests become the demo viewer when demo mode is on. */ + g_config.web_auth_enabled = true; + g_config.demo_mode = true; + + cJSON *listed = call(handle_get_camera_favorites, HTTP_METHOD_GET, + "/api/camera-favorites", 200); + TEST_ASSERT_EQUAL_INT(0, cJSON_GetArraySize( + cJSON_GetObjectItemCaseSensitive(listed, "favorites"))); + TEST_ASSERT_FALSE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(listed, "can_modify"))); + cJSON_Delete(listed); + + cJSON *json = call(handle_put_camera_favorite, HTTP_METHOD_PUT, path, 403); + cJSON_Delete(json); + json = call(handle_delete_camera_favorite, HTTP_METHOD_DELETE, path, 403); + cJSON_Delete(json); +} + +int main(void) { + unlink(TEST_DB_PATH); + if (init_database(TEST_DB_PATH) != 0) { + fprintf(stderr, "FATAL: init_database failed\n"); + return 1; + } + UNITY_BEGIN(); + RUN_TEST(test_put_list_and_delete_favorite); + RUN_TEST(test_rejects_invalid_and_unknown_cameras); + RUN_TEST(test_demo_identity_is_read_only); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} diff --git a/tests/unit/test_db_camera_favorites.c b/tests/unit/test_db_camera_favorites.c new file mode 100644 index 00000000..6214f6c9 --- /dev/null +++ b/tests/unit/test_db_camera_favorites.c @@ -0,0 +1,185 @@ +/** + * @file test_db_camera_favorites.c + * @brief Per-user camera favorites persistence tests. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include + +#include + +#include "unity.h" +#include "database/db_auth.h" +#include "database/db_camera_favorites.h" +#include "database/db_core.h" +#include "database/db_streams.h" +#include "utils/strings.h" +#include "utils/uuid.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_db_camera_favorites.db" + +/* Favorites reference users(id), so the tests need real user rows. */ +static int64_t alice_id = 0; +static int64_t bob_id = 0; + +static stream_config_t create_camera(const char *name) { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + safe_strcpy(stream.name, name, sizeof(stream.name), 0); + snprintf(stream.url, sizeof(stream.url), "rtsp://camera/%s", name); + safe_strcpy(stream.codec, "h264", sizeof(stream.codec), 0); + stream.enabled = true; + stream.streaming_enabled = true; + stream.width = 1920; + stream.height = 1080; + stream.fps = 25; + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name(stream.name, &stream)); + return stream; +} + +static bool listed(const camera_favorite_t *favorites, int count, + const char *camera_uuid) { + for (int index = 0; index < count; index++) { + if (strcmp(favorites[index].camera_uuid, camera_uuid) == 0) return true; + } + return false; +} + +void setUp(void) { + sqlite3_exec(get_db_handle(), "DELETE FROM camera_favorites;", NULL, NULL, NULL); + sqlite3_exec(get_db_handle(), "DELETE FROM streams;", NULL, NULL, NULL); +} + +void tearDown(void) {} + +void test_add_is_idempotent_and_scoped_per_user(void) { + stream_config_t lobby = create_camera("Lobby"); + stream_config_t yard = create_camera("Yard"); + camera_favorite_t first, repeat; + + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(alice_id, lobby.camera_uuid, &first)); + TEST_ASSERT_EQUAL_STRING(lobby.camera_uuid, first.camera_uuid); + TEST_ASSERT_GREATER_THAN_INT64(0, first.created_at); + + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(alice_id, lobby.camera_uuid, &repeat)); + TEST_ASSERT_EQUAL_INT64(first.created_at, repeat.created_at); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(alice_id, yard.camera_uuid, NULL)); + + camera_favorite_t favorites[8]; + int count = db_camera_favorites_list(alice_id, favorites, 8); + TEST_ASSERT_EQUAL_INT(2, count); + TEST_ASSERT_TRUE(listed(favorites, count, lobby.camera_uuid)); + TEST_ASSERT_TRUE(listed(favorites, count, yard.camera_uuid)); + + /* Another user sees nothing. */ + TEST_ASSERT_EQUAL_INT(0, db_camera_favorites_list(bob_id, favorites, 8)); +} + +void test_remove_is_idempotent(void) { + stream_config_t lobby = create_camera("Lobby"); + bool removed = true; + + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(alice_id, lobby.camera_uuid, NULL)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_remove(alice_id, lobby.camera_uuid, &removed)); + TEST_ASSERT_TRUE(removed); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_remove(alice_id, lobby.camera_uuid, &removed)); + TEST_ASSERT_FALSE(removed); + + camera_favorite_t favorites[8]; + TEST_ASSERT_EQUAL_INT(0, db_camera_favorites_list(alice_id, favorites, 8)); +} + +void test_installation_scope_without_a_user(void) { + stream_config_t lobby = create_camera("Lobby"); + camera_favorite_t favorites[8]; + + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(0, lobby.camera_uuid, NULL)); + /* Any non-positive id addresses the same NULL-owner set. */ + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(-1, lobby.camera_uuid, NULL)); + TEST_ASSERT_EQUAL_INT(1, db_camera_favorites_list(0, favorites, 8)); + TEST_ASSERT_EQUAL_INT(0, db_camera_favorites_list(bob_id, favorites, 8)); +} + +void test_rejects_invalid_and_unknown_cameras(void) { + char unknown[CAMERA_UUID_STRING_SIZE]; + TEST_ASSERT_EQUAL_INT(0, lightnvr_uuid_generate_v4(unknown)); + + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_INVALID, + db_camera_favorite_add(alice_id, "not-a-uuid", NULL)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_INVALID, + db_camera_favorite_remove(alice_id, "", NULL)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_NOT_FOUND, + db_camera_favorite_add(alice_id, unknown, NULL)); +} + +void test_deleting_a_user_drops_their_favorites(void) { + stream_config_t lobby = create_camera("Lobby"); + camera_favorite_t favorites[8]; + int64_t carol_id = 0; + + TEST_ASSERT_EQUAL_INT(0, db_auth_create_user("carol", "password123", NULL, + USER_ROLE_USER, true, &carol_id)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(carol_id, lobby.camera_uuid, NULL)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(alice_id, lobby.camera_uuid, NULL)); + TEST_ASSERT_EQUAL_INT(0, db_auth_delete_user(carol_id)); + TEST_ASSERT_EQUAL_INT(0, db_camera_favorites_list(carol_id, favorites, 8)); + TEST_ASSERT_EQUAL_INT(1, db_camera_favorites_list(alice_id, favorites, 8)); +} + +void test_deleting_a_camera_drops_its_favorites(void) { + stream_config_t lobby = create_camera("Lobby"); + camera_favorite_t favorites[8]; + + TEST_ASSERT_EQUAL_INT(DB_CAMERA_FAVORITE_OK, + db_camera_favorite_add(alice_id, lobby.camera_uuid, NULL)); + TEST_ASSERT_EQUAL_INT(1, db_camera_favorites_list(alice_id, favorites, 8)); + + /* A soft delete keeps the stream row (it can be restored), so the favorite + * survives; the API hides it because the camera leaves the live scope. */ + TEST_ASSERT_EQUAL_INT(0, delete_stream_config(lobby.name)); + TEST_ASSERT_EQUAL_INT(1, db_camera_favorites_list(alice_id, favorites, 8)); + + /* A permanent delete removes the row and the FK cascade drops the favorite. */ + TEST_ASSERT_EQUAL_INT(0, delete_stream_config_internal(lobby.name, true)); + TEST_ASSERT_EQUAL_INT(0, db_camera_favorites_list(alice_id, favorites, 8)); +} + +int main(void) { + unlink(TEST_DB_PATH); + if (init_database(TEST_DB_PATH) != 0) { + fprintf(stderr, "FATAL: init_database failed\n"); + return 1; + } + if (db_auth_create_user("alice", "password123", NULL, USER_ROLE_USER, true, + &alice_id) != 0 || + db_auth_create_user("bob", "password123", NULL, USER_ROLE_USER, true, + &bob_id) != 0) { + fprintf(stderr, "FATAL: could not create test users\n"); + return 1; + } + UNITY_BEGIN(); + RUN_TEST(test_add_is_idempotent_and_scoped_per_user); + RUN_TEST(test_remove_is_idempotent); + RUN_TEST(test_installation_scope_without_a_user); + RUN_TEST(test_rejects_invalid_and_unknown_cameras); + RUN_TEST(test_deleting_a_user_drops_their_favorites); + RUN_TEST(test_deleting_a_camera_drops_its_favorites); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} diff --git a/web/css/live.css b/web/css/live.css index f063f004..36008199 100644 --- a/web/css/live.css +++ b/web/css/live.css @@ -1620,3 +1620,46 @@ body.pseudo-native-fullscreen-active { font-size: 14px; text-align: center; } + +/* Per-user favorite star on live tiles (see live/cameraFavorites.js) */ +.favorite-toggle { + position: absolute; + top: 8px; + right: 8px; + z-index: 25; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.45); + color: rgba(255, 255, 255, 0.85); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.favorite-toggle.is-favorite { + color: #facc15; + opacity: 0.9; +} + +.favorite-toggle:hover, +.favorite-toggle:focus-visible { + opacity: 1; + transform: scale(1.1); +} + +.favorite-toggle:disabled { + cursor: wait; + opacity: 0.4; +} + +@media (pointer: coarse) { + .favorite-toggle { + width: 40px; + height: 40px; + opacity: 0.85; + } +} diff --git a/web/js/components/preact/LiveView.jsx b/web/js/components/preact/LiveView.jsx index 4947d2ad..51099799 100644 --- a/web/js/components/preact/LiveView.jsx +++ b/web/js/components/preact/LiveView.jsx @@ -15,6 +15,7 @@ import { GridPicker, computeOptimalGrid, MAX_GRID_CELLS } from './GridPicker.jsx import { useI18n } from '../../i18n.js'; import { buildLiveViewHref, resolveForcedLiveTransport } from '../../utils/live-view-url.js'; import { useCollectionMembership } from './fleet/collectionMembership.js'; +import { useCameraFavorites } from './live/cameraFavorites.js'; import { AlwaysFullscreenToggle } from './AlwaysFullscreenToggle.jsx'; import { useAlwaysFullscreenOnTap } from './useAlwaysFullscreenOnTap.js'; import { LiveDisplayModeToggle } from './LiveDisplayModeToggle.jsx'; @@ -118,6 +119,23 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT error: collectionError, } = useCollectionMembership(collectionFilter); + // Per-user favorites: a star on each tile plus a toolbar filter (#624). + const [favoritesOnly, setFavoritesOnly] = useState(() => { + const p = new URLSearchParams(window.location.search); + return p.get('favorites') === '1'; + }); + const { + favoriteUuids, + canModify: canModifyFavorites, + toggleFavorite, + pendingUuid: pendingFavoriteUuid, + isLoading: favoritesLoading, + } = useCameraFavorites(); + const favoritesAvailable = canModifyFavorites || favoriteUuids.size > 0; + // A stale ?favorites=1 must not blank the grid when favorites are unavailable + // (older server, read-only identity); keep filtering while they load. + const favoritesFilterActive = favoritesOnly && (favoritesLoading || favoritesAvailable); + // State for toggling stream labels and controls visibility const [showLabels, setShowLabels] = useState(() => { const p = new URLSearchParams(window.location.search); @@ -406,6 +424,9 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT if (collectionFilter) url.searchParams.set('collection', collectionFilter); else url.searchParams.delete('collection'); + if (favoritesOnly) url.searchParams.set('favorites', '1'); + else url.searchParams.delete('favorites'); + // Omit params when at their defaults (true) to keep URL clean if (!showLabels) url.searchParams.set('labels', '0'); else url.searchParams.delete('labels'); @@ -421,7 +442,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT localStorage.setItem('lightnvr-show-labels', String(showLabels)); localStorage.setItem('lightnvr-show-controls', String(showControls)); localStorage.setItem('lightnvr-show-detections', String(showDetections)); - }, [tagFilter, collectionFilter, showLabels, showControls, showDetections]); + }, [tagFilter, collectionFilter, favoritesOnly, showLabels, showControls, showDetections]); useEffect(() => { if (collectionError) { @@ -429,6 +450,14 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT } }, [collectionError, t]); + const handleToggleFavorite = useCallback(async (cameraUuid) => { + try { + await toggleFavorite(cameraUuid); + } catch (error) { + showStatusMessage(t('live.favoriteUpdateError', { message: error.message }), 'error'); + } + }, [toggleFavorite, t]); + // The playback grid intentionally excludes administratively disabled // cameras, but the Navigator's availability inventory must still include // them. Keep its source separate from the playable stream list. @@ -452,12 +481,13 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT const tagFilteredStreams = useMemo(() => { return streams.filter((stream) => { if (collectionFilter && !collectionCameraUuids.has(stream.camera_uuid)) return false; + if (favoritesFilterActive && !favoriteUuids.has(stream.camera_uuid)) return false; if (operatorFilter && !operatorFilter.cameraUuids.has(stream.camera_uuid)) return false; if (operatorFilter && operatorFilter.availability !== 'all' && stream.availability !== operatorFilter.availability) return false; return !tagFilter || (stream.tags && stream.tags.split(',').map(tag => tag.trim()).includes(tagFilter)); }); - }, [streams, tagFilter, collectionFilter, collectionCameraUuids, operatorFilter]); + }, [streams, tagFilter, collectionFilter, collectionCameraUuids, favoritesFilterActive, favoriteUuids, operatorFilter]); // Camera ordering hook (operates on group-filtered streams) const { @@ -670,6 +700,18 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT {operatorSurface === 'grid' && (
+ {favoritesAvailable && ( + + )} {collections.length > 0 && (
@@ -919,7 +961,7 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT
) : orderedStreams.length === 0 ? (
-

{t('live.navigator.noCameras')}

+

{favoritesFilterActive ? t('live.noFavorites') : t('live.navigator.noCameras')}

) : ( // Render each tile using its persisted playback profile. The active @@ -979,6 +1021,23 @@ export function LiveView({audioDisabled = false, isAutoDisabled = false, isWebRT {t('live.dragToReorder')}
)} + {canModifyFavorites && showControls && !reorderMode && (() => { + const isFavorite = favoriteUuids.has(stream.camera_uuid); + const label = t(isFavorite ? 'live.removeFavorite' : 'live.addFavorite', { stream: stream.name }); + return ( + + ); + })()} favorite.camera_uuid)); +} + +export function filterFavoriteStreams(streams, favoriteUuids) { + return (streams || []).filter((stream) => favoriteUuids.has(stream.camera_uuid)); +} + +/** + * Next cached favorites document after a toggle resolves. `record` is the + * server's row for an add, or null for a removal. Keeps oldest-first order. + */ +export function applyFavoriteChange(data, cameraUuid, record) { + const favorites = (data?.favorites || []).filter((favorite) => favorite.camera_uuid !== cameraUuid); + if (record) favorites.push({ camera_uuid: cameraUuid, ...record }); + favorites.sort((a, b) => + (Number(a.created_at) - Number(b.created_at)) || String(a.camera_uuid).localeCompare(String(b.camera_uuid))); + return { ...(data || EMPTY_FAVORITES), favorites }; +} + +export function requestFavoriteChange(cameraUuid, favorite, request = fetchJSON) { + const url = `/api/camera-favorites/${encodeURIComponent(cameraUuid)}`; + return request(url, { method: favorite ? 'PUT' : 'DELETE', retries: 0 }); +} + +export function useCameraFavorites() { + const queryClient = useQueryClient(); + const query = useQuery(CAMERA_FAVORITES_QUERY_KEY, '/api/camera-favorites', {}, { + staleTime: 30000, + retry: false, + }); + const favoriteUuids = useMemo(() => favoriteCameraUuids(query.data), [query.data]); + const mutation = useMutation({ + mutationFn: ({ cameraUuid, favorite }) => requestFavoriteChange(cameraUuid, favorite), + onSuccess: (result, { cameraUuid, favorite }) => { + queryClient.setQueryData(CAMERA_FAVORITES_QUERY_KEY, + (current) => applyFavoriteChange(current, cameraUuid, favorite ? result : null)); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: CAMERA_FAVORITES_QUERY_KEY }), + }); + const toggleFavorite = useCallback( + (cameraUuid) => mutation.mutateAsync({ cameraUuid, favorite: !favoriteUuids.has(cameraUuid) }), + [mutation, favoriteUuids] + ); + const pending = mutation.isPending ?? mutation.isLoading; + + return { + favoriteUuids, + canModify: query.data?.can_modify === true, + isLoading: query.isLoading, + error: query.error, + toggleFavorite, + pendingUuid: pending ? (mutation.variables?.cameraUuid || '') : '', + }; +} diff --git a/web/public/locales/en.json b/web/public/locales/en.json index a0de76e8..c72ad0c0 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -1190,6 +1190,12 @@ "live.errorProcessingStreams": "Error processing streams: {message}", "live.couldNotEnableFullscreen": "Could not enable fullscreen mode: {message}", "live.allTags": "All Tags", + "live.favorites": "Favorites", + "live.favoritesOnly": "Show only favorite cameras", + "live.addFavorite": "Add {stream} to favorites", + "live.removeFavorite": "Remove {stream} from favorites", + "live.noFavorites": "No favorite cameras yet. Use the star on a camera tile to add one.", + "live.favoriteUpdateError": "Could not update favorites: {message}", "live.tags": "Tags", "live.stream": "Stream", "live.filterByTag": "Filter by tag: {tag}", diff --git a/web/tests/cameraFavorites.spec.js b/web/tests/cameraFavorites.spec.js new file mode 100644 index 00000000..2d3f41aa --- /dev/null +++ b/web/tests/cameraFavorites.spec.js @@ -0,0 +1,67 @@ +import { + applyFavoriteChange, + favoriteCameraUuids, + filterFavoriteStreams, + requestFavoriteChange, +} from '../js/components/preact/live/cameraFavorites.js'; + +jest.mock('../js/query-client.js', () => ({ + fetchJSON: jest.fn(), + useQuery: jest.fn(), + useMutation: jest.fn(), + useQueryClient: jest.fn(), +})); + +describe('camera favorites', () => { + const streams = [ + { name: 'Lobby', camera_uuid: 'cam-a' }, + { name: 'Yard', camera_uuid: 'cam-b' }, + { name: 'Gate', camera_uuid: 'cam-c' }, + ]; + + test('derives the favorite set from the API document and filters streams by it', () => { + const favorites = favoriteCameraUuids({ + favorites: [{ camera_uuid: 'cam-c', created_at: 5 }, { camera_uuid: 'cam-a', created_at: 9 }], + can_modify: true, + }); + expect([...favorites]).toEqual(['cam-c', 'cam-a']); + expect(filterFavoriteStreams(streams, favorites).map((stream) => stream.name)).toEqual(['Lobby', 'Gate']); + expect(favoriteCameraUuids(undefined).size).toBe(0); + expect(filterFavoriteStreams(undefined, favorites)).toEqual([]); + }); + + test('applies an add and a removal to the cached list in oldest-first order', () => { + const initial = { favorites: [{ camera_uuid: 'cam-b', created_at: 20 }], can_modify: true }; + + const added = applyFavoriteChange(initial, 'cam-a', { camera_uuid: 'cam-a', created_at: 10 }); + expect(added.favorites.map((favorite) => favorite.camera_uuid)).toEqual(['cam-a', 'cam-b']); + expect(added.can_modify).toBe(true); + expect(initial.favorites).toHaveLength(1); + + const repeated = applyFavoriteChange(added, 'cam-a', { camera_uuid: 'cam-a', created_at: 10 }); + expect(repeated.favorites).toHaveLength(2); + + const removed = applyFavoriteChange(repeated, 'cam-b', null); + expect(removed.favorites.map((favorite) => favorite.camera_uuid)).toEqual(['cam-a']); + + expect(applyFavoriteChange(undefined, 'cam-z', { created_at: 1 }).favorites) + .toEqual([{ camera_uuid: 'cam-z', created_at: 1 }]); + }); + + test('sends PUT to add and DELETE to remove with the camera UUID encoded', async () => { + const request = jest.fn().mockResolvedValue({ ok: true }); + + await requestFavoriteChange('cam/1', true, request); + expect(request).toHaveBeenCalledWith('/api/camera-favorites/cam%2F1', + expect.objectContaining({ method: 'PUT' })); + + await requestFavoriteChange('cam-2', false, request); + expect(request).toHaveBeenLastCalledWith('/api/camera-favorites/cam-2', + expect.objectContaining({ method: 'DELETE' })); + }); + + test('surfaces a request failure', async () => { + const request = jest.fn().mockRejectedValue(new Error('forbidden')); + await expect(requestFavoriteChange('cam-1', true, request)).rejects.toThrow('forbidden'); + }); +});