GCC Code Coverage Report


Directory: ./
File: mediaoutput/RTSPOutputManager.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 353 413 85.5%
Functions: 26 26 100.0%
Branches: 405 836 48.4%

Line Branch Exec Source
1 /*
2 * This file is part of the Falcon Player (FPP) and is Copyright (C)
3 * 2013-2026 by the Falcon Player Developers.
4 *
5 * The Falcon Player (FPP) is free software, and is covered under
6 * multiple Open Source licenses. Please see the included 'LICENSES'
7 * file for descriptions of what files are covered by each license.
8 *
9 * This source file is covered under the LGPL v2.1 as described in the
10 * included LICENSE.LGPL file.
11 */
12
13 #include "fpp-pch.h"
14
15 #include <mutex>
16 #include <set>
17
18 #include "fpp-json.h"
19 #include "fpphttp.h" // drogon/HTTP helpers used here; see fpphttp_types.h
20
21 #include "Warnings.h" // WarningHolder -- needed directly for NOPCH builds
22
23 #include "GStreamerOut.h"
24 #include "PipeWireGraphConfig.h"
25 #include "RTSPOutputManager.h"
26
27 #ifdef HAS_RTSP_OUTPUT_GSTREAMER
28
29 #include <fstream>
30 #include <sstream>
31
32 #include "common.h"
33 #include "log.h"
34 #include "settings.h"
35
36 // ──────────────────────────────────────────────────────────────────────────────
37 // Singleton instance
38 // ──────────────────────────────────────────────────────────────────────────────
39 static RTSPOutputManager s_rtspOutputManager;
40 RTSPOutputManager& RTSPOutputManager::INSTANCE = s_rtspOutputManager;
41
42 2346 RTSPOutputManager::RTSPOutputManager() {
43
2/4
✓ Branch 0 taken 2346 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2346 times.
✗ Branch 3 not taken.
4692 m_configPath = FPP_DIR_MEDIA("/config/pipewire-rtsp-outputs.json");
44
2/4
✓ Branch 0 taken 2346 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2346 times.
✗ Branch 3 not taken.
2346 m_consumersPath = FPP_DIR_MEDIA("/config/pipewire-video-consumers.json");
45 2346 }
46
47 916 RTSPOutputManager::~RTSPOutputManager() {
48 916 Shutdown();
49 916 }
50
51 8 std::string RTSPOutputMount::AudioChannel() const {
52 8 return audioNodeName + "_ch";
53 }
54
55 47 std::string RTSPOutputMount::ResolveSourceNode() const {
56
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 37 times.
47 if (!sourceNode.empty()) {
57 10 return sourceNode;
58 }
59
1/2
✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
37 if (!streamSlots.empty()) {
60 // A group aimed at a playback stream slot has no named input source.
61 // The slot's intervideo channel exists whether or not anything is
62 // playing, and intervideosrc holds the mount up on black until it is,
63 // which is what a client watching a stream between songs should see.
64 // Only the first slot can be served: one mount is one channel.
65
1/2
✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
37 return "fppd_video_stream_" + std::to_string(streamSlots.front());
66 }
67 ✗ return "";
68 }
69
70 // ──────────────────────────────────────────────────────────────────────────────
71 // Helpers
72 // ──────────────────────────────────────────────────────────────────────────────
73
74 // Quote a config-supplied value for a gst_parse_launch description.
75 //
76 // Same hazard GstQuote() covers in VideoInputManager: these strings come from
77 // a JSON file the web UI writes, and a bare space or quote in one of them does
78 // not fail cleanly -- it re-parses as extra pipeline syntax.
79 24 static std::string RtspGstQuote(const std::string& value) {
80
1/2
✓ Branch 0 taken 24 times.
✗ Branch 1 not taken.
24 std::string out = "\"";
81
2/2
✓ Branch 0 taken 428 times.
✓ Branch 1 taken 24 times.
452 for (char c : value) {
82
2/4
✓ Branch 0 taken 428 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 428 times.
428 if (c == '\\' || c == '"') {
83 ✗ out += '\\';
84 }
85
1/2
✓ Branch 0 taken 428 times.
✗ Branch 1 not taken.
428 out += c;
86 }
87
1/2
✓ Branch 0 taken 24 times.
✗ Branch 1 not taken.
24 out += '"';
88 24 return out;
89 ✗ }
90
91 // A mount point has to start with '/' and carry nothing that would change the
92 // meaning of the URL. An empty or malformed one is replaced rather than
93 // rejected, so a half-finished config still serves something addressable.
94 30 static std::string NormaliseMountPoint(const std::string& raw, int id) {
95 30 std::string mp;
96
2/2
✓ Branch 0 taken 114 times.
✓ Branch 1 taken 30 times.
144 for (char c : raw) {
97
3/8
✓ Branch 0 taken 24 times.
✓ Branch 1 taken 90 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 24 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
114 if (isalnum(static_cast<unsigned char>(c)) || c == '/' || c == '_' || c == '-') {
98
1/2
✓ Branch 0 taken 114 times.
✗ Branch 1 not taken.
114 mp += c;
99 }
100 }
101
6/8
✓ Branch 0 taken 24 times.
✓ Branch 1 taken 6 times.
✓ Branch 2 taken 24 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 24 times.
✓ Branch 6 taken 6 times.
✓ Branch 7 taken 24 times.
30 if (mp.empty() || mp == "/") {
102
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 mp = "/stream" + std::to_string(id);
103 }
104
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 30 times.
30 if (mp[0] != '/') {
105 ✗ mp = "/" + mp;
106 }
107 30 return mp;
108 ✗ }
109
110 // ──────────────────────────────────────────────────────────────────────────────
111 // Config
112 // ──────────────────────────────────────────────────────────────────────────────
113 // The warnings the last apply raised, retracted by the next: saving the
114 // config again, or the Restart that follows freeing the port, is how a user
115 // retries, and they used to stay up until fppd restarted. RemoveWarning()
116 // needs the exact text, so it is kept as raised.
117 static std::mutex s_rtspWarningsLock;
118 static std::set<std::string> s_rtspWarnings;
119
120 1 static void RaiseRtspWarning(const std::string& w) {
121
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::lock_guard<std::mutex> l(s_rtspWarningsLock);
122
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 s_rtspWarnings.insert(w);
123
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 WarningHolder::AddWarning(31, w);
124 1 }
125
126 1891 static void RetractRtspWarnings() {
127
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 std::lock_guard<std::mutex> l(s_rtspWarningsLock);
128
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1891 times.
1892 for (const auto& w : s_rtspWarnings) {
129
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 WarningHolder::RemoveWarning(31, w);
130 }
131 1891 s_rtspWarnings.clear();
132 1891 }
133
134 3778 bool RTSPOutputManager::LoadConfig() {
135 3778 RTSPOutputConfig fresh;
136
137
3/4
✓ Branch 0 taken 3778 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3746 times.
✓ Branch 3 taken 32 times.
3778 if (!FileExists(m_configPath)) {
138
1/2
✓ Branch 0 taken 3746 times.
✗ Branch 1 not taken.
3746 LogDebug(VB_MEDIAOUT, "RTSPOutputManager: No config at %s\n", m_configPath.c_str());
139
1/2
✓ Branch 0 taken 3746 times.
✗ Branch 1 not taken.
3746 std::unique_lock<std::mutex> lock(m_configMutex);
140 3746 m_config = std::move(fresh);
141 3746 return false;
142 3746 }
143
144
1/2
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
32 std::ifstream ifs(m_configPath);
145
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 32 times.
32 if (!ifs.is_open()) {
146 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: Cannot open %s\n", m_configPath.c_str());
147 ✗ return false;
148 }
149
150
1/2
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
32 Json::Value root;
151
1/2
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
32 Json::CharReaderBuilder builder;
152 32 std::string errors;
153
2/4
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 32 times.
32 if (!Json::parseFromStream(builder, ifs, &root, &errors)) {
154 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: JSON parse error: %s\n", errors.c_str());
155 ✗ return false;
156 }
157
158 // asBool()/asInt() throw on a value of the wrong type ("enabled": "yes"),
159 // and this runs at startup with nothing above to catch it: fppd crash
160 // looped on a hand-edited file. Keep the last good config instead.
161 try {
162
4/6
✓ Branch 0 taken 32 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 32 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✓ Branch 5 taken 2 times.
36 fresh.enabled = root.get("enabled", false).asBool();
163
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 fresh.port = root.get("port", RTSPOutput::DEFAULT_PORT).asInt();
164
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 fresh.requireGroupSource = root.get("requireGroupSource", true).asBool();
165
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 fresh.latencyMs = root.get("latencyMs", RTSPOutput::DEFAULT_LATENCY_MS).asInt();
166
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 } catch (const std::exception& ex) {
167
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogWarn(VB_MEDIAOUT, "RTSPOutputManager: ignoring %s, a value has the wrong type: %s\n",
168 m_configPath.c_str(), ex.what());
169 2 return false;
170 2 }
171
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 30 times.
30 if (fresh.port < 1024 || fresh.port > 65535) {
172 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: port %d out of range, using %d\n",
173 fresh.port, RTSPOutput::DEFAULT_PORT);
174 ✗ fresh.port = RTSPOutput::DEFAULT_PORT;
175 }
176
4/4
✓ Branch 0 taken 28 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 26 times.
30 if (fresh.latencyMs < 0 || fresh.latencyMs > 5000) {
177
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogWarn(VB_MEDIAOUT, "RTSPOutputManager: latencyMs %d out of range, using %d\n",
178 fresh.latencyMs, RTSPOutput::DEFAULT_LATENCY_MS);
179 4 fresh.latencyMs = RTSPOutput::DEFAULT_LATENCY_MS;
180 }
181
182
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 LoadMounts(fresh);
183
184
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 std::unique_lock<std::mutex> lock(m_configMutex);
185 30 m_config = std::move(fresh);
186 30 return true;
187 3778 }
188
189 // Streams come from the Video Output Groups, flattened by the web UI into the
190 // same consumer list VideoOutputManager reads. Taking them from there rather
191 // than from our own config is the whole point: a group owns its video source,
192 // so an RTSP stream inherits it instead of naming it a second time where the
193 // two could drift apart -- and it picks up stream-slot groups for free.
194 30 void RTSPOutputManager::LoadMounts(RTSPOutputConfig& cfg) {
195
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 30 times.
30 if (!FileExists(m_consumersPath)) {
196 ✗ LogDebug(VB_MEDIAOUT, "RTSPOutputManager: no consumer config at %s\n", m_consumersPath.c_str());
197 ✗ return;
198 }
199
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 std::ifstream ifs(m_consumersPath);
200
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 30 times.
30 if (!ifs.is_open()) {
201 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: cannot open %s\n", m_consumersPath.c_str());
202 ✗ return;
203 }
204
205
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 Json::Value root;
206
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 Json::CharReaderBuilder builder;
207 30 std::string errors;
208
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 30 times.
30 if (!Json::parseFromStream(builder, ifs, &root, &errors)) {
209 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: consumer JSON parse error: %s\n", errors.c_str());
210 ✗ return;
211 }
212
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 30 times.
30 if (!root.isArray()) {
213 ✗ return;
214 }
215
216
7/12
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 30 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 60 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 30 times.
✓ Branch 11 taken 30 times.
60 for (const auto& entry : root) {
217
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 RTSPOutputMount m;
218 // as in LoadConfig(): a value of the wrong type skips that stream
219 try {
220
11/28
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 30 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 30 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 30 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 30 times.
✓ Branch 14 taken 30 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 30 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 30 times.
✗ Branch 19 not taken.
✗ Branch 20 not taken.
✓ Branch 21 taken 30 times.
✗ Branch 22 not taken.
✗ Branch 23 not taken.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
30 if (!entry.isObject() || entry.get("type", "").asString() != "rtsp") {
221 ✗ continue;
222 }
223
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.name = entry.get("name", "").asString();
224
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
60 m.mountPoint = NormaliseMountPoint(entry.get("mountPoint", "").asString(),
225
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
60 static_cast<int>(cfg.mounts.size() + 1));
226
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.sourceNode = entry.get("sourceNode", "").asString();
227
8/12
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 24 times.
✓ Branch 3 taken 6 times.
✓ Branch 4 taken 24 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 24 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 24 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 24 times.
✓ Branch 11 taken 6 times.
30 if (entry.isMember("streamSlots") && entry["streamSlots"].isArray()) {
228
7/12
✓ Branch 0 taken 24 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 24 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 24 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 24 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 48 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 24 times.
✓ Branch 11 taken 24 times.
48 for (const auto& sl : entry["streamSlots"]) {
229
3/6
✓ Branch 0 taken 24 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 24 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 24 times.
✗ Branch 5 not taken.
24 m.streamSlots.push_back(sl.asInt());
230 }
231 }
232
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.width = entry.get("width", RTSPOutput::DEFAULT_WIDTH).asInt();
233
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.height = entry.get("height", RTSPOutput::DEFAULT_HEIGHT).asInt();
234
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.framerate = entry.get("framerate", RTSPOutput::DEFAULT_FRAMERATE).asInt();
235
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.videoEncoding = entry.get("videoEncoding", "h264").asString();
236
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.videoBitrate = entry.get("videoBitrate", RTSPOutput::DEFAULT_VIDEO_BITRATE).asInt();
237
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.audioEnabled = entry.get("audioEnabled", false).asBool();
238
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.audioNodeName = entry.get("audioNodeName", "").asString();
239
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
30 m.audioBitrate = entry.get("audioBitrate", RTSPOutput::DEFAULT_AUDIO_BITRATE).asInt();
240 ✗ } catch (const std::exception& ex) {
241 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: skipping a stream in %s, a value has the wrong type: %s\n",
242 m_consumersPath.c_str(), ex.what());
243 ✗ continue;
244 ✗ }
245
246
2/4
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 30 times.
30 if (m.ResolveSourceNode().empty()) {
247 // The group names neither a video source nor a stream slot, so
248 // there is no channel to read and the mount would serve nothing.
249 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: '%s' has no video source, skipping\n",
250 m.name.c_str());
251 ✗ continue;
252 }
253
3/6
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 30 times.
30 if (m.width <= 0 || m.height <= 0 || m.framerate <= 0) {
254 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: '%s' has an invalid size/rate (%dx%d@%d), "
255 "using defaults\n",
256 m.name.c_str(), m.width, m.height, m.framerate);
257 ✗ m.width = RTSPOutput::DEFAULT_WIDTH;
258 ✗ m.height = RTSPOutput::DEFAULT_HEIGHT;
259 ✗ m.framerate = RTSPOutput::DEFAULT_FRAMERATE;
260 }
261
4/6
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 23 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 7 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 30 times.
30 if (m.audioEnabled && m.audioNodeName.empty()) {
262 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: '%s' wants audio but has no node name, "
263 "serving video only\n",
264 m.name.c_str());
265 ✗ m.audioEnabled = false;
266 }
267
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 cfg.mounts.push_back(std::move(m));
268
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 }
269
4/8
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 30 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 30 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 30 times.
✗ Branch 7 not taken.
30 }
270
271 // ──────────────────────────────────────────────────────────────────────────────
272 // Pipeline descriptions
273 // ──────────────────────────────────────────────────────────────────────────────
274 16 std::string RTSPOutputManager::BuildFactoryLaunch(const RTSPOutputMount& mount) const {
275
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 std::ostringstream oss;
276
277 // Video: read the intervideo channel the rest of the video plane already
278 // publishes on, exactly as the HDMI and RTP consumers do. timeout is the
279 // same 5s the other consumers use -- it must not overflow gint64 once
280 // converted to microseconds (see VideoOutputManager::StartConsumer).
281
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
32 oss << "( intervideosrc timeout=5000000000 channel=" << RtspGstQuote(mount.ResolveSourceNode())
282 << " ! videoconvert ! videoscale ! videorate"
283
6/12
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 16 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 16 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 16 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 16 times.
✗ Branch 11 not taken.
32 << " ! video/x-raw,width=" << mount.width
284
2/4
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✗ Branch 3 not taken.
16 << ",height=" << mount.height
285 << ",pixel-aspect-ratio=1/1"
286
3/6
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 16 times.
✗ Branch 5 not taken.
16 << ",framerate=" << mount.framerate << "/1"
287
2/4
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✗ Branch 3 not taken.
16 << " ! queue max-size-buffers=2 leaky=downstream ";
288
289 // Encoders are all configured for live streaming rather than filesize:
290 // zerolatency/ultrafast, no B-frames, and a keyframe roughly every two
291 // seconds so a client that joins mid-stream gets a picture quickly.
292 16 const int keyIntMax = mount.framerate * 2;
293
2/4
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 16 times.
16 if (mount.videoEncoding == "h265") {
294 oss << "! x265enc tune=zerolatency speed-preset=ultrafast"
295 ✗ << " bitrate=" << mount.videoBitrate
296 ✗ << " key-int-max=" << keyIntMax
297 ✗ << " ! rtph265pay name=pay0 pt=" << RTSPOutput::VIDEO_PAYLOAD_TYPE
298 ✗ << " config-interval=1 ";
299
3/4
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✓ Branch 3 taken 2 times.
16 } else if (mount.videoEncoding == "mjpeg") {
300 // Motion JPEG has no inter-frame prediction, so every frame is a
301 // keyframe and none of the GOP settings above apply.
302 oss << "! jpegenc quality=80"
303
2/4
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
14 << " ! rtpjpegpay name=pay0 pt=26 ";
304 } else {
305 // h264 is the default and the only encoding every client understands.
306 oss << "! x264enc tune=zerolatency speed-preset=ultrafast bframes=0"
307
3/6
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
2 << " bitrate=" << mount.videoBitrate
308
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 << " key-int-max=" << keyIntMax
309 << " ! video/x-h264,profile=baseline"
310
3/6
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
2 << " ! rtph264pay name=pay0 pt=" << RTSPOutput::VIDEO_PAYLOAD_TYPE
311
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 << " config-interval=1 ";
312 }
313
314
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 12 times.
16 if (mount.audioEnabled) {
315 // Audio arrives over an interaudio channel rather than straight from
316 // PipeWire, so the PipeWire node lives in the always-running bridge
317 // pipeline instead of in here. A GstRTSPMedia only exists while a
318 // client is connected, and a node that came and went with client
319 // connections could not be wired up in the mix bus UI ahead of time.
320
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
8 oss << " interaudiosrc channel=" << RtspGstQuote(mount.AudioChannel())
321 << " ! audioconvert ! audioresample"
322
6/12
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 4 times.
✗ Branch 11 not taken.
8 << " ! audio/x-raw,rate=" << RTSPOutput::AUDIO_RATE
323
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 << ",channels=" << RTSPOutput::AUDIO_CHANNELS
324
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 << " ! opusenc bitrate=" << mount.audioBitrate
325
3/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
4 << " ! rtpopuspay name=pay1 pt=" << RTSPOutput::AUDIO_PAYLOAD_TYPE << " ";
326 }
327
328
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 oss << ")";
329
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
32 return oss.str();
330 16 }
331
332 4 bool RTSPOutputManager::StartAudioBridge(const RTSPOutputMount& mount, bool requireGroupSource,
333 std::vector<AudioBridge>& bridges) {
334 4 AudioBridge bridge;
335
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 bridge.mountPoint = mount.mountPoint;
336
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 bridge.nodeName = mount.audioNodeName;
337
338
2/8
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 4 times.
4 if (requireGroupSource && !PipeWireGraphFeedsNode(mount.audioNodeName)) {
339 // Nothing routes into this node yet, and a pipewiresrc with
340 // node.autoconnect=false and nothing linked cannot preroll: starting
341 // it would block for 30 seconds and then report a failure for a stream
342 // the user has not finished wiring up. Leave it for the next apply,
343 // which the Audio Output Groups page performs anyway.
344 ✗ LogInfo(VB_MEDIAOUT, "RTSPOutputManager: audio for '%s' is waiting for an Audio "
345 "Output Group to feed '%s'\n",
346 mount.name.c_str(), mount.audioNodeName.c_str());
347 ✗ bridge.waitingForSource = true;
348 ✗ bridges.push_back(bridge);
349 ✗ return false;
350 }
351
352 // pipewiresrc -> interaudiosink: holds the PipeWire node open for the
353 // whole life of the config so routing can be set up before any client
354 // connects, and does only format conversion. The encode stays in the
355 // RTSP media, where it runs only while somebody is watching.
356 //
357 // The audioconvert/audioresample/audioconvert/audiorate split mirrors
358 // OpusRTPManager::CreateSendPipeline: the graph does not necessarily run
359 // at 48000, and without a real resampler negotiation never completes and
360 // set_state() blocks rather than failing cleanly.
361
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::ostringstream oss;
362 oss << "pipewiresrc name=pwsrc min-buffers=2 do-timestamp=true"
363 << " ! queue max-size-buffers=0 max-size-bytes=0 max-size-time=200000000 leaky=downstream"
364 << " ! audioconvert ! audioresample ! audioconvert ! audiorate"
365
5/10
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
4 << " ! audio/x-raw,rate=" << RTSPOutput::AUDIO_RATE
366
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 << ",channels=" << RTSPOutput::AUDIO_CHANNELS
367
4/8
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
4 << " ! interaudiosink sync=false channel=" << RtspGstQuote(mount.AudioChannel());
368
369
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::string desc = oss.str();
370
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogInfo(VB_MEDIAOUT, "RTSPOutputManager: audio bridge for '%s': %s\n",
371 mount.name.c_str(), desc.c_str());
372
373 4 GError* error = nullptr;
374
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 GstElement* pipeline = gst_parse_launch(desc.c_str(), &error);
375
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (!pipeline) {
376 ✗ LogErr(VB_MEDIAOUT, "RTSPOutputManager: audio bridge for '%s' failed to build: %s\n",
377 mount.name.c_str(), error ? error->message : "unknown error");
378 ✗ if (error) {
379 ✗ g_error_free(error);
380 }
381 ✗ return false;
382 }
383
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (error) {
384 ✗ LogWarn(VB_MEDIAOUT, "RTSPOutputManager: audio bridge warning for '%s': %s\n",
385 mount.name.c_str(), error->message);
386 ✗ g_error_free(error);
387 }
388
389 // Inline GstStructure values containing '/' (node.latency=1024/48000)
390 // crash gst_value_deserialize, so these are set after the parse -- the
391 // same reason OpusRTPManager sets them here rather than in the string.
392
3/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
4 GstElement* pwsrc = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc");
393
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (pwsrc) {
394
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::string desc2 = "FPP RTSP Audio: " + mount.name;
395
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 GstStructure* props = gst_structure_new("props",
396 "node.name", G_TYPE_STRING, mount.audioNodeName.c_str(),
397 "node.description", G_TYPE_STRING, desc2.c_str(),
398 "node.autoconnect", G_TYPE_BOOLEAN, FALSE,
399 "node.latency", G_TYPE_STRING, "1024/48000",
400 NULL);
401
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 g_object_set(pwsrc, "stream-properties", props, NULL);
402
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_structure_free(props);
403
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(pwsrc);
404 4 }
405
406 // On the system clock, not the GstPipeWireClock pipewiresrc offers: for
407 // a follower stream that clock stops advancing once the graph's real
408 // driver takes the node over (it reads the stream's time, whose rate is
409 // then 0), and pipewiresrc waits on it before every buffer. Linked while
410 // it was starting -- an audio group targeting the node links it as soon
411 // as it appears -- the bridge passed a buffer or two and then nothing, so
412 // RTSP clients heard silence.
413
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 GstClock* sysClock = gst_system_clock_obtain();
414
3/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
4 gst_pipeline_use_clock(GST_PIPELINE(pipeline), sysClock);
415
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(sysClock);
416
417 // Started on its own thread. A pipewiresrc whose node nothing feeds
418 // (requireGroupSource off) takes 30 s to fail the state change, and the
419 // caller is ApplyConfig() -- fppd's main loop for reloadRTSPOutputs, and
420 // startup. The thread holds its own ref; the bridge entry owns the
421 // pipeline and StopAudioBridges() tears it down whether or not the start
422 // has finished.
423 4 bridge.pipeline = pipeline;
424
3/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
4 GstElement* startRef = GST_ELEMENT(gst_object_ref(pipeline));
425
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::string mountName = mount.name;
426
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
12 std::thread([startRef, mountName]() {
427
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 SetThreadName("FPP-RTSPAudio");
428
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
4 if (gst_element_set_state(startRef, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) {
429 ✗ LogErr(VB_MEDIAOUT, "RTSPOutputManager: audio bridge for '%s' failed to start\n",
430 mountName.c_str());
431 ✗ RaiseRtspWarning("RTSP output '" + mountName + "': audio bridge failed to start");
432 }
433 2 gst_object_unref(startRef);
434
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
6 }).detach();
435
436
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 bridges.push_back(bridge);
437 4 return true;
438 4 }
439
440 4216 void RTSPOutputManager::StopAudioBridges() {
441 // Taken out under m_configMutex: GetStatus() walks the vector on HTTP
442 // threads, and clearing it under only the apply lock freed elements out
443 // from under it. Stopped with the lock released.
444 4216 std::vector<AudioBridge> bridges;
445 {
446
1/2
✓ Branch 0 taken 4216 times.
✗ Branch 1 not taken.
4216 std::unique_lock<std::mutex> lock(m_configMutex);
447 4216 bridges.swap(m_audioBridges);
448 4216 }
449
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 4216 times.
4220 for (auto& b : bridges) {
450
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (b.pipeline) {
451 // Bounded: a bridge whose start is still stuck in pipewiresrc
452 // holds the state lock, and this runs on the main loop and in
453 // Shutdown(). A wedged one is left to the reaper.
454
3/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 2 times.
4 if (!GStreamerOutput::SetPipelineToNullBounded(b.pipeline, 2000)) {
455
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogWarn(VB_MEDIAOUT, "RTSPOutputManager: audio bridge for '%s' did not stop, abandoning it\n",
456 b.mountPoint.c_str());
457 }
458
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(b.pipeline);
459 4 b.pipeline = nullptr;
460 }
461 }
462 4216 }
463
464 // Concurrent RTSP sessions. One shared encoder serves them all, but each
465 // still gets its own transport.
466 static constexpr guint RTSP_MAX_SESSIONS = 32;
467
468 // ──────────────────────────────────────────────────────────────────────────────
469 // Server lifecycle
470 // ──────────────────────────────────────────────────────────────────────────────
471 16 bool RTSPOutputManager::StartServer() {
472 16 GStreamerOutput::EnsureGStreamerInit();
473
474 16 setenv("PIPEWIRE_RUNTIME_DIR", "/run/pipewire-fpp", 0);
475
476 // gst-rtsp-server services its clients from a GMainContext, and FPP runs
477 // no main loop of its own. Give the server a private context rather than
478 // the default one, so nothing else that later wants the default context
479 // (a plugin, a future subsystem) has to share a loop with it.
480 16 m_mainContext = g_main_context_new();
481 16 m_mainLoop = g_main_loop_new(m_mainContext, FALSE);
482
483 16 m_server = gst_rtsp_server_new();
484
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 gst_rtsp_server_set_service(m_server, std::to_string(m_config.port).c_str());
485
486 16 GstRTSPMountPoints* mounts = gst_rtsp_server_get_mount_points(m_server);
487 16 int mounted = 0;
488
2/2
✓ Branch 0 taken 16 times.
✓ Branch 1 taken 16 times.
32 for (const auto& mount : m_config.mounts) {
489
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 std::string launch = BuildFactoryLaunch(mount);
490
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 LogInfo(VB_MEDIAOUT, "RTSPOutputManager: mount %s -> %s\n",
491 mount.mountPoint.c_str(), launch.c_str());
492
493
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 GstRTSPMediaFactory* factory = gst_rtsp_media_factory_new();
494
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 gst_rtsp_media_factory_set_launch(factory, launch.c_str());
495 // Shared: every client is served from ONE pipeline, so the encoder
496 // runs once no matter how many receivers connect. Without this each
497 // client would get its own x264enc, which a Pi cannot afford.
498
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 gst_rtsp_media_factory_set_shared(factory, TRUE);
499 // The sources are live: never let a client's disconnect send EOS and
500 // tear the shared media down under the others.
501
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 gst_rtsp_media_factory_set_eos_shutdown(factory, FALSE);
502 // Without this each media keeps gst-rtsp-server's 200ms default, which
503 // a viewer feels as lag for no benefit on a live local source.
504
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 gst_rtsp_media_factory_set_latency(factory, m_config.latencyMs);
505
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 gst_rtsp_mount_points_add_factory(mounts, mount.mountPoint.c_str(), factory);
506 16 mounted++;
507 16 }
508 16 g_object_unref(mounts);
509
510
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 16 times.
16 if (mounted == 0) {
511 ✗ LogInfo(VB_MEDIAOUT, "RTSPOutputManager: no enabled mounts, not starting the server\n");
512 ✗ StopServer();
513 ✗ return false;
514 }
515
516 16 m_serverSourceId = gst_rtsp_server_attach(m_server, m_mainContext);
517
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 15 times.
16 if (m_serverSourceId == 0) {
518 1 LogErr(VB_MEDIAOUT, "RTSPOutputManager: could not bind RTSP port %d "
519 "(in use, or not permitted)\n",
520 m_config.port);
521
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 RaiseRtspWarning("RTSP output: could not bind port " + std::to_string(m_config.port));
522 1 StopServer();
523 1 return false;
524 }
525
526 // A UDP viewer that vanishes without a TEARDOWN (a phone app killed, a
527 // dropped network) leaves its session behind, and the server goes on
528 // sending its RTP into the void. The session times out (60s by
529 // default), but only once someone runs the pool's cleanup -- which
530 // gst-rtsp-server leaves to the application. The cap bounds what a
531 // crowd of viewers, or one client opening sessions in a loop, can cost.
532
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 if (GstRTSPSessionPool* pool = gst_rtsp_server_get_session_pool(m_server)) {
533 15 gst_rtsp_session_pool_set_max_sessions(pool, RTSP_MAX_SESSIONS);
534 15 g_object_unref(pool);
535 }
536 15 m_sessionCleanup = g_timeout_source_new_seconds(2);
537
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 g_source_set_callback(
538 m_sessionCleanup,
539 ✗ [](gpointer data) -> gboolean {
540 39 GstRTSPSessionPool* pool = gst_rtsp_server_get_session_pool(static_cast<GstRTSPServer*>(data));
541
1/2
✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
39 if (pool) {
542 39 guint expired = gst_rtsp_session_pool_cleanup(pool);
543
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 38 times.
39 if (expired) {
544 1 LogInfo(VB_MEDIAOUT, "RTSPOutputManager: expired %u session(s) whose client went away\n", expired);
545 }
546 39 g_object_unref(pool);
547 }
548 39 return G_SOURCE_CONTINUE;
549 },
550
0/2
✗ Branch 0 not taken.
✗ Branch 1 not taken.
15 m_server, nullptr);
551 15 g_source_attach(m_sessionCleanup, m_mainContext);
552
553 15 m_loopThread = std::thread([this]() {
554 // The loop must run with the same context the server was attached to,
555 // and pushing it as this thread's default keeps any source the server
556 // creates on our loop rather than the process default.
557 15 g_main_context_push_thread_default(m_mainContext);
558 15 g_main_loop_run(m_mainLoop);
559 15 g_main_context_pop_thread_default(m_mainContext);
560
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 });
561
562 15 LogInfo(VB_MEDIAOUT, "RTSPOutputManager: serving %d mount(s) on rtsp://<host>:%d\n",
563 mounted, m_config.port);
564 15 return true;
565 }
566
567 4217 void RTSPOutputManager::StopServer() {
568
2/2
✓ Branch 0 taken 16 times.
✓ Branch 1 taken 4201 times.
4217 if (m_server) {
569 // Close the connected clients and their sessions first, while the loop
570 // that services them still runs. Quitting the loop and dropping the
571 // server left them connected: their media (intervideosrc -> x264enc)
572 // kept encoding through every reload and shutdown, a viewer stayed on
573 // the old config, and its context held the old listening socket.
574
2/4
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✗ Branch 3 not taken.
16 g_list_free_full(gst_rtsp_server_client_filter(
575 m_server,
576 4 [](GstRTSPServer*, GstRTSPClient*, gpointer) { return GST_RTSP_FILTER_REMOVE; },
577 nullptr),
578 g_object_unref);
579
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 if (GstRTSPSessionPool* pool = gst_rtsp_server_get_session_pool(m_server)) {
580
2/4
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✗ Branch 3 not taken.
16 g_list_free_full(gst_rtsp_session_pool_filter(
581 pool,
582 39 [](GstRTSPSessionPool*, GstRTSPSession*, gpointer) { return GST_RTSP_FILTER_REMOVE; },
583 nullptr),
584 g_object_unref);
585 16 g_object_unref(pool);
586 }
587 }
588
2/2
✓ Branch 0 taken 16 times.
✓ Branch 1 taken 4201 times.
4217 if (m_mainLoop) {
589 16 g_main_loop_quit(m_mainLoop);
590 }
591
2/2
✓ Branch 0 taken 15 times.
✓ Branch 1 taken 4202 times.
4217 if (m_loopThread.joinable()) {
592 15 m_loopThread.join();
593 }
594
2/2
✓ Branch 0 taken 15 times.
✓ Branch 1 taken 4202 times.
4217 if (m_sessionCleanup) {
595 // It points at m_server, which goes below.
596 15 g_source_destroy(m_sessionCleanup);
597 15 g_source_unref(m_sessionCleanup);
598 15 m_sessionCleanup = nullptr;
599 }
600
2/2
✓ Branch 0 taken 15 times.
✓ Branch 1 taken 4202 times.
4217 if (m_serverSourceId != 0) {
601 // Only safe now that the loop has stopped: removing the server's
602 // source closes its listening socket.
603 15 GSource* src = g_main_context_find_source_by_id(m_mainContext, m_serverSourceId);
604
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 if (src) {
605 15 g_source_destroy(src);
606 }
607 15 m_serverSourceId = 0;
608 }
609
2/2
✓ Branch 0 taken 16 times.
✓ Branch 1 taken 4201 times.
4217 if (m_server) {
610 16 g_object_unref(m_server);
611 16 m_server = nullptr;
612 }
613
2/2
✓ Branch 0 taken 16 times.
✓ Branch 1 taken 4201 times.
4217 if (m_mainLoop) {
614 16 g_main_loop_unref(m_mainLoop);
615 16 m_mainLoop = nullptr;
616 }
617
2/2
✓ Branch 0 taken 16 times.
✓ Branch 1 taken 4201 times.
4217 if (m_mainContext) {
618 16 g_main_context_unref(m_mainContext);
619 16 m_mainContext = nullptr;
620 }
621 4217 }
622
623 // ──────────────────────────────────────────────────────────────────────────────
624 // Lifecycle
625 // ──────────────────────────────────────────────────────────────────────────────
626 1887 bool RTSPOutputManager::Init() {
627 1887 LoadConfig();
628 1887 return true;
629 }
630
631 1891 bool RTSPOutputManager::ApplyConfig() {
632
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 std::unique_lock<std::mutex> applyLock(m_applyMutex);
633
634 // Before anything below builds a pipeline. The audio bridges are parsed
635 // before StartServer(), and at boot nothing has initialised GStreamer by
636 // then: gst_parse_launch() crashed in gst_value_deserialize(), and the
637 // crash handler's exit() then hung in this object's destructor on the
638 // apply lock until the crash watchdog killed fppd -- on every boot.
639
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 GStreamerOutput::EnsureGStreamerInit();
640
641
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 StopServer();
642
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 StopAudioBridges();
643 1891 m_active = false;
644
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 RetractRtspWarnings();
645
646
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 LoadConfig();
647
648 1891 RTSPOutputConfig cfg;
649 {
650
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 std::unique_lock<std::mutex> lock(m_configMutex);
651
1/2
✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
1891 cfg = m_config;
652 1891 }
653
2/2
✓ Branch 0 taken 1875 times.
✓ Branch 1 taken 16 times.
1891 if (!cfg.enabled) {
654
1/2
✓ Branch 0 taken 1875 times.
✗ Branch 1 not taken.
1875 LogDebug(VB_MEDIAOUT, "RTSPOutputManager: disabled\n");
655 1875 return true;
656 }
657
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 16 times.
16 if (cfg.mounts.empty()) {
658 ✗ LogInfo(VB_MEDIAOUT, "RTSPOutputManager: enabled but no mounts configured\n");
659 ✗ return true;
660 }
661
662 // Bridges first: the RTSP media's interaudiosrc has nothing to read until
663 // its bridge is publishing, and a client can connect the moment the
664 // server's socket is up. Built without m_configMutex (see
665 // StartAudioBridge()).
666 16 std::vector<AudioBridge> bridges;
667
2/2
✓ Branch 0 taken 16 times.
✓ Branch 1 taken 16 times.
32 for (const auto& mount : cfg.mounts) {
668
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 12 times.
16 if (mount.audioEnabled) {
669
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 StartAudioBridge(mount, cfg.requireGroupSource, bridges);
670 }
671 }
672
673
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 std::unique_lock<std::mutex> lock(m_configMutex);
674 16 m_audioBridges = std::move(bridges);
675
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 bool ok = StartServer();
676 16 m_active = ok;
677 16 return ok;
678 1891 }
679
680 11 bool RTSPOutputManager::ServesStreamSlot(int slot) {
681
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 std::unique_lock<std::mutex> lock(m_configMutex);
682
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 1 times.
11 if (!m_config.enabled) {
683 10 return false;
684 }
685
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string channel = "fppd_video_stream_" + std::to_string(slot);
686
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 for (const auto& mount : m_config.mounts) {
687
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (mount.ResolveSourceNode() == channel) {
688 1 return true;
689 }
690 }
691 ✗ return false;
692 11 }
693
694 2325 void RTSPOutputManager::Shutdown() {
695
1/2
✓ Branch 0 taken 2325 times.
✗ Branch 1 not taken.
2325 std::unique_lock<std::mutex> applyLock(m_applyMutex);
696
1/2
✓ Branch 0 taken 2325 times.
✗ Branch 1 not taken.
2325 StopServer();
697
1/2
✓ Branch 0 taken 2325 times.
✗ Branch 1 not taken.
2325 StopAudioBridges();
698 2325 m_active = false;
699 2325 }
700
701 // ──────────────────────────────────────────────────────────────────────────────
702 // Status
703 // ──────────────────────────────────────────────────────────────────────────────
704 20 RTSPOutputManager::Status RTSPOutputManager::GetStatus() {
705 20 Status st;
706
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 std::unique_lock<std::mutex> lock(m_configMutex);
707
708 20 st.serverRunning = m_active.load();
709 20 st.port = m_config.port;
710 20 st.latencyMs = m_config.latencyMs;
711
712
2/2
✓ Branch 0 taken 15 times.
✓ Branch 1 taken 20 times.
35 for (const auto& mount : m_config.mounts) {
713 15 Status::MountStatus ms;
714
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 ms.name = mount.name;
715
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 ms.mountPoint = mount.mountPoint;
716 15 ms.enabled = true;
717 15 ms.audioEnabled = mount.audioEnabled;
718
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 ms.audioNodeName = mount.audioNodeName;
719 // Host-relative on purpose: fppd does not know which of its addresses
720 // the person reading this reached it on, and the UI does.
721
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 ms.url = "rtsp://<host>:" + std::to_string(m_config.port) + mount.mountPoint;
722
723
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 15 times.
16 for (const auto& b : m_audioBridges) {
724
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
1 if (b.mountPoint == mount.mountPoint && b.waitingForSource) {
725 ✗ ms.audioWaitingForSource = true;
726 ✗ ms.note = "Audio is waiting for an Audio Output Group to feed '" + mount.audioNodeName + "'. Video is unaffected.";
727 }
728 }
729
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 st.mounts.push_back(std::move(ms));
730 15 }
731 20 return st;
732 20 }
733
734 20 HttpResponsePtr RTSPOutputManager::render_GET(const HttpRequestPtr& req) {
735
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 std::string url(req->path());
736
737
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
20 if (url.find("/rtspoutput/") == 0) {
738 ✗ url = url.substr(12);
739
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 } else if (url == "/rtspoutput") {
740
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 url = "status";
741 }
742
743
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 if (url == "status") {
744
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Status st = GetStatus();
745
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::Value result;
746
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 result["active"] = st.serverRunning;
747
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 result["port"] = st.port;
748
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 result["latencyMs"] = st.latencyMs;
749
750
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::Value mounts(Json::arrayValue);
751
2/2
✓ Branch 0 taken 15 times.
✓ Branch 1 taken 20 times.
35 for (const auto& m : st.mounts) {
752
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 Json::Value mj;
753
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 mj["name"] = m.name;
754
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 mj["mountPoint"] = m.mountPoint;
755
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 mj["url"] = m.url;
756
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 mj["enabled"] = m.enabled;
757
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 mj["audioEnabled"] = m.audioEnabled;
758
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 5 times.
15 if (m.audioEnabled) {
759
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
10 mj["audioNodeName"] = m.audioNodeName;
760 }
761 // Kept distinct from an error for the same reason Opus RTP does:
762 // the UI renders a failure in red and this as guidance.
763
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 15 times.
15 if (m.audioWaitingForSource) {
764 ✗ mj["audioWaitingForSource"] = true;
765 }
766
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 15 times.
15 if (!m.note.empty()) {
767 ✗ mj["note"] = m.note;
768 }
769
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 mounts.append(mj);
770 15 }
771
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 result["mounts"] = mounts;
772
773
1/2
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
20 Json::StreamWriterBuilder wbuilder;
774
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
60 wbuilder["indentation"] = "";
775
3/6
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
20 return makeStringResponse(Json::writeString(wbuilder, result), 200, "application/json");
776 20 }
777
778 ✗ return makeStringResponse("{\"error\":\"unknown endpoint\"}", 404, "application/json");
779 20 }
780
781 #endif // HAS_RTSP_OUTPUT_GSTREAMER
782