Skip to content

Commit 10973a3

Browse files
matteiusclaude
andcommitted
Add sub-stream support and address Copilot review feedback
Sub-stream support (closes discussion #366): - Add sub_stream_url field to stream config, coupled into migration 0040 - Register sub-streams with go2rtc as "{name}_sub" when URL is provided - Frontend grid view uses sub-stream (low-res) when available; fullscreen and recording always use the main stream - Sub-stream URL field added to stream config modal (Basic Information) - MSE, HLS, and WebRTC video cells accept useSubStream prop Copilot review fixes: - Single-URL overrides now emit valid inline YAML scalar form ("cam": rtsp://...) instead of block form - Stream names are YAML-escaped in double-quoted keys (handles " and \) - DB calls in config generation guarded with get_db_handle() != NULL to avoid noisy errors when DB isn't initialized yet - go2rtc_config_override restart dispatch moved outside if(settings_changed) so DB-backed overrides actually trigger a go2rtc restart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ee8407b commit 10973a3

19 files changed

Lines changed: 335 additions & 95 deletions
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
-- Add go2rtc_source_override column to streams table
2-
-- When non-empty, this value is written directly into go2rtc.yaml streams
3-
-- section instead of auto-constructing the source URL from the stream URL.
4-
-- Supports single source URLs or multi-source YAML lists for advanced
5-
-- go2rtc features like failover, transcoding, and hardware acceleration.
1+
-- Add go2rtc_source_override and sub_stream_url columns to streams table
2+
--
3+
-- go2rtc_source_override: when non-empty, written directly into go2rtc.yaml
4+
-- streams section instead of auto-constructing the source URL.
5+
--
6+
-- sub_stream_url: optional low-resolution stream URL used for the dashboard
7+
-- grid view while the main URL is used for recording and fullscreen viewing.
68

79
-- migrate:up
810
ALTER TABLE streams ADD COLUMN go2rtc_source_override TEXT DEFAULT '';
11+
ALTER TABLE streams ADD COLUMN sub_stream_url TEXT DEFAULT '';
912

1013
-- migrate:down
1114
-- SQLite does not support DROP COLUMN in older versions; migration is left intentionally empty.

include/core/config.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ typedef struct {
103103
// streams section instead of auto-constructing the source URL.
104104
// Supports single URLs or multi-source YAML lists (e.g. "- rtsp://cam/main\n- ffmpeg:cam#video=h264")
105105
char go2rtc_source_override[2048];
106+
107+
// Sub-stream URL: optional low-resolution stream for dashboard grid view.
108+
// When non-empty, registered with go2rtc as "{name}_sub" and used by the
109+
// frontend in grid view while the main URL is used for fullscreen/recording.
110+
char sub_stream_url[MAX_URL_LENGTH];
106111
} stream_config_t;
107112

108113
// Size of recording schedule text buffer: 168 values + 167 commas + null terminator

include/database/db_embedded_migrations.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,8 @@ static const char migration_0039_down[] =
624624
"SELECT 1;";
625625

626626
static const char migration_0040_up[] =
627-
"ALTER TABLE streams ADD COLUMN go2rtc_source_override TEXT DEFAULT '';";
627+
"ALTER TABLE streams ADD COLUMN go2rtc_source_override TEXT DEFAULT '';\n"
628+
"ALTER TABLE streams ADD COLUMN sub_stream_url TEXT DEFAULT '';";
628629

629630
static const char migration_0040_down[] =
630631
"SELECT 1;";
@@ -905,7 +906,7 @@ static const migration_t embedded_migrations_data[] = {
905906
},
906907
{
907908
.version = "0040",
908-
.description = "add_go2rtc_source_override",
909+
.description = "add_go2rtc_source_override_and_sub_stream_url",
909910
.sql_up = migration_0040_up,
910911
.sql_down = migration_0040_down,
911912
.is_embedded = true

src/database/db_streams.c

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ uint64_t add_stream_config(const stream_config_t *stream) {
132132
"ptz_enabled = ?, ptz_max_x = ?, ptz_max_y = ?, ptz_max_z = ?, ptz_has_home = ?, "
133133
"onvif_username = ?, onvif_password = ?, onvif_profile = ?, onvif_port = ?, "
134134
"record_on_schedule = ?, recording_schedule = ?, tags = ?, admin_url = ?, "
135-
"privacy_mode = ?, motion_trigger_source = ?, go2rtc_source_override = ? "
135+
"privacy_mode = ?, motion_trigger_source = ?, go2rtc_source_override = ?, "
136+
"sub_stream_url = ? "
136137
"WHERE id = ?;";
137138

138139
rc = sqlite3_prepare_v2(db, update_sql, -1, &stmt, NULL);
@@ -215,9 +216,10 @@ uint64_t add_stream_config(const stream_config_t *stream) {
215216
sqlite3_bind_int(stmt, 44, stream->privacy_mode ? 1 : 0);
216217
sqlite3_bind_text(stmt, 45, stream->motion_trigger_source, -1, SQLITE_STATIC);
217218
sqlite3_bind_text(stmt, 46, stream->go2rtc_source_override, -1, SQLITE_STATIC);
219+
sqlite3_bind_text(stmt, 47, stream->sub_stream_url, -1, SQLITE_STATIC);
218220

219221
// Bind ID parameter
220-
sqlite3_bind_int64(stmt, 47, (sqlite3_int64)existing_id);
222+
sqlite3_bind_int64(stmt, 48, (sqlite3_int64)existing_id);
221223

222224
// Execute statement
223225
rc = sqlite3_step(stmt);
@@ -265,8 +267,9 @@ uint64_t add_stream_config(const stream_config_t *stream) {
265267
"tier_critical_multiplier, tier_important_multiplier, tier_ephemeral_multiplier, storage_priority, "
266268
"ptz_enabled, ptz_max_x, ptz_max_y, ptz_max_z, ptz_has_home, "
267269
"onvif_username, onvif_password, onvif_profile, onvif_port, "
268-
"record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, go2rtc_source_override) "
269-
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);";
270+
"record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, "
271+
"go2rtc_source_override, sub_stream_url) "
272+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);";
270273

271274
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
272275
if (rc != SQLITE_OK) {
@@ -343,12 +346,13 @@ uint64_t add_stream_config(const stream_config_t *stream) {
343346
serialize_recording_schedule(stream->recording_schedule, insert_schedule_buf, sizeof(insert_schedule_buf));
344347
sqlite3_bind_text(stmt, 42, insert_schedule_buf, -1, SQLITE_TRANSIENT);
345348

346-
// Bind tags, admin URL, privacy_mode, motion_trigger_source, and go2rtc override parameters
349+
// Bind tags, admin URL, privacy_mode, motion_trigger_source, go2rtc override, and sub-stream parameters
347350
sqlite3_bind_text(stmt, 43, stream->tags, -1, SQLITE_STATIC);
348351
sqlite3_bind_text(stmt, 44, stream->admin_url, -1, SQLITE_STATIC);
349352
sqlite3_bind_int(stmt, 45, stream->privacy_mode ? 1 : 0);
350353
sqlite3_bind_text(stmt, 46, stream->motion_trigger_source, -1, SQLITE_STATIC);
351354
sqlite3_bind_text(stmt, 47, stream->go2rtc_source_override, -1, SQLITE_STATIC);
355+
sqlite3_bind_text(stmt, 48, stream->sub_stream_url, -1, SQLITE_STATIC);
352356

353357
// Execute statement
354358
rc = sqlite3_step(stmt);
@@ -419,7 +423,8 @@ int update_stream_config(const char *name, const stream_config_t *stream) {
419423
"ptz_enabled = ?, ptz_max_x = ?, ptz_max_y = ?, ptz_max_z = ?, ptz_has_home = ?, "
420424
"onvif_username = ?, onvif_password = ?, onvif_profile = ?, onvif_port = ?, "
421425
"record_on_schedule = ?, recording_schedule = ?, tags = ?, admin_url = ?, privacy_mode = ?, "
422-
"motion_trigger_source = ?, go2rtc_source_override = ? "
426+
"motion_trigger_source = ?, go2rtc_source_override = ?, "
427+
"sub_stream_url = ? "
423428
"WHERE name = ?;";
424429

425430
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
@@ -497,15 +502,16 @@ int update_stream_config(const char *name, const stream_config_t *stream) {
497502
serialize_recording_schedule(stream->recording_schedule, update_schedule_buf, sizeof(update_schedule_buf));
498503
sqlite3_bind_text(stmt, 42, update_schedule_buf, -1, SQLITE_TRANSIENT);
499504

500-
// Bind tags, admin URL, privacy_mode, motion_trigger_source, and go2rtc override parameters
505+
// Bind tags, admin URL, privacy_mode, motion_trigger_source, go2rtc override, and sub-stream parameters
501506
sqlite3_bind_text(stmt, 43, stream->tags, -1, SQLITE_STATIC);
502507
sqlite3_bind_text(stmt, 44, stream->admin_url, -1, SQLITE_STATIC);
503508
sqlite3_bind_int(stmt, 45, stream->privacy_mode ? 1 : 0);
504509
sqlite3_bind_text(stmt, 46, stream->motion_trigger_source, -1, SQLITE_STATIC);
505510
sqlite3_bind_text(stmt, 47, stream->go2rtc_source_override, -1, SQLITE_STATIC);
511+
sqlite3_bind_text(stmt, 48, stream->sub_stream_url, -1, SQLITE_STATIC);
506512

507513
// Bind the WHERE clause parameter
508-
sqlite3_bind_text(stmt, 48, name, -1, SQLITE_STATIC);
514+
sqlite3_bind_text(stmt, 49, name, -1, SQLITE_STATIC);
509515

510516
// Execute statement
511517
rc = sqlite3_step(stmt);
@@ -778,7 +784,7 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) {
778784
"ptz_enabled, ptz_max_x, ptz_max_y, ptz_max_z, ptz_has_home, "
779785
"onvif_username, onvif_password, onvif_profile, onvif_port, "
780786
"record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, "
781-
"go2rtc_source_override "
787+
"go2rtc_source_override, sub_stream_url "
782788
"FROM streams WHERE name = ?;";
783789

784790
// Column index constants for readability
@@ -794,7 +800,7 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) {
794800
COL_PTZ_ENABLED, COL_PTZ_MAX_X, COL_PTZ_MAX_Y, COL_PTZ_MAX_Z, COL_PTZ_HAS_HOME,
795801
COL_ONVIF_USERNAME, COL_ONVIF_PASSWORD, COL_ONVIF_PROFILE, COL_ONVIF_PORT,
796802
COL_RECORD_ON_SCHEDULE, COL_RECORDING_SCHEDULE, COL_TAGS, COL_ADMIN_URL, COL_PRIVACY_MODE,
797-
COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE
803+
COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE, COL_SUB_STREAM_URL
798804
};
799805

800806
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
@@ -963,6 +969,14 @@ int get_stream_config_by_name(const char *name, stream_config_t *stream) {
963969
stream->go2rtc_source_override[0] = '\0';
964970
}
965971

972+
// Sub-stream URL
973+
const char *sub_stream_url_val = (const char *)sqlite3_column_text(stmt, COL_SUB_STREAM_URL);
974+
if (sub_stream_url_val) {
975+
safe_strcpy(stream->sub_stream_url, sub_stream_url_val, sizeof(stream->sub_stream_url), 0);
976+
} else {
977+
stream->sub_stream_url[0] = '\0';
978+
}
979+
966980
result = 0;
967981
}
968982

@@ -1015,7 +1029,7 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) {
10151029
"ptz_enabled, ptz_max_x, ptz_max_y, ptz_max_z, ptz_has_home, "
10161030
"onvif_username, onvif_password, onvif_profile, onvif_port, "
10171031
"record_on_schedule, recording_schedule, tags, admin_url, privacy_mode, motion_trigger_source, "
1018-
"go2rtc_source_override "
1032+
"go2rtc_source_override, sub_stream_url "
10191033
"FROM streams ORDER BY name;";
10201034

10211035
// Column index constants (same as get_stream_config_by_name)
@@ -1031,7 +1045,7 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) {
10311045
COL_PTZ_ENABLED, COL_PTZ_MAX_X, COL_PTZ_MAX_Y, COL_PTZ_MAX_Z, COL_PTZ_HAS_HOME,
10321046
COL_ONVIF_USERNAME, COL_ONVIF_PASSWORD, COL_ONVIF_PROFILE, COL_ONVIF_PORT,
10331047
COL_RECORD_ON_SCHEDULE, COL_RECORDING_SCHEDULE, COL_TAGS, COL_ADMIN_URL, COL_PRIVACY_MODE,
1034-
COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE
1048+
COL_MOTION_TRIGGER_SOURCE, COL_GO2RTC_SOURCE_OVERRIDE, COL_SUB_STREAM_URL
10351049
};
10361050

10371051
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
@@ -1199,6 +1213,14 @@ int get_all_stream_configs(stream_config_t *streams, int max_count) {
11991213
s->go2rtc_source_override[0] = '\0';
12001214
}
12011215

1216+
// Sub-stream URL
1217+
const char *sub_url = (const char *)sqlite3_column_text(stmt, COL_SUB_STREAM_URL);
1218+
if (sub_url) {
1219+
safe_strcpy(s->sub_stream_url, sub_url, sizeof(s->sub_stream_url), 0);
1220+
} else {
1221+
s->sub_stream_url[0] = '\0';
1222+
}
1223+
12021224
count++;
12031225
}
12041226

src/video/go2rtc/go2rtc_integration.c

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,6 +1376,19 @@ bool go2rtc_integration_register_all_streams(void) {
13761376
} else {
13771377
log_info("Successfully registered stream %s with go2rtc", streams[i].name);
13781378
}
1379+
1380+
// Register sub-stream if configured (low-res for grid view)
1381+
if (streams[i].sub_stream_url[0] != '\0') {
1382+
char sub_name[MAX_STREAM_NAME + 8];
1383+
snprintf(sub_name, sizeof(sub_name), "%s_sub", streams[i].name);
1384+
log_info("Registering sub-stream %s with go2rtc", sub_name);
1385+
if (!go2rtc_stream_register(sub_name, streams[i].sub_stream_url,
1386+
streams[i].onvif_username[0] != '\0' ? streams[i].onvif_username : NULL,
1387+
streams[i].onvif_password[0] != '\0' ? streams[i].onvif_password : NULL,
1388+
false, streams[i].protocol, false)) {
1389+
log_warn("Failed to register sub-stream %s with go2rtc", sub_name);
1390+
}
1391+
}
13791392
}
13801393
}
13811394
free(streams);
@@ -1480,6 +1493,18 @@ bool go2rtc_sync_streams_from_database(void) {
14801493
log_info("Successfully synced stream %s to go2rtc", db_streams[i].name);
14811494
synced++;
14821495
}
1496+
1497+
// Register sub-stream if configured
1498+
if (db_streams[i].sub_stream_url[0] != '\0') {
1499+
char sub_name[MAX_STREAM_NAME + 8];
1500+
snprintf(sub_name, sizeof(sub_name), "%s_sub", db_streams[i].name);
1501+
if (!go2rtc_api_stream_exists(sub_name)) {
1502+
log_info("Registering missing sub-stream %s with go2rtc", sub_name);
1503+
go2rtc_stream_register(sub_name, db_streams[i].sub_stream_url,
1504+
username, password,
1505+
false, db_streams[i].protocol, false);
1506+
}
1507+
}
14831508
}
14841509

14851510
log_info("go2rtc sync complete: %d synced, %d skipped, %d failed", synced, skipped, failed);
@@ -1847,6 +1872,17 @@ bool go2rtc_integration_register_stream(const char *stream_name) {
18471872
config.backchannel_enabled, config.protocol,
18481873
config.record_audio)) {
18491874
log_info("Successfully registered stream %s with go2rtc", stream_name);
1875+
1876+
// Register sub-stream if configured
1877+
if (config.sub_stream_url[0] != '\0') {
1878+
char sub_name[MAX_STREAM_NAME + 8];
1879+
snprintf(sub_name, sizeof(sub_name), "%s_sub", stream_name);
1880+
log_info("Registering sub-stream %s with go2rtc", sub_name);
1881+
go2rtc_stream_register(sub_name, config.sub_stream_url,
1882+
username[0] != '\0' ? username : NULL,
1883+
password[0] != '\0' ? password : NULL,
1884+
false, config.protocol, false);
1885+
}
18501886
return true;
18511887
}
18521888

src/video/go2rtc/go2rtc_process.c

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,26 @@
2323
#include "core/config.h"
2424
#include "core/path_utils.h"
2525
#include "utils/strings.h"
26+
#include "database/db_core.h"
2627
#include "database/db_streams.h"
2728
#include "database/db_system_settings.h"
2829

30+
/**
31+
* Escape a string for use inside a YAML double-quoted scalar.
32+
* Handles " → \" and \ → \\. Returns dst.
33+
*/
34+
static char *yaml_escape_string(const char *src, char *dst, size_t dst_size) {
35+
size_t j = 0;
36+
for (size_t i = 0; src[i] && j + 2 < dst_size; i++) {
37+
if (src[i] == '"' || src[i] == '\\') {
38+
dst[j++] = '\\';
39+
}
40+
dst[j++] = src[i];
41+
}
42+
dst[j] = '\0';
43+
return dst;
44+
}
45+
2946

3047
// Define PATH_MAX if not defined
3148
#ifndef PATH_MAX
@@ -703,44 +720,60 @@ bool go2rtc_process_generate_config(const char *config_path, int api_port) {
703720
// other streams will be registered dynamically via the go2rtc API.
704721
fprintf(config_file, "\nstreams:\n");
705722
{
706-
int ms = g_config.max_streams > 0 ? g_config.max_streams : 32;
707-
stream_config_t *streams = calloc(ms, sizeof(stream_config_t));
708-
int count = streams ? get_all_stream_configs(streams, ms) : 0;
709723
bool has_overridden = false;
724+
if (get_db_handle() != NULL) {
725+
int ms = g_config.max_streams > 0 ? g_config.max_streams : 32;
726+
stream_config_t *streams = calloc(ms, sizeof(stream_config_t));
727+
int count = streams ? get_all_stream_configs(streams, ms) : 0;
710728

711-
for (int i = 0; i < count; i++) {
712-
if (!streams[i].enabled) continue;
713-
if (streams[i].go2rtc_source_override[0] == '\0') continue;
714-
715-
has_overridden = true;
716-
// Quote stream name for YAML safety (handles spaces, colons, etc.)
717-
fprintf(config_file, " \"%s\":\n", streams[i].name);
718-
719-
// Write each line of the override with base indentation while
720-
// preserving any user-provided indentation within the YAML.
721-
const char *p = streams[i].go2rtc_source_override;
722-
while (*p) {
723-
const char *eol = strchr(p, '\n');
724-
if (eol) {
725-
fprintf(config_file, " %.*s\n", (int)(eol - p), p);
726-
p = eol + 1;
729+
for (int i = 0; i < count; i++) {
730+
if (!streams[i].enabled) continue;
731+
if (streams[i].go2rtc_source_override[0] == '\0') continue;
732+
733+
has_overridden = true;
734+
735+
// Escape stream name for YAML double-quoted key safety
736+
char escaped_name[MAX_STREAM_NAME * 2];
737+
yaml_escape_string(streams[i].name, escaped_name, sizeof(escaped_name));
738+
739+
const char *override = streams[i].go2rtc_source_override;
740+
bool is_single_line = (strchr(override, '\n') == NULL);
741+
742+
if (is_single_line) {
743+
// Single URL: write as inline YAML scalar
744+
// "cam": rtsp://camera/stream
745+
fprintf(config_file, " \"%s\": %s\n", escaped_name, override);
727746
} else {
728-
fprintf(config_file, " %s\n", p);
729-
break;
747+
// Multi-line: write as indented block under the key
748+
// "cam":
749+
// - rtsp://camera/main
750+
// - ffmpeg:cam#video=h264
751+
fprintf(config_file, " \"%s\":\n", escaped_name);
752+
const char *p = override;
753+
while (*p) {
754+
const char *eol = strchr(p, '\n');
755+
if (eol) {
756+
fprintf(config_file, " %.*s\n", (int)(eol - p), p);
757+
p = eol + 1;
758+
} else {
759+
fprintf(config_file, " %s\n", p);
760+
break;
761+
}
762+
}
730763
}
731764
}
765+
free(streams);
732766
}
733-
734767
if (!has_overridden) {
735768
fprintf(config_file, " # Streams will be added dynamically via API\n");
736769
}
737-
free(streams);
738770
}
739771

740772
// Global go2rtc config override from system settings
741773
{
742774
char global_override[4096] = {0};
743-
if (db_get_system_setting("go2rtc_config_override", global_override, sizeof(global_override)) == 0
775+
if (get_db_handle() != NULL
776+
&& db_get_system_setting("go2rtc_config_override", global_override, sizeof(global_override)) == 0
744777
&& global_override[0] != '\0') {
745778
fprintf(config_file, "\n# User config override\n");
746779
fprintf(config_file, "%s\n", global_override);

0 commit comments

Comments
 (0)