diff --git a/.gitignore b/.gitignore index 87ab357b..acbaca2f 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,4 @@ lightnvr-buildroot/ *.ico *.png lightnvr-provisioning-prd.docx.md +.mcp.json diff --git a/.gitmodules b/.gitmodules index 54f895b0..d338a47b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "go2rtc"] path = go2rtc url = https://github.com/opensensor/go2rtc.git + branch = dev diff --git a/db/migrations/0040_add_go2rtc_source_override.sql b/db/migrations/0040_add_go2rtc_source_override.sql new file mode 100644 index 00000000..71c55e62 --- /dev/null +++ b/db/migrations/0040_add_go2rtc_source_override.sql @@ -0,0 +1,14 @@ +-- Add go2rtc_source_override and sub_stream_url columns to streams table +-- +-- go2rtc_source_override: when non-empty, written directly into go2rtc.yaml +-- streams section instead of auto-constructing the source URL. +-- +-- sub_stream_url: optional low-resolution stream URL used for the dashboard +-- grid view while the main URL is used for recording and fullscreen viewing. + +-- migrate:up +ALTER TABLE streams ADD COLUMN go2rtc_source_override TEXT DEFAULT ''; +ALTER TABLE streams ADD COLUMN sub_stream_url TEXT DEFAULT ''; + +-- migrate:down +-- SQLite does not support DROP COLUMN in older versions; migration is left intentionally empty. diff --git a/go2rtc b/go2rtc index bd8b4796..63c58e77 160000 --- a/go2rtc +++ b/go2rtc @@ -1 +1 @@ -Subproject commit bd8b4796525db4ba6746d74504ed03fdc9755000 +Subproject commit 63c58e77a6661fcced56e0ad795ee875c74ebc39 diff --git a/include/core/config.h b/include/core/config.h index 5f00663a..d31af622 100644 --- a/include/core/config.h +++ b/include/core/config.h @@ -98,6 +98,16 @@ typedef struct { // Useful for dual-lens cameras where one lens provides ONVIF events and // the other (e.g. PTZ) does not expose its own motion events. char motion_trigger_source[MAX_STREAM_NAME]; + + // go2rtc source override: when non-empty, written directly into go2rtc.yaml + // streams section instead of auto-constructing the source URL. + // Supports single URLs or multi-source YAML lists (e.g. "- rtsp://cam/main\n- ffmpeg:cam#video=h264") + char go2rtc_source_override[2048]; + + // Sub-stream URL: optional low-resolution stream for dashboard grid view. + // When non-empty, registered with go2rtc as "{name}_sub" and used by the + // frontend in grid view while the main URL is used for fullscreen/recording. + char sub_stream_url[MAX_URL_LENGTH]; } stream_config_t; // Size of recording schedule text buffer: 168 values + 167 commas + null terminator diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index 66ff905c..3efc8e36 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -623,6 +623,13 @@ static const char migration_0039_up[] = static const char migration_0039_down[] = "SELECT 1;"; +static const char migration_0040_up[] = + "ALTER TABLE streams ADD COLUMN go2rtc_source_override TEXT DEFAULT '';\n" + "ALTER TABLE streams ADD COLUMN sub_stream_url TEXT DEFAULT '';"; + +static const char migration_0040_down[] = + "SELECT 1;"; + static const migration_t embedded_migrations_data[] = { { .version = "0001", @@ -897,8 +904,15 @@ static const migration_t embedded_migrations_data[] = { .sql_down = migration_0039_down, .is_embedded = true }, + { + .version = "0040", + .description = "add_go2rtc_source_override_and_sub_stream_url", + .sql_up = migration_0040_up, + .sql_down = migration_0040_down, + .is_embedded = true + }, }; -#define EMBEDDED_MIGRATIONS_COUNT 39 +#define EMBEDDED_MIGRATIONS_COUNT 40 #endif /* DB_EMBEDDED_MIGRATIONS_H */ diff --git a/src/database/db_streams.c b/src/database/db_streams.c index 6b5c06fe..bcd2ff28 100644 --- a/src/database/db_streams.c +++ b/src/database/db_streams.c @@ -132,7 +132,8 @@ uint64_t add_stream_config(const stream_config_t *stream) { "ptz_enabled = ?, ptz_max_x = ?, ptz_max_y = ?, ptz_max_z = ?, ptz_has_home = ?, " "onvif_username = ?, onvif_password = ?, onvif_profile = ?, onvif_port = ?, " "record_on_schedule = ?, recording_schedule = ?, tags = ?, admin_url = ?, " - "privacy_mode = ?, motion_trigger_source = ? " + "privacy_mode = ?, motion_trigger_source = ?, go2rtc_source_override = ?, " + "sub_stream_url = ? " "WHERE id = ?;"; rc = sqlite3_prepare_v2(db, update_sql, -1, &stmt, NULL); @@ -214,9 +215,11 @@ uint64_t add_stream_config(const stream_config_t *stream) { sqlite3_bind_text(stmt, 43, stream->admin_url, -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 44, stream->privacy_mode ? 1 : 0); sqlite3_bind_text(stmt, 45, stream->motion_trigger_source, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 46, stream->go2rtc_source_override, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 47, stream->sub_stream_url, -1, SQLITE_STATIC); // Bind ID parameter - sqlite3_bind_int64(stmt, 46, (sqlite3_int64)existing_id); + sqlite3_bind_int64(stmt, 48, (sqlite3_int64)existing_id); // Execute statement rc = sqlite3_step(stmt); @@ -264,8 +267,9 @@ uint64_t add_stream_config(const stream_config_t *stream) { "tier_critical_multiplier, tier_important_multiplier, tier_ephemeral_multiplier, storage_priority, " "ptz_enabled, ptz_max_x, ptz_max_y, ptz_max_z, ptz_has_home, " "onvif_username, onvif_password, onvif_profile, onvif_port, " - "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " + "go2rtc_source_override, sub_stream_url) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { @@ -342,11 +346,13 @@ uint64_t add_stream_config(const stream_config_t *stream) { serialize_recording_schedule(stream->recording_schedule, insert_schedule_buf, sizeof(insert_schedule_buf)); sqlite3_bind_text(stmt, 42, insert_schedule_buf, -1, SQLITE_TRANSIENT); - // Bind tags, admin URL, privacy_mode, and motion_trigger_source parameters + // Bind tags, admin URL, privacy_mode, motion_trigger_source, go2rtc override, and sub-stream parameters sqlite3_bind_text(stmt, 43, stream->tags, -1, SQLITE_STATIC); sqlite3_bind_text(stmt, 44, stream->admin_url, -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 45, stream->privacy_mode ? 1 : 0); sqlite3_bind_text(stmt, 46, stream->motion_trigger_source, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 47, stream->go2rtc_source_override, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 48, stream->sub_stream_url, -1, SQLITE_STATIC); // Execute statement rc = sqlite3_step(stmt); @@ -417,7 +423,8 @@ int update_stream_config(const char *name, const stream_config_t *stream) { "ptz_enabled = ?, ptz_max_x = ?, ptz_max_y = ?, ptz_max_z = ?, ptz_has_home = ?, " "onvif_username = ?, onvif_password = ?, onvif_profile = ?, onvif_port = ?, " "record_on_schedule = ?, recording_schedule = ?, tags = ?, admin_url = ?, privacy_mode = ?, " - "motion_trigger_source = ? " + "motion_trigger_source = ?, go2rtc_source_override = ?, " + "sub_stream_url = ? " "WHERE name = ?;"; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); @@ -495,14 +502,16 @@ int update_stream_config(const char *name, const stream_config_t *stream) { serialize_recording_schedule(stream->recording_schedule, update_schedule_buf, sizeof(update_schedule_buf)); sqlite3_bind_text(stmt, 42, update_schedule_buf, -1, SQLITE_TRANSIENT); - // Bind tags, admin URL, privacy_mode, and motion_trigger_source parameters + // Bind tags, admin URL, privacy_mode, motion_trigger_source, go2rtc override, and sub-stream parameters sqlite3_bind_text(stmt, 43, stream->tags, -1, SQLITE_STATIC); sqlite3_bind_text(stmt, 44, stream->admin_url, -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 45, stream->privacy_mode ? 1 : 0); sqlite3_bind_text(stmt, 46, stream->motion_trigger_source, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 47, stream->go2rtc_source_override, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 48, stream->sub_stream_url, -1, SQLITE_STATIC); // Bind the WHERE clause parameter - sqlite3_bind_text(stmt, 47, name, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 49, name, -1, SQLITE_STATIC); // Execute statement rc = sqlite3_step(stmt); @@ -774,7 +783,8 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { "tier_critical_multiplier, tier_important_multiplier, tier_ephemeral_multiplier, storage_priority, " "ptz_enabled, ptz_max_x, ptz_max_y, ptz_max_z, ptz_has_home, " "onvif_username, onvif_password, onvif_profile, onvif_port, " - "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source " + "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " + "go2rtc_source_override, sub_stream_url " "FROM streams WHERE name = ?;"; // Column index constants for readability @@ -790,7 +800,7 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { COL_PTZ_ENABLED, COL_PTZ_MAX_X, COL_PTZ_MAX_Y, COL_PTZ_MAX_Z, COL_PTZ_HAS_HOME, COL_ONVIF_USERNAME, COL_ONVIF_PASSWORD, COL_ONVIF_PROFILE, COL_ONVIF_PORT, COL_RECORD_ON_SCHEDULE, COL_RECORDING_SCHEDULE, COL_TAGS, COL_ADMIN_URL, COL_PRIVACY_MODE, - COL_MOTION_TRIGGER_SOURCE + COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE, COL_SUB_STREAM_URL }; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); @@ -951,6 +961,22 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) { stream->motion_trigger_source[0] = '\0'; } + // go2rtc source override + const char *go2rtc_source_override = (const char *)sqlite3_column_text(stmt, COL_GO2RTC_SOURCE_OVERRIDE); + if (go2rtc_source_override) { + safe_strcpy(stream->go2rtc_source_override, go2rtc_source_override, sizeof(stream->go2rtc_source_override), 0); + } else { + stream->go2rtc_source_override[0] = '\0'; + } + + // Sub-stream URL + const char *sub_stream_url_val = (const char *)sqlite3_column_text(stmt, COL_SUB_STREAM_URL); + if (sub_stream_url_val) { + safe_strcpy(stream->sub_stream_url, sub_stream_url_val, sizeof(stream->sub_stream_url), 0); + } else { + stream->sub_stream_url[0] = '\0'; + } + result = 0; } @@ -1002,7 +1028,8 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { "tier_critical_multiplier, tier_important_multiplier, tier_ephemeral_multiplier, storage_priority, " "ptz_enabled, ptz_max_x, ptz_max_y, ptz_max_z, ptz_has_home, " "onvif_username, onvif_password, onvif_profile, onvif_port, " - "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source " + "record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, " + "go2rtc_source_override, sub_stream_url " "FROM streams ORDER BY name;"; // Column index constants (same as get_stream_config_by_name) @@ -1018,7 +1045,7 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { COL_PTZ_ENABLED, COL_PTZ_MAX_X, COL_PTZ_MAX_Y, COL_PTZ_MAX_Z, COL_PTZ_HAS_HOME, COL_ONVIF_USERNAME, COL_ONVIF_PASSWORD, COL_ONVIF_PROFILE, COL_ONVIF_PORT, COL_RECORD_ON_SCHEDULE, COL_RECORDING_SCHEDULE, COL_TAGS, COL_ADMIN_URL, COL_PRIVACY_MODE, - COL_MOTION_TRIGGER_SOURCE + COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE, COL_SUB_STREAM_URL }; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); @@ -1178,6 +1205,22 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) { s->motion_trigger_source[0] = '\0'; } + // go2rtc source override + const char *go2rtc_src_override = (const char *)sqlite3_column_text(stmt, COL_GO2RTC_SOURCE_OVERRIDE); + if (go2rtc_src_override) { + safe_strcpy(s->go2rtc_source_override, go2rtc_src_override, sizeof(s->go2rtc_source_override), 0); + } else { + s->go2rtc_source_override[0] = '\0'; + } + + // Sub-stream URL + const char *sub_url = (const char *)sqlite3_column_text(stmt, COL_SUB_STREAM_URL); + if (sub_url) { + safe_strcpy(s->sub_stream_url, sub_url, sizeof(s->sub_stream_url), 0); + } else { + s->sub_stream_url[0] = '\0'; + } + count++; } diff --git a/src/video/go2rtc/go2rtc_integration.c b/src/video/go2rtc/go2rtc_integration.c index bf7c445f..d974601c 100644 --- a/src/video/go2rtc/go2rtc_integration.c +++ b/src/video/go2rtc/go2rtc_integration.c @@ -1355,19 +1355,38 @@ bool go2rtc_integration_register_all_streams(void) { bool all_success = true; for (int i = 0; i < count; i++) { if (streams[i].enabled) { - log_info("Registering stream %s with go2rtc", streams[i].name); - - // Register the stream with go2rtc - if (!go2rtc_stream_register(streams[i].name, streams[i].url, - streams[i].onvif_username[0] != '\0' ? streams[i].onvif_username : NULL, - streams[i].onvif_password[0] != '\0' ? streams[i].onvif_password : NULL, - streams[i].backchannel_enabled, streams[i].protocol, - streams[i].record_audio)) { - log_error("Failed to register stream %s with go2rtc", streams[i].name); - all_success = false; - // Continue with other streams + // Skip main stream API registration when go2rtc source override is set — + // the main stream is already defined in go2rtc.yaml. + // Sub-stream registration still proceeds via API regardless. + if (streams[i].go2rtc_source_override[0] != '\0') { + log_info("Skipping main stream API registration for %s (has go2rtc source override)", streams[i].name); } else { - log_info("Successfully registered stream %s with go2rtc", streams[i].name); + log_info("Registering stream %s with go2rtc", streams[i].name); + + if (!go2rtc_stream_register(streams[i].name, streams[i].url, + streams[i].onvif_username[0] != '\0' ? streams[i].onvif_username : NULL, + streams[i].onvif_password[0] != '\0' ? streams[i].onvif_password : NULL, + streams[i].backchannel_enabled, streams[i].protocol, + streams[i].record_audio)) { + log_error("Failed to register stream %s with go2rtc", streams[i].name); + all_success = false; + } else { + log_info("Successfully registered stream %s with go2rtc", streams[i].name); + } + } + + // Register sub-stream if configured (low-res for grid view) — + // always via API, even when main stream uses config override. + if (streams[i].sub_stream_url[0] != '\0') { + char sub_name[MAX_STREAM_NAME + 8]; + snprintf(sub_name, sizeof(sub_name), "%s_sub", streams[i].name); + log_info("Registering sub-stream %s with go2rtc", sub_name); + if (!go2rtc_stream_register(sub_name, streams[i].sub_stream_url, + streams[i].onvif_username[0] != '\0' ? streams[i].onvif_username : NULL, + streams[i].onvif_password[0] != '\0' ? streams[i].onvif_password : NULL, + false, streams[i].protocol, false)) { + log_warn("Failed to register sub-stream %s with go2rtc", sub_name); + } } } } @@ -1434,18 +1453,7 @@ bool go2rtc_sync_streams_from_database(void) { skipped++; continue; } - - // Check if stream already exists in go2rtc - if (go2rtc_api_stream_exists(db_streams[i].name)) { - log_debug("Stream %s already exists in go2rtc, skipping", db_streams[i].name); - skipped++; - continue; - } - - // Stream needs to be registered - log_info("Registering missing stream %s with go2rtc", db_streams[i].name); - - // Determine username and password + // Determine username and password (needed for both main and sub-stream) const char *username = NULL; const char *password = NULL; @@ -1456,17 +1464,42 @@ bool go2rtc_sync_streams_from_database(void) { password = db_streams[i].onvif_password; } - // Register the stream - if (!go2rtc_stream_register(db_streams[i].name, db_streams[i].url, - username, password, - db_streams[i].backchannel_enabled, db_streams[i].protocol, - db_streams[i].record_audio)) { - log_error("Failed to register stream %s with go2rtc", db_streams[i].name); - all_success = false; - failed++; + // Skip main stream API sync when override is set (defined in go2rtc.yaml), + // but still fall through to sub-stream registration below. + if (db_streams[i].go2rtc_source_override[0] != '\0') { + log_debug("Skipping main stream API sync for %s (has go2rtc source override)", db_streams[i].name); + skipped++; + } else if (go2rtc_api_stream_exists(db_streams[i].name)) { + log_debug("Stream %s already exists in go2rtc, skipping", db_streams[i].name); + skipped++; } else { - log_info("Successfully synced stream %s to go2rtc", db_streams[i].name); - synced++; + // Stream needs to be registered + log_info("Registering missing stream %s with go2rtc", db_streams[i].name); + + if (!go2rtc_stream_register(db_streams[i].name, db_streams[i].url, + username, password, + db_streams[i].backchannel_enabled, db_streams[i].protocol, + db_streams[i].record_audio)) { + log_error("Failed to register stream %s with go2rtc", db_streams[i].name); + all_success = false; + failed++; + } else { + log_info("Successfully synced stream %s to go2rtc", db_streams[i].name); + synced++; + } + } + + // Register sub-stream if configured — always via API, + // even when main stream uses config override. + if (db_streams[i].sub_stream_url[0] != '\0') { + char sub_name[MAX_STREAM_NAME + 8]; + snprintf(sub_name, sizeof(sub_name), "%s_sub", db_streams[i].name); + if (!go2rtc_api_stream_exists(sub_name)) { + log_info("Registering missing sub-stream %s with go2rtc", sub_name); + go2rtc_stream_register(sub_name, db_streams[i].sub_stream_url, + username, password, + false, db_streams[i].protocol, false); + } } } @@ -1779,6 +1812,10 @@ bool go2rtc_integration_register_stream(const char *stream_name) { return false; } + // Check for go2rtc source override — main stream is defined in go2rtc.yaml + // and doesn't need API registration, but sub-stream still needs it. + bool skip_main = (config.go2rtc_source_override[0] != '\0'); + // Determine username and password // Priority: 1) onvif fields, 2) extracted from URL char username[64] = {0}; @@ -1818,18 +1855,36 @@ bool go2rtc_integration_register_stream(const char *stream_name) { } } - // Register with go2rtc - if (go2rtc_stream_register(stream_name, config.url, + // Register main stream with go2rtc (skip if override is set — defined in YAML) + bool main_ok = true; + if (skip_main) { + log_info("Stream %s has go2rtc source override, skipping main API registration", stream_name); + } else { + if (go2rtc_stream_register(stream_name, config.url, + username[0] != '\0' ? username : NULL, + password[0] != '\0' ? password : NULL, + config.backchannel_enabled, config.protocol, + config.record_audio)) { + log_info("Successfully registered stream %s with go2rtc", stream_name); + } else { + log_warn("Failed to register stream %s with go2rtc", stream_name); + main_ok = false; + } + } + + // Register sub-stream if configured — always via API, + // even when main stream uses config override. + if (config.sub_stream_url[0] != '\0') { + char sub_name[MAX_STREAM_NAME + 8]; + snprintf(sub_name, sizeof(sub_name), "%s_sub", stream_name); + log_info("Registering sub-stream %s with go2rtc", sub_name); + go2rtc_stream_register(sub_name, config.sub_stream_url, username[0] != '\0' ? username : NULL, password[0] != '\0' ? password : NULL, - config.backchannel_enabled, config.protocol, - config.record_audio)) { - log_info("Successfully registered stream %s with go2rtc", stream_name); - return true; + false, config.protocol, false); } - log_warn("Failed to register stream %s with go2rtc", stream_name); - return false; + return main_ok || skip_main; } // ============================================================================ diff --git a/src/video/go2rtc/go2rtc_process.c b/src/video/go2rtc/go2rtc_process.c index 67704108..e8e06659 100644 --- a/src/video/go2rtc/go2rtc_process.c +++ b/src/video/go2rtc/go2rtc_process.c @@ -23,6 +23,25 @@ #include "core/config.h" #include "core/path_utils.h" #include "utils/strings.h" +#include "database/db_core.h" +#include "database/db_streams.h" +#include "database/db_system_settings.h" + +/** + * Escape a string for use inside a YAML double-quoted scalar. + * Handles " → \" and \ → \\. Returns dst. + */ +static char *yaml_escape_string(const char *src, char *dst, size_t dst_size) { + size_t j = 0; + for (size_t i = 0; src[i] && j + 2 < dst_size; i++) { + if (src[i] == '"' || src[i] == '\\') { + dst[j++] = '\\'; + } + dst[j++] = src[i]; + } + dst[j] = '\0'; + return dst; +} // Define PATH_MAX if not defined @@ -697,25 +716,86 @@ bool go2rtc_process_generate_config(const char *config_path, int api_port) { fprintf(config_file, " h264: \"-codec:v libx264 -g:v 30 -preset:v superfast\"\n"); fprintf(config_file, " h265: \"-codec:v libx265 -g:v 30 -preset:v superfast\"\n"); - // Streams section (will be populated dynamically) - fprintf(config_file, "streams:\n"); - fprintf(config_file, " # Streams will be added dynamically\n"); + // Streams section — write overridden streams directly into config, + // other streams will be registered dynamically via the go2rtc API. + fprintf(config_file, "\nstreams:\n"); + { + bool has_overridden = false; + if (get_db_handle() != NULL) { + int ms = g_config.max_streams > 0 ? g_config.max_streams : 32; + stream_config_t *streams = calloc(ms, sizeof(stream_config_t)); + int count = streams ? get_all_stream_configs(streams, ms) : 0; + + for (int i = 0; i < count; i++) { + if (!streams[i].enabled) continue; + if (streams[i].go2rtc_source_override[0] == '\0') continue; + + has_overridden = true; + + // Escape stream name for YAML double-quoted key safety + char escaped_name[MAX_STREAM_NAME * 2]; + yaml_escape_string(streams[i].name, escaped_name, sizeof(escaped_name)); + + const char *override = streams[i].go2rtc_source_override; + bool is_single_line = (strchr(override, '\n') == NULL); + + if (is_single_line) { + // Single URL: write as inline YAML scalar + // "cam": rtsp://camera/stream + fprintf(config_file, " \"%s\": %s\n", escaped_name, override); + } else { + // Multi-line: write as indented block under the key + // "cam": + // - rtsp://camera/main + // - ffmpeg:cam#video=h264 + fprintf(config_file, " \"%s\":\n", escaped_name); + const char *p = override; + while (*p) { + const char *eol = strchr(p, '\n'); + if (eol) { + fprintf(config_file, " %.*s\n", (int)(eol - p), p); + p = eol + 1; + } else { + fprintf(config_file, " %s\n", p); + break; + } + } + } + } + free(streams); + } + if (!has_overridden) { + fprintf(config_file, " # Streams will be added dynamically via API\n"); + } + } + + // Global go2rtc config override from system settings + { + char global_override[4096] = {0}; + if (get_db_handle() != NULL + && db_get_system_setting("go2rtc_config_override", global_override, sizeof(global_override)) == 0 + && global_override[0] != '\0') { + fprintf(config_file, "\n# User config override\n"); + fprintf(config_file, "%s\n", global_override); + } + } fclose(config_file); log_info("Generated go2rtc configuration file: %s", config_path); - // Print the content of the config file for debugging + // Print the content of the config file at DEBUG level to avoid + // leaking credentials from overrides into production logs. FILE *read_file = fopen(config_path, "r"); if (read_file) { char line[256]; - log_info("Contents of go2rtc config file:"); + log_debug("Contents of go2rtc config file:"); while (fgets(line, sizeof(line), read_file)) { // Remove newline character size_t len = strlen(line); if (len > 0 && line[len-1] == '\n') { line[len-1] = '\0'; } - log_info(" %s", line); + log_debug(" %s", line); } fclose(read_file); } diff --git a/src/web/api_handlers_settings.c b/src/web/api_handlers_settings.c index 38ebd36f..6d71fe55 100644 --- a/src/web/api_handlers_settings.c +++ b/src/web/api_handlers_settings.c @@ -22,6 +22,7 @@ #include "database/db_core.h" #include "database/db_streams.h" #include "database/db_auth.h" +#include "database/db_system_settings.h" #include "video/stream_manager.h" #include "video/streams.h" #include "video/mp4_recording.h" @@ -361,6 +362,16 @@ void handle_get_settings(const http_request_t *req, http_response_t *res) { cJSON_AddStringToObject(settings, "go2rtc_ice_servers", g_config.go2rtc_ice_servers); cJSON_AddBoolToObject(settings, "go2rtc_force_native_hls", g_config.go2rtc_force_native_hls); + // go2rtc global config override (stored in system_settings table) + { + char go2rtc_config_override_buf[4096] = {0}; + if (db_get_system_setting("go2rtc_config_override", go2rtc_config_override_buf, sizeof(go2rtc_config_override_buf)) == 0) { + cJSON_AddStringToObject(settings, "go2rtc_config_override", go2rtc_config_override_buf); + } else { + cJSON_AddStringToObject(settings, "go2rtc_config_override", ""); + } + } + // MQTT settings cJSON_AddBoolToObject(settings, "mqtt_enabled", g_config.mqtt_enabled); cJSON_AddStringToObject(settings, "mqtt_broker_host", g_config.mqtt_broker_host); @@ -949,6 +960,21 @@ void handle_post_settings(const http_request_t *req, http_response_t *res) { log_info("Updated go2rtc_force_native_hls: %s", g_config.go2rtc_force_native_hls ? "true" : "false"); } + // go2rtc global config override (stored in system_settings) + cJSON *go2rtc_config_override = cJSON_GetObjectItem(settings, "go2rtc_config_override"); + if (go2rtc_config_override && cJSON_IsString(go2rtc_config_override)) { + size_t override_len = strlen(go2rtc_config_override->valuestring); + if (override_len >= 4096) { + log_error("go2rtc_config_override too long (%zu bytes, max 4095)", override_len); + } else if (db_set_system_setting("go2rtc_config_override", go2rtc_config_override->valuestring) != 0) { + log_error("Failed to save go2rtc_config_override to system_settings"); + } else { + log_info("Updated go2rtc_config_override"); + go2rtc_config_changed = true; + go2rtc_becoming_enabled = g_config.go2rtc_enabled; + } + } + // MQTT enabled cJSON *mqtt_enabled = cJSON_GetObjectItem(settings, "mqtt_enabled"); if (mqtt_enabled && cJSON_IsBool(mqtt_enabled)) { @@ -1624,58 +1650,63 @@ void handle_post_settings(const http_request_t *req, http_response_t *res) { log_info("Database path after reload: %s", g_config.db_path); } - // If go2rtc-related settings changed, spawn background thread - // to handle start/stop (avoids blocking the API response for 5-15+ seconds) - if (go2rtc_config_changed) { - go2rtc_settings_task_t *task = calloc(1, sizeof(go2rtc_settings_task_t)); - if (task) { - task->becoming_enabled = go2rtc_becoming_enabled; - - pthread_t thread_id; - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - - if (pthread_create(&thread_id, &attr, - (void *(*)(void *))go2rtc_settings_worker, task) != 0) { - log_error("Failed to create go2rtc settings worker thread"); - free(task); - } else { - log_info("go2rtc settings change dispatched to background thread (becoming_%s)", - go2rtc_becoming_enabled ? "enabled" : "disabled"); - } - pthread_attr_destroy(&attr); + } else { + log_info("No settings changed"); + } + + // Dispatch go2rtc and MQTT background workers outside settings_changed + // so that DB-backed overrides (go2rtc_config_override in system_settings) + // trigger a restart even when no g_config field changed. + + // If go2rtc-related settings changed, spawn background thread + // to handle start/stop (avoids blocking the API response for 5-15+ seconds) + if (go2rtc_config_changed) { + go2rtc_settings_task_t *task = calloc(1, sizeof(go2rtc_settings_task_t)); + if (task) { + task->becoming_enabled = go2rtc_becoming_enabled; + + pthread_t thread_id; + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + + if (pthread_create(&thread_id, &attr, + (void *(*)(void *))go2rtc_settings_worker, task) != 0) { + log_error("Failed to create go2rtc settings worker thread"); + free(task); } else { - log_error("Failed to allocate go2rtc settings task"); + log_info("go2rtc settings change dispatched to background thread (becoming_%s)", + go2rtc_becoming_enabled ? "enabled" : "disabled"); } + pthread_attr_destroy(&attr); + } else { + log_error("Failed to allocate go2rtc settings task"); } + } - // If MQTT-related settings changed, spawn background thread - // to handle cleanup + reinit (avoids blocking the API response) - if (mqtt_config_changed) { - mqtt_settings_task_t *task = calloc(1, sizeof(mqtt_settings_task_t)); - if (task) { - task->mqtt_now_enabled = g_config.mqtt_enabled; - - pthread_t thread_id; - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - - if (pthread_create(&thread_id, &attr, - (void *(*)(void *))mqtt_settings_worker, task) != 0) { - log_error("Failed to create MQTT settings worker thread"); - free(task); - } else { - log_info("MQTT settings change dispatched to background thread"); - } - pthread_attr_destroy(&attr); + // If MQTT-related settings changed, spawn background thread + // to handle cleanup + reinit (avoids blocking the API response) + if (mqtt_config_changed) { + mqtt_settings_task_t *task = calloc(1, sizeof(mqtt_settings_task_t)); + if (task) { + task->mqtt_now_enabled = g_config.mqtt_enabled; + + pthread_t thread_id; + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + + if (pthread_create(&thread_id, &attr, + (void *(*)(void *))mqtt_settings_worker, task) != 0) { + log_error("Failed to create MQTT settings worker thread"); + free(task); } else { - log_error("Failed to allocate MQTT settings task"); + log_info("MQTT settings change dispatched to background thread"); } + pthread_attr_destroy(&attr); + } else { + log_error("Failed to allocate MQTT settings task"); } - } else { - log_info("No settings changed"); } // Clean up diff --git a/src/web/api_handlers_streams_get.c b/src/web/api_handlers_streams_get.c index 470fa0cd..b6700a52 100644 --- a/src/web/api_handlers_streams_get.c +++ b/src/web/api_handlers_streams_get.c @@ -236,6 +236,8 @@ void handle_get_streams(const http_request_t *req, http_response_t *res) { cJSON_AddStringToObject(stream_obj, "admin_url", db_streams[i].admin_url); cJSON_AddBoolToObject(stream_obj, "privacy_mode", db_streams[i].privacy_mode); cJSON_AddStringToObject(stream_obj, "motion_trigger_source", db_streams[i].motion_trigger_source); + cJSON_AddStringToObject(stream_obj, "go2rtc_source_override", db_streams[i].go2rtc_source_override); + cJSON_AddStringToObject(stream_obj, "sub_stream_url", db_streams[i].sub_stream_url); // Get stream status stream_handle_t stream = get_stream_by_name(db_streams[i].name); @@ -392,6 +394,8 @@ void handle_get_stream(const http_request_t *req, http_response_t *res) { cJSON_AddStringToObject(stream_obj, "admin_url", config.admin_url); cJSON_AddBoolToObject(stream_obj, "privacy_mode", config.privacy_mode); cJSON_AddStringToObject(stream_obj, "motion_trigger_source", config.motion_trigger_source); + cJSON_AddStringToObject(stream_obj, "go2rtc_source_override", config.go2rtc_source_override); + cJSON_AddStringToObject(stream_obj, "sub_stream_url", config.sub_stream_url); // Get stream status — resolve using UDT state so that go2rtc-managed // streams (which stay INACTIVE in the state manager) report accurately. @@ -542,6 +546,8 @@ void handle_get_stream_full(const http_request_t *req, http_response_t *res) { cJSON_AddStringToObject(stream_obj, "admin_url", config.admin_url); cJSON_AddBoolToObject(stream_obj, "privacy_mode", config.privacy_mode); cJSON_AddStringToObject(stream_obj, "motion_trigger_source", config.motion_trigger_source); + cJSON_AddStringToObject(stream_obj, "go2rtc_source_override", config.go2rtc_source_override); + cJSON_AddStringToObject(stream_obj, "sub_stream_url", config.sub_stream_url); // Status — resolve using UDT state for accurate reporting when go2rtc // manages the stream (state manager stays INACTIVE/STOPPED at startup). diff --git a/src/web/api_handlers_streams_modify.c b/src/web/api_handlers_streams_modify.c index 393368dd..b7ef233d 100644 --- a/src/web/api_handlers_streams_modify.c +++ b/src/web/api_handlers_streams_modify.c @@ -56,6 +56,8 @@ typedef struct { bool has_streaming_enabled; // Whether streaming_enabled flag was provided bool non_dynamic_config_changed; // Whether non-dynamic fields changed bool credentials_changed; // Whether ONVIF credentials changed + bool go2rtc_override_changed; // Whether go2rtc_source_override changed + bool sub_stream_changed; // Whether sub_stream_url changed } put_stream_task_t; static void format_stream_capacity_error(char *buf, size_t buf_size, @@ -359,6 +361,34 @@ static void put_stream_worker(put_stream_task_t *task) { usleep(500000); // 500ms } + // If go2rtc_source_override changed, regenerate config and fully re-register. + // The override is baked into go2rtc.yaml so a config regen + re-registration is needed. + if (task->go2rtc_override_changed) { + log_info("go2rtc source override changed for stream %s, re-registering with go2rtc", task->config.name); + go2rtc_api_remove_stream(task->config.name); + go2rtc_integration_register_stream(task->config.name); + usleep(500000); + } + + // If sub-stream URL changed, register or unregister the {name}_sub stream + if (task->sub_stream_changed) { + char sub_name[MAX_STREAM_NAME + 8]; + snprintf(sub_name, sizeof(sub_name), "%s_sub", task->config.name); + // Always remove old sub-stream first + go2rtc_api_remove_stream(sub_name); + // Re-register if new sub-stream URL is set + if (task->config.sub_stream_url[0] != '\0') { + log_info("Registering updated sub-stream %s with go2rtc", sub_name); + go2rtc_stream_register(sub_name, task->config.sub_stream_url, + task->config.onvif_username[0] != '\0' ? task->config.onvif_username : NULL, + task->config.onvif_password[0] != '\0' ? task->config.onvif_password : NULL, + false, task->config.protocol, false); + } else { + log_info("Sub-stream %s removed from go2rtc", sub_name); + } + usleep(500000); + } + // Start stream if enabled (AFTER go2rtc has been updated) if (task->config.enabled) { log_info("Starting stream %s after configuration update", task->config.name); @@ -658,6 +688,24 @@ void handle_post_stream(const http_request_t *req, http_response_t *res) { config.motion_trigger_source[0] = '\0'; } + // Parse go2rtc source override + cJSON *go2rtc_source_override_post = cJSON_GetObjectItem(stream_json, "go2rtc_source_override"); + if (go2rtc_source_override_post && cJSON_IsString(go2rtc_source_override_post)) { + safe_strcpy(config.go2rtc_source_override, go2rtc_source_override_post->valuestring, + sizeof(config.go2rtc_source_override), 0); + } else { + config.go2rtc_source_override[0] = '\0'; + } + + // Parse sub-stream URL + cJSON *sub_stream_url_post = cJSON_GetObjectItem(stream_json, "sub_stream_url"); + if (sub_stream_url_post && cJSON_IsString(sub_stream_url_post)) { + safe_strcpy(config.sub_stream_url, sub_stream_url_post->valuestring, + sizeof(config.sub_stream_url), 0); + } else { + config.sub_stream_url[0] = '\0'; + } + // Check if isOnvif flag is set in the request cJSON *is_onvif = cJSON_GetObjectItem(stream_json, "isOnvif"); if (is_onvif && cJSON_IsBool(is_onvif)) { @@ -880,6 +928,8 @@ void handle_put_stream(const http_request_t *req, http_response_t *res) { // Update configuration with provided values bool config_changed = false; bool requires_restart = false; // Flag for changes that require stream restart + bool go2rtc_override_changed = false; // Track go2rtc source override changes + bool sub_stream_changed = false; // Track sub-stream URL changes bool has_record = false; // Track if record flag was provided bool has_streaming_enabled = false; // Track if streaming_enabled flag was provided bool non_dynamic_config_changed = false; // Track if non-dynamic fields changed @@ -1265,6 +1315,50 @@ void handle_put_stream(const http_request_t *req, http_response_t *res) { } } + // Parse go2rtc source override + cJSON *go2rtc_source_override_put = cJSON_GetObjectItem(stream_json, "go2rtc_source_override"); + if (go2rtc_source_override_put && cJSON_IsString(go2rtc_source_override_put)) { + if (strncmp(config.go2rtc_source_override, go2rtc_source_override_put->valuestring, + sizeof(config.go2rtc_source_override) - 1) != 0) { + safe_strcpy(config.go2rtc_source_override, go2rtc_source_override_put->valuestring, + sizeof(config.go2rtc_source_override), 0); + config_changed = true; + requires_restart = true; + go2rtc_override_changed = true; + log_info("go2rtc source override changed for stream %s", config.name); + } + } else if (go2rtc_source_override_put && cJSON_IsNull(go2rtc_source_override_put)) { + if (config.go2rtc_source_override[0] != '\0') { + config.go2rtc_source_override[0] = '\0'; + config_changed = true; + requires_restart = true; + go2rtc_override_changed = true; + log_info("go2rtc source override cleared for stream %s", config.name); + } + } + + // Parse sub-stream URL + cJSON *sub_stream_url_put = cJSON_GetObjectItem(stream_json, "sub_stream_url"); + if (sub_stream_url_put && cJSON_IsString(sub_stream_url_put)) { + if (strncmp(config.sub_stream_url, sub_stream_url_put->valuestring, + sizeof(config.sub_stream_url) - 1) != 0) { + safe_strcpy(config.sub_stream_url, sub_stream_url_put->valuestring, + sizeof(config.sub_stream_url), 0); + config_changed = true; + requires_restart = true; + sub_stream_changed = true; + log_info("Sub-stream URL changed for stream %s", config.name); + } + } else if (sub_stream_url_put && cJSON_IsNull(sub_stream_url_put)) { + if (config.sub_stream_url[0] != '\0') { + config.sub_stream_url[0] = '\0'; + config_changed = true; + requires_restart = true; + sub_stream_changed = true; + log_info("Sub-stream URL cleared for stream %s", config.name); + } + } + // Update is_onvif flag based on request or URL bool original_is_onvif = config.is_onvif; @@ -1515,6 +1609,8 @@ void handle_put_stream(const http_request_t *req, http_response_t *res) { task->has_streaming_enabled = has_streaming_enabled; task->non_dynamic_config_changed = non_dynamic_config_changed; task->credentials_changed = credentials_changed; + task->go2rtc_override_changed = go2rtc_override_changed; + task->sub_stream_changed = sub_stream_changed; log_info("Detection settings before update - Model: %s, Threshold: %.2f, Interval: %d, Pre-buffer: %d, Post-buffer: %d", config.detection_model, config.detection_threshold, config.detection_interval, diff --git a/tests/unit/test_db_streams.c b/tests/unit/test_db_streams.c index 76bd56be..cf25b3ce 100644 --- a/tests/unit/test_db_streams.c +++ b/tests/unit/test_db_streams.c @@ -272,6 +272,142 @@ void test_motion_trigger_source_in_get_all(void) { TEST_ASSERT_TRUE(found_ptz); } +/* ================================================================ + * go2rtc source override tests + * ================================================================ */ + +void test_go2rtc_source_override_defaults_empty(void) { + stream_config_t s = make_stream("cam_g2r_def", true); + add_stream_config(&s); + + stream_config_t got; + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name("cam_g2r_def", &got)); + TEST_ASSERT_EQUAL_STRING("", got.go2rtc_source_override); +} + +void test_go2rtc_source_override_round_trip(void) { + stream_config_t s = make_stream("cam_g2r_rt", true); + safe_strcpy(s.go2rtc_source_override, + "- rtsp://admin:pass@cam/main#transport=tcp\n- ffmpeg:cam_g2r_rt#video=h264#hardware", + sizeof(s.go2rtc_source_override), 0); + add_stream_config(&s); + + stream_config_t got; + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name("cam_g2r_rt", &got)); + TEST_ASSERT_EQUAL_STRING( + "- rtsp://admin:pass@cam/main#transport=tcp\n- ffmpeg:cam_g2r_rt#video=h264#hardware", + got.go2rtc_source_override); +} + +void test_go2rtc_source_override_update(void) { + stream_config_t s = make_stream("cam_g2r_upd", true); + safe_strcpy(s.go2rtc_source_override, "rtsp://old/stream", sizeof(s.go2rtc_source_override), 0); + add_stream_config(&s); + + safe_strcpy(s.go2rtc_source_override, "rtsp://new/stream#transport=tcp", sizeof(s.go2rtc_source_override), 0); + TEST_ASSERT_EQUAL_INT(0, update_stream_config("cam_g2r_upd", &s)); + + stream_config_t got; + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name("cam_g2r_upd", &got)); + TEST_ASSERT_EQUAL_STRING("rtsp://new/stream#transport=tcp", got.go2rtc_source_override); +} + +void test_go2rtc_source_override_in_get_all(void) { + stream_config_t plain = make_stream("cam_g2r_plain", true); + stream_config_t custom = make_stream("cam_g2r_custom", true); + safe_strcpy(custom.go2rtc_source_override, "ffmpeg:cam_g2r_custom#video=h264", + sizeof(custom.go2rtc_source_override), 0); + add_stream_config(&plain); + add_stream_config(&custom); + + stream_config_t out[10]; + int n = get_all_stream_configs(out, 10); + TEST_ASSERT_EQUAL_INT(2, n); + + bool found = false; + for (int i = 0; i < n; i++) { + if (strcmp(out[i].name, "cam_g2r_custom") == 0) { + TEST_ASSERT_EQUAL_STRING("ffmpeg:cam_g2r_custom#video=h264", out[i].go2rtc_source_override); + found = true; + } else if (strcmp(out[i].name, "cam_g2r_plain") == 0) { + TEST_ASSERT_EQUAL_STRING("", out[i].go2rtc_source_override); + } + } + TEST_ASSERT_TRUE(found); +} + +void test_go2rtc_source_override_clear(void) { + stream_config_t s = make_stream("cam_g2r_clr", true); + safe_strcpy(s.go2rtc_source_override, "rtsp://custom/stream", sizeof(s.go2rtc_source_override), 0); + add_stream_config(&s); + + s.go2rtc_source_override[0] = '\0'; + TEST_ASSERT_EQUAL_INT(0, update_stream_config("cam_g2r_clr", &s)); + + stream_config_t got; + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name("cam_g2r_clr", &got)); + TEST_ASSERT_EQUAL_STRING("", got.go2rtc_source_override); +} + +/* ================================================================ + * sub-stream URL tests + * ================================================================ */ + +void test_sub_stream_url_defaults_empty(void) { + stream_config_t s = make_stream("cam_sub_def", true); + add_stream_config(&s); + + stream_config_t got; + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name("cam_sub_def", &got)); + TEST_ASSERT_EQUAL_STRING("", got.sub_stream_url); +} + +void test_sub_stream_url_round_trip(void) { + stream_config_t s = make_stream("cam_sub_rt", true); + safe_strcpy(s.sub_stream_url, "rtsp://camera/substream", sizeof(s.sub_stream_url), 0); + add_stream_config(&s); + + stream_config_t got; + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name("cam_sub_rt", &got)); + TEST_ASSERT_EQUAL_STRING("rtsp://camera/substream", got.sub_stream_url); +} + +void test_sub_stream_url_update(void) { + stream_config_t s = make_stream("cam_sub_upd", true); + safe_strcpy(s.sub_stream_url, "rtsp://camera/sub_old", sizeof(s.sub_stream_url), 0); + add_stream_config(&s); + + safe_strcpy(s.sub_stream_url, "rtsp://camera/sub_new", sizeof(s.sub_stream_url), 0); + TEST_ASSERT_EQUAL_INT(0, update_stream_config("cam_sub_upd", &s)); + + stream_config_t got; + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name("cam_sub_upd", &got)); + TEST_ASSERT_EQUAL_STRING("rtsp://camera/sub_new", got.sub_stream_url); +} + +void test_sub_stream_url_in_get_all(void) { + stream_config_t main_only = make_stream("cam_sub_main", true); + stream_config_t with_sub = make_stream("cam_sub_both", true); + safe_strcpy(with_sub.sub_stream_url, "rtsp://camera/low", sizeof(with_sub.sub_stream_url), 0); + add_stream_config(&main_only); + add_stream_config(&with_sub); + + stream_config_t out[10]; + int n = get_all_stream_configs(out, 10); + TEST_ASSERT_EQUAL_INT(2, n); + + bool found = false; + for (int i = 0; i < n; i++) { + if (strcmp(out[i].name, "cam_sub_both") == 0) { + TEST_ASSERT_EQUAL_STRING("rtsp://camera/low", out[i].sub_stream_url); + found = true; + } else if (strcmp(out[i].name, "cam_sub_main") == 0) { + TEST_ASSERT_EQUAL_STRING("", out[i].sub_stream_url); + } + } + TEST_ASSERT_TRUE(found); +} + void test_repair_onvif_embedded_credentials_migration_normalizes_legacy_rows(void) { sqlite3 *db = get_db_handle(); exec_sql_or_fail(db, "DELETE FROM streams;"); @@ -324,6 +460,15 @@ int main(void) { RUN_TEST(test_motion_trigger_source_round_trip); RUN_TEST(test_motion_trigger_source_update); RUN_TEST(test_motion_trigger_source_in_get_all); + RUN_TEST(test_go2rtc_source_override_defaults_empty); + RUN_TEST(test_go2rtc_source_override_round_trip); + RUN_TEST(test_go2rtc_source_override_update); + RUN_TEST(test_go2rtc_source_override_in_get_all); + RUN_TEST(test_go2rtc_source_override_clear); + RUN_TEST(test_sub_stream_url_defaults_empty); + RUN_TEST(test_sub_stream_url_round_trip); + RUN_TEST(test_sub_stream_url_update); + RUN_TEST(test_sub_stream_url_in_get_all); int result = UNITY_END(); shutdown_database(); diff --git a/web/js/components/preact/HLSVideoCell.jsx b/web/js/components/preact/HLSVideoCell.jsx index 3b61e71b..95205415 100644 --- a/web/js/components/preact/HLSVideoCell.jsx +++ b/web/js/components/preact/HLSVideoCell.jsx @@ -31,6 +31,7 @@ import Hls from 'hls.js'; export function HLSVideoCell({ stream, streamId, + useSubStream = false, onToggleFullscreen, initDelay = 0, showLabels = true, @@ -123,6 +124,9 @@ export function HLSVideoCell({ console.log(`[HLS ${stream.name}] useEffect triggered, videoRef:`, !!videoRef.current, 'retryCount:', retryCount, 'initDelay:', initDelay); + // Effective stream name for go2rtc source — use sub-stream in grid view + const effectiveName = useSubStream ? `${stream.name}_sub` : stream.name; + // Track if component is still mounted - using ref for stable access in callbacks let isMounted = true; let initTimeout = null; @@ -183,7 +187,7 @@ export function HLSVideoCell({ // Build the HLS stream URL using go2rtc's dynamic HLS endpoint // Using &mp4=flac for best codec compatibility (H264/H265 + AAC/PCMA/PCMU/PCM) - hlsStreamUrl = `${go2rtcBaseUrl}/api/stream.m3u8?src=${encodeURIComponent(stream.name)}&mp4=flac`; + hlsStreamUrl = `${go2rtcBaseUrl}/api/stream.m3u8?src=${encodeURIComponent(effectiveName)}&mp4=flac`; usingGo2rtc = true; console.log(`[HLS ${stream.name}] Using go2rtc HLS: ${hlsStreamUrl}`); console.log(`[HLS ${stream.name}] go2rtc base URL: ${go2rtcBaseUrl}`); diff --git a/web/js/components/preact/LiveView.jsx b/web/js/components/preact/LiveView.jsx index 47724f35..1bf9dcd2 100644 --- a/web/js/components/preact/LiveView.jsx +++ b/web/js/components/preact/LiveView.jsx @@ -773,6 +773,7 @@ export function LiveView({isWebRTCDisabled}) { )} {t('settings.iceServersHelp')} + +
+ +
+