GCC Code Coverage Report


Directory: ./
File: mediaoutput/VideoInputManager.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 935 1152 81.2%
Functions: 47 55 85.5%
Branches: 1076 2274 47.3%

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 "Warnings.h" // WarningHolder -- needed directly for NOPCH builds
16 #include <algorithm>
17 #include <atomic>
18 #include <cmath>
19 #include <map>
20 #include <mutex>
21 #include <set>
22 #include <vector>
23
24 #include <fcntl.h>
25 #include <poll.h>
26 #include <signal.h>
27 #include <spawn.h>
28 #include <sys/wait.h>
29 #include <unistd.h>
30 #ifdef __APPLE__
31 #include <crt_externs.h>
32 #define FPP_ENVIRON (*_NSGetEnviron())
33 #else
34 extern char** environ;
35 #define FPP_ENVIRON environ
36 #endif
37
38 #include "GStreamerOut.h"
39 #include "V4L2Device.h"
40 #include "VideoInputManager.h"
41 #include "VideoOutputManager.h"
42 #include "common.h"
43 #include "settings.h"
44 #include "log.h"
45
46 #include <fstream>
47 #include <sstream>
48
49 #include "fpp-json.h"
50
51 #ifdef HAS_GSTREAMER_VIDEO_INPUT
52 #include <gst/app/gstappsink.h>
53 #endif
54
55 // pipewiresink mode=provide enum value (GST_PIPEWIRE_SINK_MODE_PROVIDE)
56 static constexpr int PIPEWIRE_SINK_MODE_PROVIDE = 2;
57
58 // Quote a config-derived value for a gst_parse_launch() description.
59 //
60 // That description is a grammar, not a format string: space, '!', ',' and '='
61 // are all syntax. Splicing a raw config value in lets it reshape the pipeline -
62 // an RTSP URI containing a space or a comma turns the rest of the description
63 // into something else entirely, and the failure does not surface as the GError
64 // the callers already check. It has been observed faulting inside libgstreamer's
65 // own parser (gst_value_deserialize <- gst_caps_from_string <-
66 // gst_parse_launch_full), which takes fppd down with it and, since the source is
67 // re-started on the next reload, crash-loops the daemon.
68 //
69 // Quoting fixes it: inside quotes the parser takes the value literally, having
70 // first un-escaped backslash sequences - so backslashes and quotes in the value
71 // must themselves be escaped. Quoting a value that needed no escaping parses to
72 // exactly the same string, so this is safe to apply unconditionally.
73 167 static std::string GstQuote(const std::string& value) {
74 167 std::string out;
75
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 out.reserve(value.size() + 2);
76
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 out += '"';
77
2/2
✓ Branch 0 taken 2854 times.
✓ Branch 1 taken 167 times.
3021 for (char c : value) {
78
3/4
✓ Branch 0 taken 2853 times.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 2853 times.
2854 if (c == '\\' || c == '"') {
79
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 out += '\\';
80 }
81
1/2
✓ Branch 0 taken 2854 times.
✗ Branch 1 not taken.
2854 out += c;
82 }
83
1/2
✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
167 out += '"';
84 167 return out;
85 ✗ }
86
87 // Sanitise a URI down to URL-safe characters before it is spliced into a
88 // single-quoted shell argument, so nothing in it can close the quote.
89 12 static std::string ShellSafeUri(const std::string& uri) {
90 12 std::string out;
91
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 out.reserve(uri.size());
92
2/2
✓ Branch 0 taken 396 times.
✓ Branch 1 taken 12 times.
408 for (char c : uri) {
93
6/6
✓ Branch 0 taken 95 times.
✓ Branch 1 taken 301 times.
✓ Branch 2 taken 84 times.
✓ Branch 3 taken 11 times.
✓ Branch 4 taken 48 times.
✓ Branch 5 taken 36 times.
396 if (std::isalnum(static_cast<unsigned char>(c)) || c == ':' || c == '/'
94
8/10
✓ Branch 0 taken 26 times.
✓ Branch 1 taken 22 times.
✓ Branch 2 taken 23 times.
✓ Branch 3 taken 3 times.
✓ Branch 4 taken 23 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 23 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 12 times.
✓ Branch 9 taken 11 times.
48 || c == '.' || c == '-' || c == '_' || c == '~' || c == '?'
95
2/10
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 12 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
12 || c == '&' || c == '=' || c == '%' || c == '+' || c == '@') {
96
1/2
✓ Branch 0 taken 396 times.
✗ Branch 1 not taken.
396 out += c;
97 }
98 }
99 12 return out;
100 ✗ }
101
102 // What a yt-dlp resolution produced.
103 struct YtDlpResult {
104 std::string url; // empty when resolution failed
105 double fps = 0.0; // 0 when fps wasn't requested, or didn't parse
106 std::string diagnostics; // yt-dlp's own error text, for the log
107 };
108
109 // Resolve a page URL to a directly playable stream URL via yt-dlp.
110 //
111 // stderr used to be sent to /dev/null, which made every failure look identical
112 // in the log - "returned no valid URL" - while the reason for it was thrown
113 // away. The packaged yt-dlp goes stale against YouTube every few months and
114 // then fails with a specific, actionable message ("The page needs to be
115 // reloaded", "Sign in to confirm you're not a bot"), so that is the failure
116 // operators actually hit and the one the log could least afford to swallow.
117 // stderr is folded into the capture and reported instead: yt-dlp's own
118 // diagnostics are line-oriented and never begin with "http", so the URL line
119 // stays unambiguous among them.
120 //
121 // Bounded, and killable: it talks to YouTube, and a stalled connection would
122 // otherwise hold the caller for as long as the network stays silent. It runs
123 // in its own process group, so the deadline and Shutdown() (which has only a
124 // few seconds before fppd_stop kills fppd) can take down yt-dlp and anything
125 // it started. Spawned directly rather than through a shell, so the URI is an
126 // argument, not shell text.
127 static constexpr int YTDLP_TIMEOUT_SEC = 45;
128 static std::atomic<pid_t> s_ytDlpPgid{ 0 };
129
130 1867 static void KillRunningYtDlp() {
131 1867 pid_t pgid = s_ytDlpPgid.load();
132
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1866 times.
1867 if (pgid > 0) {
133 1 kill(-pgid, SIGKILL);
134 }
135 1867 }
136
137 12 static YtDlpResult RunYtDlp(const std::string& format, const std::string& uri, bool wantFps) {
138 12 YtDlpResult res;
139
3/6
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 60 times.
✓ Branch 3 taken 12 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
84 std::vector<std::string> args = { "yt-dlp", "-f", format, "--print", "url" };
140
2/2
✓ Branch 0 taken 10 times.
✓ Branch 1 taken 2 times.
12 if (wantFps) {
141
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
20 args.push_back("--print");
142
2/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
20 args.push_back("fps");
143 }
144 // Only a web URL, and after "--" so it can never be read as an option:
145 // ShellSafeUri() keeps '-' and '=', and IsYouTubeUri() only looks for
146 // "youtube.com/" anywhere in it, so "--config-locations=.../youtube.com/x"
147 // had yt-dlp load an uploaded config, which can carry --exec.
148
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 std::string safeUri = ShellSafeUri(uri);
149
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 std::string scheme = safeUri.substr(0, 8);
150 12 std::transform(scheme.begin(), scheme.end(), scheme.begin(), ::tolower);
151
13/30
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 12 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 12 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 12 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1 times.
✓ Branch 11 taken 11 times.
✓ Branch 12 taken 12 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 12 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 12 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 12 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 1 times.
✓ Branch 21 taken 11 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.
✗ Branch 28 not taken.
✗ Branch 29 not taken.
60 if (!startsWith(scheme, "http://") && !startsWith(scheme, "https://")) {
152
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 res.diagnostics = "not an http(s) URL";
153 1 return res;
154 }
155
2/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
11 args.push_back("--");
156
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 args.push_back(safeUri);
157 11 std::vector<char*> argv;
158
2/2
✓ Branch 0 taken 95 times.
✓ Branch 1 taken 11 times.
106 for (auto& a : args) {
159
1/2
✓ Branch 0 taken 95 times.
✗ Branch 1 not taken.
95 argv.push_back(a.data());
160 }
161
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 argv.push_back(nullptr);
162
163 int fds[2];
164
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 11 times.
11 if (pipe(fds) != 0) {
165 ✗ res.diagnostics = "could not run yt-dlp";
166 ✗ return res;
167 }
168
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 fcntl(fds[0], F_SETFD, FD_CLOEXEC);
169 posix_spawn_file_actions_t actions;
170 11 posix_spawn_file_actions_init(&actions);
171 11 posix_spawn_file_actions_adddup2(&actions, fds[1], STDOUT_FILENO);
172 11 posix_spawn_file_actions_adddup2(&actions, fds[1], STDERR_FILENO);
173 11 posix_spawn_file_actions_addclose(&actions, fds[1]);
174 posix_spawnattr_t attr;
175 11 posix_spawnattr_init(&attr);
176 11 posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP);
177 11 posix_spawnattr_setpgroup(&attr, 0);
178 11 pid_t pid = -1;
179
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 int rc = posix_spawnp(&pid, "yt-dlp", &actions, &attr, argv.data(), FPP_ENVIRON);
180 11 posix_spawn_file_actions_destroy(&actions);
181 11 posix_spawnattr_destroy(&attr);
182
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 close(fds[1]);
183
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 11 times.
11 if (rc != 0) {
184 ✗ close(fds[0]);
185 ✗ res.diagnostics = "could not run yt-dlp";
186 ✗ return res;
187 }
188 11 s_ytDlpPgid = pid;
189
190 // stderr is folded into the capture (see above); read until yt-dlp exits
191 // and closes the pipe, or the deadline passes.
192 11 std::string output;
193 char buf[4096];
194
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 uint64_t deadline = GetMonotonicTimeMS() + (uint64_t)YTDLP_TIMEOUT_SEC * 1000;
195 11 bool timedOut = false;
196 for (;;) {
197
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 uint64_t now = GetMonotonicTimeMS();
198
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 16 times.
16 if (now >= deadline) {
199 ✗ timedOut = true;
200 ✗ break;
201 }
202 16 struct pollfd pfd = { fds[0], POLLIN, 0 };
203
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 int pr = poll(&pfd, 1, (int)std::min<uint64_t>(deadline - now, 1000));
204
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 16 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
16 if (pr < 0 && errno != EINTR) {
205 ✗ break;
206 }
207
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 16 times.
16 if (pr <= 0) {
208 ✗ continue;
209 }
210
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 ssize_t n = read(fds[0], buf, sizeof(buf));
211
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 11 times.
16 if (n > 0) {
212
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 output.append(buf, (size_t)n);
213
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 11 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
11 } else if (n == 0 || errno != EINTR) {
214 break;
215 }
216 5 }
217
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 11 times.
11 if (timedOut) {
218 ✗ kill(-pid, SIGKILL);
219 ✗ res.diagnostics = "yt-dlp did not answer within " + std::to_string(YTDLP_TIMEOUT_SEC) + "s";
220 }
221 11 s_ytDlpPgid = 0;
222
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 close(fds[0]);
223 // fppd sets SA_NOCLDWAIT, so the kernel reaps yt-dlp itself and this
224 // usually fails with ECHILD; it only matters where that isn't set. The
225 // exit status can't be relied on either way - validate the output instead.
226
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 waitpid(pid, nullptr, WNOHANG);
227
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 11 times.
11 if (timedOut) {
228 ✗ return res;
229 }
230
231
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 std::istringstream iss(output);
232 11 std::string line;
233 11 bool fpsLineNext = false;
234
4/6
✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 21 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✓ Branch 5 taken 11 times.
21 while (std::getline(iss, line)) {
235
5/10
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 10 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 10 times.
10 while (!line.empty() && (line.back() == '\n' || line.back() == '\r' || line.back() == ' ')) {
236 ✗ line.pop_back();
237 }
238
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 10 times.
10 if (line.empty()) {
239 ✗ continue;
240 }
241
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 7 times.
10 if (fpsLineNext) {
242 // --print fps emits the framerate on the line right after the URL.
243 3 fpsLineNext = false;
244 try {
245
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 res.fps = std::stod(line);
246 ✗ } catch (...) {
247 ✗ res.fps = 0.0;
248 ✗ }
249
7/8
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✓ Branch 5 taken 2 times.
✓ Branch 6 taken 4 times.
✓ Branch 7 taken 3 times.
7 } else if (res.url.empty() && line.rfind("http", 0) == 0) {
250
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 res.url = line;
251 4 fpsLineNext = wantFps;
252
5/6
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 1 times.
3 } else if (res.url.empty() && res.diagnostics.size() < 400) {
253
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (!res.diagnostics.empty()) {
254 ✗ res.diagnostics += " | ";
255 }
256
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 res.diagnostics += line;
257 }
258 }
259 11 return res;
260
5/14
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 12 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 12 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 12 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
36 }
261
262 // The formats StartSource() and StartSourceWithAudio() ask yt-dlp for.
263 20 static std::string YtVideoFormat(int height) {
264 // Select the best H.264 video-only stream that fits the configured
265 // output height. This avoids fetching 240p when the user wants
266 // 720p/1080p, while still capping bandwidth to what the Pi can
267 // actually decode and display.
268
2/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
20 return "bestvideo[height<=" + std::to_string(height) + "][vcodec^=avc1]/worst[vcodec^=avc1]";
269 }
270 // 234 = high quality HLS audio, 233 = low quality HLS audio
271 // 140 = AAC 128k from DASH (non-live fallback), 139 = HE-AAC 48k
272 static const char* const YT_AUDIO_FORMAT = "234/233/140/139/bestaudio";
273
274 18 static bool IsYouTubeUri(const std::string& uri) {
275
3/4
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 10 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 8 times.
26 return uri.find("youtube.com/") != std::string::npos ||
276 26 uri.find("youtu.be/") != std::string::npos;
277 }
278
279 // yt-dlp results fetched before StartSource() runs (see StartLoadedSources()),
280 // keyed by format and URI. Only touched under VideoInputManager::m_mutex.
281 static std::map<std::string, YtDlpResult> s_ytPrefetched;
282
283 23 static std::string YtKey(const std::string& format, const std::string& uri) {
284
2/4
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 23 times.
✗ Branch 3 not taken.
23 return format + "\n" + uri;
285 }
286
287 // What StartSource() calls: the prefetched answer when there is one, else a
288 // live lookup (a source started outside StartLoadedSources()).
289 11 static YtDlpResult ResolveYtDlp(const std::string& format, const std::string& uri, bool wantFps) {
290
2/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
11 auto it = s_ytPrefetched.find(YtKey(format, uri));
291
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 if (it != s_ytPrefetched.end()) {
292
1/2
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
11 return it->second;
293 }
294 ✗ return RunYtDlp(format, uri, wantFps);
295 }
296
297 // Adopt the stream's native framerate, but never above the configured one -
298 // don't push a source to 60fps just because the stream is 60fps natively when
299 // the user asked for 30. videorate handles the downsampling.
300 3 static void ApplyDetectedFps(double detectedFps, int& framerate, const char* name) {
301
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
3 if (detectedFps < 1.0 || detectedFps > 120.0) {
302 ✗ LogInfo(VB_MEDIAOUT, "VideoInputManager: yt-dlp fps not available for '%s', using config %dfps\n",
303 name, framerate);
304 ✗ return;
305 }
306 3 int detected = (int)std::lround(detectedFps);
307 3 int configFps = framerate;
308 3 framerate = std::min(detected, configFps);
309
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 LogInfo(VB_MEDIAOUT, "VideoInputManager: yt-dlp detected %dfps for '%s', "
310 "capped to configured %dfps -> using %dfps\n",
311 detected, name, configFps, framerate);
312 }
313
314 // Warnings about a source's config or its stream (an invalid value, no URI,
315 // an RTSP stream with no video). Each is recorded as it is raised and all
316 // come down at the next reload -- which raises again whichever is still true
317 // -- and at shutdown; nothing else retracts them. "Failed to start" also
318 // comes down once the source reaches PLAYING.
319 static std::mutex s_sourceWarningsLock;
320 static std::set<std::string> s_sourceWarnings;
321
322 22 static void AddSourceWarning(const std::string& msg) {
323 {
324
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 std::lock_guard<std::mutex> lock(s_sourceWarningsLock);
325
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 s_sourceWarnings.insert(msg);
326 22 }
327
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 WarningHolder::AddWarning(56, msg);
328 22 }
329
330 4 static void RemoveSourceWarning(const std::string& msg) {
331 {
332
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::lock_guard<std::mutex> lock(s_sourceWarningsLock);
333
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 s_sourceWarnings.erase(msg);
334 4 }
335
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 WarningHolder::RemoveWarning(56, msg);
336 4 }
337
338 3767 static void ClearSourceWarnings() {
339 3767 std::set<std::string> warnings;
340 {
341
1/2
✓ Branch 0 taken 3767 times.
✗ Branch 1 not taken.
3767 std::lock_guard<std::mutex> lock(s_sourceWarningsLock);
342 3767 warnings.swap(s_sourceWarnings);
343 3767 }
344
2/2
✓ Branch 0 taken 21 times.
✓ Branch 1 taken 3767 times.
3788 for (const auto& msg : warnings) {
345
2/4
✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 21 times.
✗ Branch 3 not taken.
42 WarningHolder::RemoveWarning(56, msg);
346 }
347 3767 }
348
349 // Control characters have no representation in the description grammar and
350 // never belong in a URI, device path or element name, so reject them up front
351 // rather than trying to encode them. Checked once per source in StartSource()
352 // instead of at each of the seven splice sites.
353 449 static bool GstValueUsable(const std::string& value, const char* what,
354 const std::string& sourceName) {
355
2/2
✓ Branch 0 taken 3294 times.
✓ Branch 1 taken 447 times.
3741 for (unsigned char c : value) {
356
3/4
✓ Branch 0 taken 3292 times.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 3292 times.
3294 if (c < 0x20 || c == 0x7f) {
357
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogWarn(VB_MEDIAOUT, "VideoInputManager: %s for '%s' contains a control character\n",
358 what, sourceName.c_str());
359
4/8
✓ 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.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
2 AddSourceWarning("Video input '" + sourceName + "' has an invalid " + what);
360 2 return false;
361 }
362 }
363 447 return true;
364 }
365
366 #ifdef HAS_GSTREAMER_VIDEO_INPUT
367 // Per-pipeline state behind the rtspsrc stream decisions below.
368 //
369 // Heap-allocated and owned by the rtspsrc element as object data -- freed
370 // when rtspsrc is finalised along with the pipeline -- rather than living in
371 // the SourceInfo. The pipeline outlives StartSource() and a
372 // Reload can reallocate m_sources while it is still running, which is the
373 // same hazard the run thread's copied-by-value locals guard against.
374 struct RtspStreamFilter {
375 std::string sourceName;
376 bool wantAudio = false; // mirrors SourceInfo::audioEnabled
377 bool videoSelected = false;
378 bool audioSelected = false;
379 int offered = 0; // SDP media select-stream has been asked about
380 };
381
382 static const char* RTSP_FILTER_KEY = "fpp-rtsp-stream-filter";
383
384 11 static std::string NoVideoTrackWarning(const std::string& sourceName) {
385
2/4
✓ Branch 0 taken 11 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
11 return "Video input '" + sourceName + "': the RTSP stream contains no video track";
386 }
387
388 // The camera's SDP offered media, none of it video. Retried on the backoff
389 // like any failed source, since the camera's own configuration can add the
390 // video track back; the warning comes down when a video media is selected
391 // again (RtspSelectStream), or at the next reload.
392 7 static void ReportNoVideoTrack(const RtspStreamFilter* filter) {
393 7 LogErr(VB_MEDIAOUT, "VideoInputManager: '%s' RTSP stream offers no video track\n",
394 filter->sourceName.c_str());
395
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
7 AddSourceWarning(NoVideoTrackWarning(filter->sourceName));
396 7 }
397
398 // rtspsrc's "select-stream" signal: return FALSE to keep it from ever
399 // SETUPing a given SDP media.
400 //
401 // Every media rtspsrc sets up becomes one src pad, and every pad has to
402 // reach a sink: an unlinked one makes rtspsrc's delivery loop tear the whole
403 // pipeline down with "Internal data stream error ... reason not-linked",
404 // which is fatal under protocols=tcp where all media share one connection.
405 // The pipelines built below offer exactly one video branch, plus one audio
406 // branch when audio extraction is enabled, so accept at most one media for
407 // each and refuse the rest before they are ever requested. Refusing here
408 // rather than sinking the extra pads also means the camera never sends
409 // them, so nothing is decoded or carried that we would only discard.
410 //
411 // Taking only the *first* video matters for cameras that advertise two video
412 // media in a single SDP -- some offer H.264 and MJPEG as alternatives. Main
413 // and sub substreams are normally separate RTSP URLs and never reach here.
414 14 static gboolean RtspSelectStream(GstElement*, guint idx, GstCaps* caps, gpointer userData) {
415 14 auto* filter = static_cast<RtspStreamFilter*>(userData);
416
4/8
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 14 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 14 times.
14 if (!filter || !caps || gst_caps_get_size(caps) == 0) {
417 ✗ return TRUE;
418 }
419
420 14 GstStructure* s = gst_caps_get_structure(caps, 0);
421
1/2
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
14 const gchar* media = s ? gst_structure_get_string(s, "media") : nullptr;
422
423 // "media" carries the SDP m= line type and is always present in practice.
424 // An unlabelled media is treated as the video track rather than dropped,
425 // so a server we cannot classify still gets its one stream through -- the
426 // same benefit-of-the-doubt the original video-only filter gave it.
427
3/4
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 9 times.
14 const bool isVideo = !media || g_strcmp0(media, "video") == 0;
428
3/4
✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✓ Branch 3 taken 5 times.
14 const bool isAudio = media && g_strcmp0(media, "audio") == 0;
429 14 filter->offered++;
430
431
2/2
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 9 times.
14 if (isVideo) {
432
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 4 times.
5 if (filter->videoSelected) {
433 1 LogWarn(VB_MEDIAOUT, "VideoInputManager: '%s' SDP offers more than one video "
434 "stream; keeping the first and ignoring media %u\n",
435 filter->sourceName.c_str(), idx);
436 1 return FALSE;
437 }
438 4 filter->videoSelected = true;
439
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 RemoveSourceWarning(NoVideoTrackWarning(filter->sourceName));
440 4 return TRUE;
441 }
442
443
4/6
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 2 times.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
9 if (isAudio && filter->wantAudio && !filter->audioSelected) {
444 7 filter->audioSelected = true;
445 7 return TRUE;
446 }
447
448
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogDebug(VB_MEDIAOUT, "VideoInputManager: '%s' ignoring SDP media %u (%s)\n",
449 filter->sourceName.c_str(), idx, media ? media : "unlabelled");
450 2 return FALSE;
451 }
452
453 // rtspsrc has finished creating pads for everything select-stream accepted.
454 // A stream with no video media at all leaves the video branch empty, which
455 // otherwise surfaces only as "No frames" in the preview with nothing in the
456 // log to explain it -- so name the actual cause here.
457 10 static void RtspNoMorePads(GstElement*, gpointer userData) {
458 10 auto* filter = static_cast<RtspStreamFilter*>(userData);
459
3/4
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 6 times.
10 if (!filter || filter->videoSelected) {
460 4 return;
461 }
462 6 ReportNoVideoTrack(filter);
463 }
464
465 // When select-stream refused every media -- an audio-only camera on a source
466 // with audio off -- rtspsrc creates no pads, so "no-more-pads" never fires:
467 // it fails instead with "No supported stream was found", which reads as a
468 // transport problem. Called for a pipeline error; reports the real cause and
469 // returns true if that is what happened.
470 5 static bool RtspFailedForNoVideo(GstElement* pipeline) {
471 5 GstElement* rtspEl = gst_bin_get_by_name(GST_BIN(pipeline), "rtspvsrc");
472
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
5 if (!rtspEl) {
473 ✗ return false;
474 }
475 // Written on rtspsrc's thread before it posted the error this is called for.
476 5 auto* filter = static_cast<RtspStreamFilter*>(g_object_get_data(G_OBJECT(rtspEl), RTSP_FILTER_KEY));
477
4/6
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 4 times.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
5 bool noVideo = filter && filter->offered > 0 && !filter->videoSelected;
478
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 4 times.
5 if (noVideo) {
479 1 ReportNoVideoTrack(filter);
480 }
481 5 gst_object_unref(rtspEl);
482 5 return noVideo;
483 }
484
485 // Route each rtspsrc pad to the branch matching its media type.
486 //
487 // Only needed for the audio+video pipeline. gst_parse_launch's own delayed
488 // linking drops its pad-added handler after the first pad it manages to link,
489 // so it can satisfy exactly one branch and the second would be left dangling
490 // -- the very failure this whole filter exists to prevent.
491 8 static void RtspLinkPadToBranch(GstElement* src, GstPad* pad, gpointer userData) {
492 8 auto* filter = static_cast<RtspStreamFilter*>(userData);
493
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 if (!filter) {
494 ✗ return;
495 }
496 8 const std::string& name = filter->sourceName;
497
498 8 GstCaps* caps = gst_pad_get_current_caps(pad);
499
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 if (!caps) {
500 ✗ caps = gst_pad_query_caps(pad, nullptr);
501 }
502 8 const gchar* media = nullptr;
503
3/6
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
8 if (caps && gst_caps_get_size(caps) > 0) {
504 8 media = gst_structure_get_string(gst_caps_get_structure(caps, 0), "media");
505 }
506
3/4
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 1 times.
8 const char* branch = (media && g_strcmp0(media, "audio") == 0) ? "adec" : "vdec";
507
508 8 GstObject* parent = gst_element_get_parent(src);
509
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 GstElement* dec = parent ? gst_bin_get_by_name(GST_BIN(parent), branch) : nullptr;
510
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 if (!dec) {
511 ✗ LogErr(VB_MEDIAOUT, "VideoInputManager: '%s' has no '%s' branch for RTSP media '%s'\n",
512 name.c_str(), branch, media ? media : "unlabelled");
513 } else {
514 8 GstPad* sinkPad = gst_element_get_static_pad(dec, "sink");
515
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 if (!sinkPad) {
516 ✗ LogErr(VB_MEDIAOUT, "VideoInputManager: '%s' branch '%s' has no sink pad\n",
517 name.c_str(), branch);
518
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 } else if (gst_pad_is_linked(sinkPad)) {
519 // select-stream should have kept this from happening; if a server
520 // produces a second pad for a branch anyway, leaving it unlinked
521 // would kill the pipeline, so say so rather than fail silently.
522 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: '%s' branch '%s' is already linked, "
523 "ignoring extra RTSP media '%s'\n",
524 name.c_str(), branch, media ? media : "unlabelled");
525 } else {
526 8 GstPadLinkReturn ret = gst_pad_link(pad, sinkPad);
527
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
8 if (ret != GST_PAD_LINK_OK) {
528 ✗ LogErr(VB_MEDIAOUT, "VideoInputManager: '%s' could not link RTSP media '%s' "
529 "to '%s' (%d)\n", name.c_str(), media ? media : "unlabelled", branch, ret);
530 } else {
531
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 LogInfo(VB_MEDIAOUT, "VideoInputManager: '%s' linked RTSP media '%s' to '%s'\n",
532 name.c_str(), media ? media : "unlabelled", branch);
533 }
534 }
535
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (sinkPad) {
536 8 gst_object_unref(sinkPad);
537 }
538 8 gst_object_unref(dec);
539 }
540
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (parent) {
541 8 gst_object_unref(parent);
542 }
543
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (caps) {
544 8 gst_caps_unref(caps);
545 }
546 }
547 #endif
548
549 #ifdef HAS_GSTREAMER_VIDEO_INPUT
550 // Publish a pipeline's "apwsink" pipewiresink as a PipeWire Audio/Source node
551 // so the mix bus can route it, and attach the throughput probe used to spot
552 // audio arriving faster or slower than real time.
553 //
554 // Shared by the YouTube (StartSourceWithAudio) and RTSP audio paths: both end
555 // in the same named sink and want it configured identically apart from
556 // branchMayStayEmpty, which is set when the source may carry no audio track.
557 8 static void ConfigureAudioSourceSink(GstElement* pipeline, const std::string& audioNodeName,
558 const std::string& sourceName, bool branchMayStayEmpty) {
559 8 GstElement* apwsink = gst_bin_get_by_name(GST_BIN(pipeline), "apwsink");
560
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (apwsink) {
561
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 std::string audioNodeDesc = "FPP Audio Input: " + sourceName;
562
563
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 GstStructure* aprops = gst_structure_new("props",
564 "media.class", G_TYPE_STRING, "Audio/Source",
565 "node.name", G_TYPE_STRING, audioNodeName.c_str(),
566 "node.description", G_TYPE_STRING, audioNodeDesc.c_str(),
567 "node.autoconnect", G_TYPE_BOOLEAN, FALSE,
568 "node.always-process", G_TYPE_BOOLEAN, TRUE,
569 "node.latency", G_TYPE_STRING, "2048/48000",
570 "object.register", G_TYPE_STRING, "true",
571 NULL);
572
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 g_object_set(apwsink, "stream-properties", aprops, NULL);
573
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_structure_free(aprops);
574
575
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 g_object_set(apwsink, "mode", PIPEWIRE_SINK_MODE_PROVIDE, NULL);
576 // sync=FALSE: provide mode crashes with sync=TRUE.
577 // The clocksync element upstream paces the audio at real-time instead.
578
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 g_object_set(apwsink, "sync", FALSE, NULL);
579
580 // An RTSP camera may turn out to advertise no audio at all, leaving this
581 // branch with nothing flowing through it. async=FALSE keeps the sink out
582 // of the pipeline's preroll so an empty audio branch cannot stall the
583 // video branch's path to PLAYING. The YouTube path already resolved a
584 // real audio URL before building the pipeline, so it keeps the default.
585
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 1 times.
8 if (branchMayStayEmpty) {
586
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_object_set(apwsink, "async", FALSE, NULL);
587 }
588
589 // Add a throughput probe on pipewiresink's sink pad to measure
590 // how fast audio actually flows (vs expected real-time pace).
591
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 GstPad* sinkPad = gst_element_get_static_pad(apwsink, "sink");
592
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (sinkPad) {
593 struct ProbeData {
594 int64_t totalBytes;
595 int64_t startUs;
596 int64_t lastLogUs;
597 int64_t lastPtsNs;
598 int sampleRate;
599 int bytesPerFrame;
600 };
601
2/4
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 8 times.
8 auto* pd = new ProbeData{0, 0, 0, 0, 48000, 8}; // default 2ch*F32LE=8 bpf
602
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_pad_add_probe(sinkPad, GST_PAD_PROBE_TYPE_BUFFER,
603 ✗ [](GstPad* pad, GstPadProbeInfo* info, gpointer udata) -> GstPadProbeReturn {
604 91 auto* d = static_cast<ProbeData*>(udata);
605 91 GstBuffer* buf = GST_PAD_PROBE_INFO_BUFFER(info);
606
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 83 times.
91 if (d->startUs == 0) {
607 8 d->startUs = g_get_monotonic_time();
608 // Log actual negotiated caps
609 8 GstCaps* caps = gst_pad_get_current_caps(pad);
610
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (caps) {
611
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gchar* cs = gst_caps_to_string(caps);
612
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 LogInfo(VB_MEDIAOUT, "Audio probe caps: %s\n", cs);
613 // Extract bytes-per-frame from caps to fix calculation
614
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 GstStructure* s = gst_caps_get_structure(caps, 0);
615
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 const gchar* fmt = gst_structure_get_string(s, "format");
616
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 int ch = 0; gst_structure_get_int(s, "channels", &ch);
617 8 int bps = 2; // default S16LE
618
2/6
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 8 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
8 if (fmt && (strcmp(fmt, "F32LE") == 0 || strcmp(fmt, "F32BE") == 0 ||
619 ✗ strcmp(fmt, "S32LE") == 0 || strcmp(fmt, "S32BE") == 0))
620 8 bps = 4;
621 ✗ else if (fmt && (strcmp(fmt, "F64LE") == 0 || strcmp(fmt, "F64BE") == 0))
622 ✗ bps = 8;
623
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (ch > 0) d->bytesPerFrame = ch * bps;
624
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 g_free(cs);
625
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_caps_unref(caps);
626 }
627 }
628 91 d->totalBytes += gst_buffer_get_size(buf);
629 // Also track by PTS
630 91 GstClockTime pts = GST_BUFFER_PTS(buf);
631
1/2
✓ Branch 0 taken 91 times.
✗ Branch 1 not taken.
91 if (GST_CLOCK_TIME_IS_VALID(pts)) d->lastPtsNs = pts;
632 91 int64_t now = g_get_monotonic_time();
633
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 83 times.
91 if (now - d->lastLogUs >= 5000000) { // every 5s
634 8 d->lastLogUs = now;
635 8 double audioSecs = (double)d->totalBytes / (d->sampleRate * d->bytesPerFrame);
636 8 double wallSecs = (double)(now - d->startUs) / 1000000.0;
637
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 double ratio = wallSecs > 0 ? audioSecs / wallSecs : 0;
638 8 double ptsSecs = (double)d->lastPtsNs / 1000000000.0;
639 8 LogInfo(VB_MEDIAOUT, "Audio throughput: %.1fs audio in %.1fs wall (ratio=%.3f) bpf=%d pts=%.1fs\n",
640 audioSecs, wallSecs, ratio, d->bytesPerFrame, ptsSecs);
641 }
642 91 return GST_PAD_PROBE_OK;
643 }, pd,
644
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 [](gpointer udata) { delete static_cast<ProbeData*>(udata); });
645
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_object_unref(sinkPad);
646 }
647
648
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 gst_object_unref(apwsink);
649 8 }
650 8 }
651 #endif
652
653 4566 VideoInputManager& VideoInputManager::Instance() {
654
3/4
✓ Branch 0 taken 1887 times.
✓ Branch 1 taken 2679 times.
✓ Branch 2 taken 1887 times.
✗ Branch 3 not taken.
4566 static VideoInputManager instance;
655 4566 return instance;
656 }
657
658 ✗ VideoInputManager::~VideoInputManager() {
659 ✗ Shutdown();
660 ✗ }
661
662 1887 void VideoInputManager::Init() {
663
1/2
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
1887 std::unique_lock<std::mutex> lock(m_mutex);
664
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1887 times.
1887 if (m_initialized)
665 ✗ return;
666
667 // Only init if PipeWire backend is active (Simple or Advanced).
668 // Simple PipeWire mode does not configure video input sources by itself,
669 // but the manager is still safe to leave initialised.
670
3/4
✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✓ Branch 3 taken 1881 times.
1887 if (!isPipeWireBackend()) {
671
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 std::string backend = getSetting("MediaBackend");
672
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
6 LogDebug(VB_MEDIAOUT, "VideoInputManager: Skipping init (MediaBackend=%s, not pipewire)\n", backend.c_str());
673 6 return;
674 6 }
675
676 1881 m_initialized = true;
677 // A prior Shutdown() may have latched m_shutdownDone; since we're
678 // (re)starting sources here, a future Shutdown() must be allowed to
679 // run its teardown again.
680 1881 m_shutdownDone = false;
681
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 lock.unlock();
682 {
683
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 std::lock_guard<std::mutex> rl(m_reloadMutex);
684 1881 m_stopReloads = false;
685 1881 }
686
687 // Load and start the sources on the worker: a YouTube source can take
688 // yt-dlp's whole timeout to resolve, and fppd's startup shouldn't wait.
689
1/2
✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
1881 RequestReload();
690
2/2
✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 6 times.
1887 }
691
692 1934 void VideoInputManager::RequestReload() {
693
1/2
✓ Branch 0 taken 1934 times.
✗ Branch 1 not taken.
1934 std::lock_guard<std::mutex> rl(m_reloadMutex);
694
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1934 times.
1934 if (m_stopReloads) {
695 ✗ return;
696 }
697 1934 m_reloadPending = true;
698
1/2
✓ Branch 0 taken 1934 times.
✗ Branch 1 not taken.
1934 EnsureReloadWorkerLocked();
699 1934 m_reloadCv.notify_all();
700
1/2
✓ Branch 0 taken 1934 times.
✗ Branch 1 not taken.
1934 }
701
702 23 void VideoInputManager::ScheduleSourceRestart(int sourceId) {
703
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 std::lock_guard<std::mutex> rl(m_reloadMutex);
704
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 23 times.
23 if (m_stopReloads) {
705 ✗ return;
706 }
707 23 auto now = std::chrono::steady_clock::now();
708
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 RestartState& r = m_restarts[sourceId];
709
7/10
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 20 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 3 times.
✓ Branch 8 taken 20 times.
✓ Branch 9 taken 3 times.
23 if (r.delayMs == 0 || now - r.lastFailure > std::chrono::milliseconds(RESTART_QUIET_RESET_MS)) {
710 20 r.delayMs = RESTART_MIN_DELAY_MS;
711 } else {
712 3 r.delayMs = std::min(r.delayMs * 2, RESTART_MAX_DELAY_MS);
713 }
714 23 r.lastFailure = now;
715
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 r.due = now + std::chrono::milliseconds(r.delayMs);
716 23 r.pending = true;
717
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 LogInfo(VB_MEDIAOUT, "VideoInputManager: Restarting source %d in %d ms\n", sourceId, r.delayMs);
718
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 EnsureReloadWorkerLocked();
719 23 m_reloadCv.notify_all();
720
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 }
721
722 1957 void VideoInputManager::EnsureReloadWorkerLocked() {
723
2/2
✓ Branch 0 taken 53 times.
✓ Branch 1 taken 1904 times.
1957 if (m_reloadRunning) {
724 53 return; // the running worker sees the new request and acts on it
725 }
726
2/2
✓ Branch 0 taken 23 times.
✓ Branch 1 taken 1881 times.
1904 if (m_reloadThread.joinable()) {
727 23 m_reloadThread.join(); // a finished worker; it no longer needs the lock
728 }
729 1904 m_reloadRunning = true;
730 3808 m_reloadThread = std::thread([this]() {
731
2/4
✓ Branch 0 taken 1904 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1904 times.
✗ Branch 3 not taken.
1904 SetThreadName("FPP-VideoInReload");
732
1/2
✓ Branch 0 taken 1904 times.
✗ Branch 1 not taken.
1904 std::unique_lock<std::mutex> l(m_reloadMutex);
733
2/2
✓ Branch 0 taken 3813 times.
✓ Branch 1 taken 11 times.
3824 while (!m_stopReloads) {
734
2/2
✓ Branch 0 taken 1900 times.
✓ Branch 1 taken 1913 times.
3813 if (m_reloadPending) {
735 // A reload restarts every source from the new config, so
736 // queued restarts and their backoff go with the old one.
737 1900 m_reloadPending = false;
738 1900 m_restarts.clear();
739
1/2
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
1900 l.unlock();
740
1/2
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
1900 Reload();
741
1/2
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
1900 l.lock();
742 1916 continue;
743 }
744 1913 int id = -1;
745 1913 std::chrono::steady_clock::time_point due;
746
2/2
✓ Branch 0 taken 31 times.
✓ Branch 1 taken 1913 times.
1944 for (const auto& [sid, r] : m_restarts) {
747
8/10
✓ Branch 0 taken 27 times.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 20 times.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 7 times.
✓ Branch 8 taken 20 times.
✓ Branch 9 taken 11 times.
31 if (r.pending && (id < 0 || r.due < due)) {
748 20 id = sid;
749 20 due = r.due;
750 }
751 }
752
2/2
✓ Branch 0 taken 1893 times.
✓ Branch 1 taken 20 times.
1913 if (id < 0) {
753 1893 break;
754 }
755
3/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✓ Branch 3 taken 4 times.
20 if (std::chrono::steady_clock::now() < due) {
756
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 m_reloadCv.wait_until(l, due);
757 16 continue;
758 }
759
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 m_restarts[id].pending = false;
760
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 l.unlock();
761
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 RestartSource(id);
762
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 l.lock();
763 }
764 1904 m_reloadRunning = false;
765
1/2
✓ Branch 0 taken 1904 times.
✗ Branch 1 not taken.
3808 });
766 }
767
768 4 void VideoInputManager::RestartSource(int sourceId) {
769
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::unique_lock<std::mutex> lock(m_mutex);
770 8 auto find = [this, sourceId]() -> SourceInfo* {
771
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 for (auto& s : m_sources) {
772
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (s.id == sourceId) {
773 8 return &s;
774 }
775 }
776 ✗ return nullptr;
777 4 };
778 4 SourceInfo* source = find();
779
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 not taken.
✓ Branch 7 taken 4 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 4 times.
4 if (!m_initialized || !source || !source->enabled || source->running) {
780 ✗ return;
781 }
782
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogInfo(VB_MEDIAOUT, "VideoInputManager: Restarting source '%s'\n", source->name.c_str());
783 // The run thread cleared `running` on its way out and takes no lock after
784 // that, so this join is immediate.
785
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 StopSource(*source);
786
787
4/6
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 3 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
4 bool youTube = source->type == "urisrc" && IsYouTubeUri(source->uri);
788
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::string uri = source->uri;
789 4 int height = source->height;
790 4 bool audio = source->audioEnabled;
791
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 lock.unlock();
792
793 // As in Reload(): the old pipeline must let go of the device or socket
794 // before the new one opens it, and a wedged teardown or a yt-dlp lookup
795 // must not hold m_mutex.
796
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 JoinTeardownThreads();
797 4 std::map<std::string, YtDlpResult> results;
798
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (youTube) {
799 ✗ results[YtKey(YtVideoFormat(height), uri)] = RunYtDlp(YtVideoFormat(height), uri, true);
800 ✗ if (audio) {
801 ✗ results[YtKey(YT_AUDIO_FORMAT, uri)] = RunYtDlp(YT_AUDIO_FORMAT, uri, false);
802 }
803 }
804
805
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 lock.lock();
806 {
807
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 std::lock_guard<std::mutex> rl(m_reloadMutex);
808
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (m_stopReloads) {
809 ✗ return;
810 }
811
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 }
812 4 source = find();
813
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 not taken.
✓ Branch 7 taken 4 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 4 times.
4 if (!m_initialized || !source || !source->enabled || source->running) {
814 ✗ return;
815 }
816 4 s_ytPrefetched = std::move(results);
817
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 bool started = StartSource(*source);
818 4 s_ytPrefetched.clear();
819
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
4 if (!started) {
820 // A lookup or a resource that isn't back yet (a YouTube URL that
821 // won't resolve): keep trying, on the backoff.
822 ✗ ScheduleSourceRestart(sourceId);
823 }
824
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 }
825
826 61 void VideoInputManager::StartLoadedSources(std::unique_lock<std::mutex>& lock) {
827 // Resolving a YouTube source runs yt-dlp, which can take as long as its
828 // timeout. Holding m_mutex across that stalled everything that asks this
829 // manager about its sources -- the video output manager starting media
830 // playback among them, under its own lock. Collect what needs resolving,
831 // resolve it unlocked, and start the sources from the results.
832 struct YtJob {
833 std::string format;
834 std::string uri;
835 bool wantFps;
836 };
837 61 std::vector<YtJob> jobs;
838
2/2
✓ Branch 0 taken 84 times.
✓ Branch 1 taken 61 times.
145 for (const auto& source : m_sources) {
839
8/10
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✓ Branch 5 taken 67 times.
✓ Branch 6 taken 10 times.
✓ Branch 7 taken 7 times.
✓ Branch 8 taken 10 times.
✓ Branch 9 taken 74 times.
84 if (source.enabled && source.type == "urisrc" && IsYouTubeUri(source.uri)) {
840 10 jobs.push_back({ YtVideoFormat(source.height), source.uri, true });
841
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 8 times.
10 if (source.audioEnabled) {
842 4 jobs.push_back({ YT_AUDIO_FORMAT, source.uri, false });
843 }
844 }
845 }
846
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 53 times.
61 if (!jobs.empty()) {
847
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 lock.unlock();
848 8 std::map<std::string, YtDlpResult> results;
849
2/2
✓ Branch 0 taken 12 times.
✓ Branch 1 taken 8 times.
20 for (const auto& job : jobs) {
850 {
851 // Shutdown() waits for this; don't make it wait for every
852 // source when one lookup would do.
853
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 std::lock_guard<std::mutex> rl(m_reloadMutex);
854
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 12 times.
12 if (m_stopReloads) {
855 ✗ break;
856 }
857
1/2
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
12 }
858
3/6
✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 12 times.
✗ Branch 5 not taken.
12 results[YtKey(job.format, job.uri)] = RunYtDlp(job.format, job.uri, job.wantFps);
859 }
860
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 lock.lock();
861 8 s_ytPrefetched = std::move(results);
862
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 std::lock_guard<std::mutex> rl(m_reloadMutex);
863
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 7 times.
8 if (m_stopReloads) {
864 // Shutting down: don't start what it is about to tear down.
865 1 s_ytPrefetched.clear();
866 1 return;
867 }
868
4/4
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 1 times.
9 }
869
2/2
✓ Branch 0 taken 83 times.
✓ Branch 1 taken 60 times.
143 for (auto& source : m_sources) {
870 // A source that can't start yet (yt-dlp with no network at boot, a
871 // camera still coming up) is retried on the backoff, like one whose
872 // pipeline dies later; nothing else would try it again before Apply.
873
6/8
✓ Branch 0 taken 83 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 83 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✓ Branch 5 taken 70 times.
✓ Branch 6 taken 13 times.
✓ Branch 7 taken 70 times.
83 if (source.enabled && !StartSource(source)) {
874
1/2
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
13 ScheduleSourceRestart(source.id);
875 }
876 }
877 60 s_ytPrefetched.clear();
878
8/22
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 10 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 60 times.
✓ Branch 13 taken 1 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
✗ Branch 20 not taken.
✗ Branch 21 not taken.
75 }
879
880 1900 void VideoInputManager::Reload() {
881
1/2
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
1900 LogInfo(VB_MEDIAOUT, "VideoInputManager: Reloading configuration\n");
882
1/2
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
1900 StopAllSources();
883
884 // Join the teardown threads spawned by the StopAllSources() above (plus
885 // any still outstanding from an earlier cycle) before touching the
886 // devices/URIs again — otherwise StartSource() below can race the old
887 // pipeline's gst_element_set_state(..., NULL) and fail to reopen a
888 // v4l2/rtsp resource that's still held (EBUSY).
889
1/2
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
1900 JoinTeardownThreads();
890
891 // The sources are rebuilt from the new config, and raise again what
892 // still applies. Cleared only now: until the old run threads were
893 // joined and their pipelines set to NULL (which stops rtspsrc's
894 // streaming thread), they could still raise "failed to start" or "no
895 // video track" for a source the new config may not have.
896
1/2
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
1900 ClearSourceWarnings();
897
898
1/2
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
1900 std::unique_lock<std::mutex> lock(m_mutex);
899 1900 m_initialized = true;
900 // See Init(): allow a subsequent Shutdown() to run its teardown again.
901 1900 m_shutdownDone = false;
902
903
3/4
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 61 times.
✓ Branch 3 taken 1839 times.
1900 if (LoadConfig()) {
904
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 LogInfo(VB_MEDIAOUT, "VideoInputManager: Reloaded with %d source(s)\n", (int)m_sources.size());
905
1/2
✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
61 StartLoadedSources(lock);
906 }
907 1900 }
908
909 1867 void VideoInputManager::Shutdown() {
910 // Idempotent: main() calls this at shutdown and ~VideoInputManager() calls
911 // it again at static-destruction time. Run the teardown only once.
912
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1867 times.
1867 if (m_shutdownDone.exchange(true)) {
913 ✗ return;
914 }
915 // No reloads from here on, and wait out one in progress: it is about to
916 // start sources this is tearing down.
917 1867 std::thread reloadWorker;
918 {
919
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 std::lock_guard<std::mutex> rl(m_reloadMutex);
920 1867 m_stopReloads = true;
921 1867 m_reloadPending = false;
922 1867 m_restarts.clear();
923 1867 reloadWorker = std::move(m_reloadThread);
924 1867 }
925 1867 m_reloadCv.notify_all();
926 // A YouTube lookup can run for its full timeout, far longer than
927 // fppd_stop waits before it kills fppd.
928 1867 KillRunningYtDlp();
929
2/2
✓ Branch 0 taken 1861 times.
✓ Branch 1 taken 6 times.
1867 if (reloadWorker.joinable()) {
930
1/2
✓ Branch 0 taken 1861 times.
✗ Branch 1 not taken.
1861 reloadWorker.join();
931 }
932 {
933
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 std::lock_guard<std::mutex> lock(m_mutex);
934 1867 m_initialized = false;
935 1867 }
936
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 StopAllSources();
937 // Make sure every teardown thread spawned above (and any left over from
938 // a prior Reload) has actually finished before we consider Shutdown()
939 // complete, so nothing outlives the process.
940
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 JoinTeardownThreads();
941
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 ClearSourceWarnings();
942
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 LogInfo(VB_MEDIAOUT, "VideoInputManager: Shutdown complete\n");
943 1867 }
944
945 ✗ bool VideoInputManager::HasSources() const {
946 ✗ std::lock_guard<std::mutex> lock(m_mutex);
947 ✗ return !m_sources.empty();
948 ✗ }
949
950 ✗ bool VideoInputManager::HasActiveSources() const {
951 ✗ std::lock_guard<std::mutex> lock(m_mutex);
952 ✗ for (const auto& s : m_sources) {
953 ✗ if (s.running)
954 ✗ return true;
955 }
956 ✗ return false;
957 ✗ }
958
959 ✗ int VideoInputManager::ActiveSourceCount() const {
960 ✗ std::lock_guard<std::mutex> lock(m_mutex);
961 ✗ int count = 0;
962 ✗ for (const auto& s : m_sources) {
963 ✗ if (s.running)
964 ✗ count++;
965 }
966 ✗ return count;
967 ✗ }
968
969 ✗ std::string VideoInputManager::GetSourceNodeName(int sourceId) const {
970 ✗ std::lock_guard<std::mutex> lock(m_mutex);
971 ✗ for (const auto& s : m_sources) {
972 ✗ if (s.id == sourceId && s.running)
973 ✗ return s.pipeWireNodeName;
974 }
975 ✗ return "";
976 ✗ }
977
978 ✗ std::vector<std::pair<int, std::string>> VideoInputManager::GetSourceList() const {
979 ✗ std::lock_guard<std::mutex> lock(m_mutex);
980 ✗ std::vector<std::pair<int, std::string>> result;
981 ✗ for (const auto& s : m_sources) {
982 ✗ result.emplace_back(s.id, s.pipeWireNodeName);
983 }
984 ✗ return result;
985 ✗ }
986
987 696 int VideoInputManager::GetSourceFramerate(const std::string& channelName) const {
988
1/2
✓ Branch 0 taken 696 times.
✗ Branch 1 not taken.
696 std::lock_guard<std::mutex> lock(m_mutex);
989
2/2
✓ Branch 0 taken 59 times.
✓ Branch 1 taken 637 times.
696 for (const auto& s : m_sources) {
990
1/2
✓ Branch 0 taken 59 times.
✗ Branch 1 not taken.
59 if (s.pipeWireNodeName == channelName)
991 59 return s.framerate;
992 }
993 637 return 0;
994 696 }
995
996 10 bool VideoInputManager::IsSourceRunning(const std::string& channelName) const {
997
1/2
✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
10 std::lock_guard<std::mutex> lock(m_mutex);
998
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 2 times.
10 for (const auto& s : m_sources) {
999
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 if (s.pipeWireNodeName == channelName)
1000 8 return s.running.load();
1001 }
1002 2 return false;
1003 10 }
1004
1005 1900 bool VideoInputManager::LoadConfig() {
1006
2/4
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1900 times.
✗ Branch 3 not taken.
1900 std::string configPath = FPP_DIR_MEDIA("/config/pipewire-video-input-sources-gen.json");
1007
1008
3/4
✓ Branch 0 taken 1900 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1838 times.
✓ Branch 3 taken 62 times.
1900 if (!FileExists(configPath)) {
1009
1/2
✓ Branch 0 taken 1838 times.
✗ Branch 1 not taken.
1838 LogDebug(VB_MEDIAOUT, "VideoInputManager: No config at %s\n", configPath.c_str());
1010 1838 return false;
1011 }
1012
1013
1/2
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
62 std::ifstream ifs(configPath);
1014
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 62 times.
62 if (!ifs.is_open()) {
1015 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Cannot open %s\n", configPath.c_str());
1016 ✗ return false;
1017 }
1018
1019
1/2
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
62 Json::Value root;
1020
1/2
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
62 Json::CharReaderBuilder builder;
1021 62 std::string errors;
1022
2/4
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 62 times.
62 if (!Json::parseFromStream(builder, ifs, &root, &errors)) {
1023 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: JSON parse error: %s\n", errors.c_str());
1024 ✗ return false;
1025 }
1026
1027
2/4
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 62 times.
62 if (!root.isArray()) {
1028 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Config is not an array\n");
1029 ✗ return false;
1030 }
1031
1032
7/12
✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 62 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 86 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 148 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 86 times.
✓ Branch 11 taken 62 times.
148 for (const auto& entry : root) {
1033
1/2
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
86 SourceInfo si;
1034 // asInt()/asBool()/asString() throw on a value of the wrong type, and
1035 // this runs on the reload worker thread, where an escaping exception
1036 // terminates fppd (at every start, since Init() asks for a reload);
1037 // skip that source.
1038 try {
1039
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 85 times.
86 if (!entry.isObject()) {
1040 1 continue;
1041 }
1042
4/6
✓ Branch 0 taken 85 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 85 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✓ Branch 5 taken 1 times.
87 si.id = entry.get("id", 0).asInt();
1043
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.name = entry.get("name", "").asString();
1044
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.type = entry.get("type", "").asString();
1045
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.pipeWireNodeName = entry.get("pipeWireNodeName", "").asString();
1046
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.enabled = entry.get("enabled", true).asBool();
1047
1048
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.width = entry.get("width", 320).asInt();
1049
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.height = entry.get("height", 240).asInt();
1050
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.framerate = entry.get("framerate", 10).asInt();
1051
1052
3/4
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✓ Branch 3 taken 34 times.
84 if (si.type == "videotestsrc") {
1053
3/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
50 si.pattern = entry.get("pattern", "smpte").asString();
1054
3/4
✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 33 times.
34 } else if (si.type == "v4l2src") {
1055
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 si.device = entry.get("device", "/dev/video0").asString();
1056
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 si.powerLineFrequency = entry.get("powerLineFrequency", -1).asInt();
1057
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 si.exposureMode = entry.get("exposureMode", "camera").asString();
1058
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 si.exposureTime100us = entry.get("exposureTime100us", -1).asInt();
1059
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 si.dynamicFramerate = entry.get("dynamicFramerate", -1).asInt();
1060
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
1 if (si.powerLineFrequency < 0 || si.powerLineFrequency > 2)
1061 1 si.powerLineFrequency = -1;
1062
5/10
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1 times.
✗ Branch 9 not taken.
1 if (si.exposureMode != "auto" && si.exposureMode != "manual")
1063
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 si.exposureMode = "camera";
1064
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
1 if (si.dynamicFramerate < 0 || si.dynamicFramerate > 1)
1065 1 si.dynamicFramerate = -1;
1066 // 100us units: 1 .. 1s. The device's own range is applied on
1067 // top of this in V4L2Device::ApplyControls.
1068
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
1 if (si.exposureTime100us < 1 || si.exposureTime100us > 10000)
1069 1 si.exposureTime100us = -1;
1070
3/4
✓ Branch 0 taken 33 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✓ Branch 3 taken 20 times.
33 } else if (si.type == "rtspsrc") {
1071
3/6
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
13 si.uri = entry.get("uri", "").asString();
1072
3/6
✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 13 times.
✗ Branch 5 not taken.
13 si.latency = entry.get("latency", 200).asInt();
1073
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 13 times.
13 if (si.latency < 0) si.latency = 0;
1074
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 13 times.
13 if (si.latency > 10000) si.latency = 10000;
1075
3/4
✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✓ Branch 3 taken 3 times.
20 } else if (si.type == "urisrc") {
1076
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 si.uri = entry.get("uri", "").asString();
1077
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 si.bufferSec = entry.get("bufferSec", 3.0).asDouble();
1078
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 17 times.
17 if (si.bufferSec < 0) si.bufferSec = 0;
1079
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 17 times.
17 if (si.bufferSec > 30) si.bufferSec = 30;
1080
3/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 2 times.
3 } else if (si.type == "rtpsrc") {
1081
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 si.port = entry.get("port", 5004).asInt();
1082
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 si.encoding = entry.get("encoding", "H264").asString();
1083
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 si.multicastGroup = entry.get("multicastGroup", "").asString();
1084
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (si.port < 1024) si.port = 1024;
1085
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (si.port > 65535) si.port = 65535;
1086 }
1087
1088 // Audio extraction settings
1089
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.audioEnabled = entry.get("audioEnabled", false).asBool();
1090
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 84 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 84 times.
✗ Branch 5 not taken.
84 si.audioPipeWireNodeName = entry.get("audioPipeWireNodeName", "").asString();
1091
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 } catch (const std::exception& ex) {
1092
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "VideoInputManager: skipping a source in %s, a value has the wrong type: %s\n",
1093 configPath.c_str(), ex.what());
1094 1 continue;
1095 1 }
1096
1097
3/6
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 84 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 84 times.
84 if (si.type.empty() || si.pipeWireNodeName.empty()) {
1098 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Skipping source with missing type or node name\n");
1099 ✗ continue;
1100 }
1101
1102 // Clamp to reasonable bounds
1103
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 84 times.
84 if (si.width < 16) si.width = 16;
1104
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 84 times.
84 if (si.width > 3840) si.width = 3840;
1105
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 84 times.
84 if (si.height < 16) si.height = 16;
1106
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 84 times.
84 if (si.height > 2160) si.height = 2160;
1107
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 84 times.
84 if (si.framerate < 1) si.framerate = 1;
1108
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 84 times.
84 if (si.framerate > 60) si.framerate = 60;
1109
1110
1/2
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
84 m_sources.push_back(std::move(si));
1111
2/2
✓ Branch 0 taken 84 times.
✓ Branch 1 taken 2 times.
86 }
1112
1113 62 return !m_sources.empty();
1114 1900 }
1115
1116 ✗ std::string VideoInputManager::BuildDeviceCaps(const SourceInfo& source) {
1117 ✗ std::vector<V4L2Device::Mode> modes = V4L2Device::EnumerateModes(source.device);
1118 ✗ if (modes.empty()) {
1119 ✗ LogInfo(VB_MEDIAOUT, "VideoInputManager: %s enumerated no capture modes, "
1120 "letting the device pick its own\n", source.device.c_str());
1121 ✗ return "";
1122 }
1123
1124 ✗ V4L2Device::Mode chosen;
1125 ✗ if (!V4L2Device::SelectMode(modes, source.width, source.height, source.framerate, chosen))
1126 ✗ return "";
1127
1128 ✗ std::string caps = V4L2Device::ModeToCaps(chosen);
1129 ✗ if (caps.empty()) {
1130 ✗ LogInfo(VB_MEDIAOUT, "VideoInputManager: %s best mode is %s (no caps mapping), "
1131 "letting the device pick its own\n",
1132 source.device.c_str(), chosen.fourcc.c_str());
1133 ✗ return "";
1134 }
1135
1136 ✗ if (chosen.width != source.width || chosen.height != source.height ||
1137 ✗ chosen.fps() < source.framerate - 0.01) {
1138 // Worth saying out loud: this is the difference between what the
1139 // operator typed and what the camera can actually deliver, and the
1140 // scaler silently papering over it is what made the old behaviour
1141 // hard to spot.
1142 ✗ LogInfo(VB_MEDIAOUT, "VideoInputManager: %s has no %dx%d@%d mode; capturing %s %dx%d@%.3g "
1143 "and converting\n",
1144 source.device.c_str(), source.width, source.height, source.framerate,
1145 chosen.fourcc.c_str(), chosen.width, chosen.height, chosen.fps());
1146 } else {
1147 ✗ LogInfo(VB_MEDIAOUT, "VideoInputManager: %s capturing %s %dx%d@%.3g\n",
1148 source.device.c_str(), chosen.fourcc.c_str(), chosen.width, chosen.height, chosen.fps());
1149 }
1150
1151 ✗ return caps;
1152 ✗ }
1153
1154 88 bool VideoInputManager::StartSource(SourceInfo& source) {
1155 #ifdef HAS_GSTREAMER_VIDEO_INPUT
1156
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 88 times.
88 if (source.running) {
1157 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Source '%s' already running\n", source.name.c_str());
1158 ✗ return true;
1159 }
1160
1161 // Sources start from Init()/Reload(), which a config change can trigger
1162 // before any media has played -- and media playback is what used to be the
1163 // only thing that called gst_init(). Without this, gst_parse_launch()
1164 // below segfaults inside GStreamer's own parser on a perfectly well-formed
1165 // pipeline description.
1166
1/2
✓ Branch 0 taken 88 times.
✗ Branch 1 not taken.
88 GStreamerOutput::EnsureGStreamerInit();
1167
1168 88 setenv("PIPEWIRE_RUNTIME_DIR", "/run/pipewire-fpp", 0);
1169
1170
1/2
✓ Branch 0 taken 88 times.
✗ Branch 1 not taken.
88 if (!GstValueUsable(source.uri, "URI", source.name) ||
1171
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 !GstValueUsable(source.device, "capture device path", source.name) ||
1172
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
86 !GstValueUsable(source.pattern, "test pattern", source.name) ||
1173
6/8
✓ Branch 0 taken 86 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 86 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 86 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 86 times.
260 !GstValueUsable(source.multicastGroup, "multicast group", source.name) ||
1174
2/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 86 times.
86 !GstValueUsable(source.pipeWireNodeName, "node name", source.name)) {
1175 2 return false;
1176 }
1177
1178 // Build the source element portion of the pipeline
1179 86 std::string srcElement;
1180 86 bool useDecodebin = false;
1181 86 bool useUridecodebin = false;
1182 86 bool useRtspAudio = false;
1183
3/4
✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✓ Branch 3 taken 36 times.
86 if (source.type == "videotestsrc") {
1184
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 srcElement = "videotestsrc is-live=true";
1185
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
50 if (!source.pattern.empty()) {
1186
3/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
50 srcElement += " pattern=" + GstQuote(source.pattern);
1187 }
1188
3/4
✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 35 times.
36 } else if (source.type == "v4l2src") {
1189
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (source.device.empty()) {
1190
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "VideoInputManager: v4l2src '%s' has no device path\n", source.name.c_str());
1191
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 AddSourceWarning("Video input '" + source.name + "' has no capture device path configured");
1192 1 return false;
1193 }
1194 ✗ srcElement = "v4l2src device=" + GstQuote(source.device);
1195
3/4
✓ Branch 0 taken 35 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✓ Branch 3 taken 19 times.
35 } else if (source.type == "rtspsrc") {
1196
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 15 times.
16 if (source.uri.empty()) {
1197
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "VideoInputManager: rtspsrc '%s' has no URI\n", source.name.c_str());
1198
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 AddSourceWarning("Video input '" + source.name + "' (RTSP) has no URI configured");
1199 1 return false;
1200 }
1201 // rtspsrc → decodebin handles codec negotiation (H.264, H.265, MJPEG, etc.)
1202 // name=rtspvsrc lets us fetch the element below to filter which SDP
1203 // media are set up at all (see the select-stream connection further
1204 // down), and to route the resulting pads when audio is wanted too.
1205
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
30 srcElement = "rtspsrc name=rtspvsrc location=" + GstQuote(source.uri)
1206
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
60 + " latency=" + std::to_string(source.latency)
1207
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 + " protocols=tcp";
1208 // With audio enabled the camera's audio track is kept and published as
1209 // its own PipeWire source, which needs a second branch and so a
1210 // different pipeline shape -- built below rather than by useDecodebin.
1211 15 useRtspAudio = source.audioEnabled;
1212 15 useDecodebin = !useRtspAudio;
1213
3/4
✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✓ Branch 3 taken 3 times.
19 } else if (source.type == "urisrc") {
1214
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 15 times.
16 if (source.uri.empty()) {
1215
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "VideoInputManager: urisrc '%s' has no URI\n", source.name.c_str());
1216
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 AddSourceWarning("Video input '" + source.name + "' has no URI configured");
1217 7 return false;
1218 }
1219
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::string resolvedUri = source.uri;
1220
1221 // Detect YouTube URLs and resolve to a direct stream URL via yt-dlp.
1222 // YouTube page URLs can't be played by GStreamer directly, and the
1223 // resolved URLs expire after ~1 hour, so we resolve at start time.
1224 // When audioEnabled is true for YouTube sources, skip resolution here —
1225 // StartSourceWithAudio resolves separate video + audio URLs.
1226
6/6
✓ Branch 0 taken 13 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 8 times.
✓ Branch 4 taken 8 times.
✓ Branch 5 taken 7 times.
33 if (!source.audioEnabled &&
1227
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
18 (resolvedUri.find("youtube.com/") != std::string::npos ||
1228 5 resolvedUri.find("youtu.be/") != std::string::npos)) {
1229
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 LogInfo(VB_MEDIAOUT, "VideoInputManager: Resolving YouTube URL for '%s' via yt-dlp\n",
1230 source.name.c_str());
1231
2/4
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
8 YtDlpResult yt = ResolveYtDlp(YtVideoFormat(source.height), resolvedUri, true);
1232
2/2
✓ Branch 0 taken 6 times.
✓ Branch 1 taken 2 times.
8 if (yt.url.empty()) {
1233 // Do not fall back to the original page URL. uridecodebin will
1234 // happily fetch it, get text/html back, and fail with "Your
1235 // GStreamer installation is missing a plug-in ... Missing
1236 // decoder: text/html" - which reads like a broken GStreamer
1237 // install and sends you looking in entirely the wrong place.
1238 // The source cannot start until yt-dlp resolves it, so fail
1239 // here and say why.
1240
3/4
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
6 LogErr(VB_MEDIAOUT, "VideoInputManager: yt-dlp could not resolve the URL for '%s': %s\n",
1241 source.name.c_str(),
1242 yt.diagnostics.empty() ? "no output" : yt.diagnostics.c_str());
1243
2/4
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
18 AddSourceWarning("Video input '" + source.name
1244
1/2
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
12 + "': yt-dlp could not resolve the YouTube URL - it may need updating");
1245 6 return false;
1246 }
1247
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogInfo(VB_MEDIAOUT, "VideoInputManager: yt-dlp resolved '%s' -> %zu-char stream URL\n",
1248 source.name.c_str(), yt.url.size());
1249
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 resolvedUri = yt.url;
1250
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 ApplyDetectedFps(yt.fps, source.framerate, source.name.c_str());
1251
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 6 times.
8 }
1252
1253 // uridecodebin handles full URI negotiation: HTTP, HLS (.m3u8),
1254 // MPEG-DASH, direct files, etc. It selects the right source
1255 // element (souphttpsrc, hlsdemux, …) and decoder automatically.
1256 // caps="video/x-raw" restricts output to decoded video only —
1257 // without it, streams containing audio+video (e.g. HLS) will
1258 // expose an audio pad with no consumer, blocking preroll.
1259
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
18 srcElement = "uridecodebin uri=" + GstQuote(resolvedUri)
1260
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
9 + " caps=\"video/x-raw\"";
1261 // uridecodebin already includes decoding — do NOT add another
1262 // decodebin after it. Just need videoconvert + videoscale + caps.
1263 9 useDecodebin = false;
1264 9 useUridecodebin = true;
1265
5/6
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 6 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 2 times.
18 } else if (source.type == "rtpsrc") {
1266 // UDP RTP receiver with appropriate depayloader
1267 1 std::string caps;
1268 1 std::string depay;
1269
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
1 if (source.encoding == "H264") {
1270 ✗ caps = "application/x-rtp,media=video,encoding-name=H264,clock-rate=90000";
1271 ✗ depay = "rtph264depay";
1272
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
1 } else if (source.encoding == "H265") {
1273 ✗ caps = "application/x-rtp,media=video,encoding-name=H265,clock-rate=90000";
1274 ✗ depay = "rtph265depay";
1275
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 } else if (source.encoding == "JPEG") {
1276
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 caps = "application/x-rtp,media=video,encoding-name=JPEG,clock-rate=90000";
1277
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 depay = "rtpjpegdepay";
1278 ✗ } else if (source.encoding == "MP2T") {
1279 ✗ caps = "application/x-rtp,media=video,encoding-name=MP2T,clock-rate=90000";
1280 ✗ depay = "rtpmp2tdepay";
1281 } else {
1282 // RAW or unknown — use decodebin to auto-detect
1283 ✗ caps = "application/x-rtp";
1284 ✗ depay = "";
1285 }
1286
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 srcElement = "udpsrc port=" + std::to_string(source.port);
1287
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (!source.multicastGroup.empty()) {
1288 ✗ srcElement += " multicast-group=" + GstQuote(source.multicastGroup)
1289 ✗ + " auto-multicast=true";
1290 }
1291
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 srcElement += " caps=\"" + caps + "\"";
1292
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (!depay.empty()) {
1293
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 srcElement += " ! " + depay;
1294 }
1295 1 useDecodebin = true;
1296 1 } else {
1297
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogWarn(VB_MEDIAOUT, "VideoInputManager: Unknown source type '%s'\n", source.type.c_str());
1298
5/10
✓ 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.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
2 AddSourceWarning("Video input '" + source.name + "' has unknown source type '" + source.type + "'");
1299 2 return false;
1300 }
1301
1302 // Build full pipeline:
1303 // <source> ! capsfilter ! videoconvert ! queue ! pipewiresink(mode=provide)
1304 // For rtspsrc: rtspsrc ! decodebin ! videoconvert ! videoscale ! caps ! queue ! pipewiresink
1305
1306 // When audio is enabled for YouTube urisrc sources, delegate to
1307 // StartSourceWithAudio which resolves separate video + audio URLs.
1308 // YouTube muxed HLS formats return 403 for segment downloads, so
1309 // we use two separate uridecodebin elements.
1310
4/4
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 66 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 7 times.
75 if (source.audioEnabled && useUridecodebin) {
1311
1/4
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
2 bool isYouTube = source.uri.find("youtube.com/") != std::string::npos ||
1312 ✗ source.uri.find("youtu.be/") != std::string::npos;
1313
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 if (isYouTube) {
1314
3/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
2 if (StartSourceWithAudio(source)) {
1315 1 return true;
1316 }
1317 // StartSourceWithAudio() couldn't resolve the video and/or audio
1318 // URL (e.g. yt-dlp found no separate audio stream) and returned
1319 // false without starting anything. Don't leave the source
1320 // unstarted — retry as video-only. Clearing audioEnabled makes
1321 // this re-entrant call take the plain yt-dlp video-URL
1322 // resolution path above (it's skipped above while audioEnabled
1323 // is set, since that path is StartSourceWithAudio's job).
1324
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "VideoInputManager: '%s' failed to start with audio, "
1325 "retrying as video-only\n", source.name.c_str());
1326 1 source.audioEnabled = false;
1327
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 return StartSource(source);
1328 }
1329 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: audioEnabled not supported for non-YouTube urisrc '%s', ignoring audio\n",
1330 source.name.c_str());
1331 }
1332
1333 // pixel-aspect-ratio=1/1 is load-bearing. Forcing width and height
1334 // alone lets videoscale hit the requested size by changing the PAR
1335 // instead of actually rescaling: a 4:3 camera configured as 1280x720
1336 // produced caps of 1280x720 with pixel-aspect-ratio=3/4, i.e. a frame
1337 // that still displays 4:3. Pinning square pixels makes videoscale do
1338 // the real work, and its add-borders default letterboxes rather than
1339 // distorting when the source and target aspects differ.
1340
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
146 std::string capsStr = "video/x-raw,width=" + std::to_string(source.width)
1341
2/4
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 73 times.
✗ Branch 3 not taken.
284 + ",height=" + std::to_string(source.height)
1342
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
146 + ",pixel-aspect-ratio=1/1"
1343
3/6
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 73 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 73 times.
✗ Branch 5 not taken.
211 + ",framerate=" + std::to_string(source.framerate) + "/1";
1344
1345 73 std::string pipelineDesc;
1346
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 66 times.
73 if (useRtspAudio) {
1347 // Two branches in ONE GstPipeline so both share a clock and base_time.
1348 // Separate pipelines pace independently and drift apart, which is why
1349 // StartSourceWithAudio() combines its branches the same way.
1350 //
1351 // rtspsrc's pads are deliberately left unlinked in this description:
1352 // gst_parse_launch can only ever satisfy one of them, so
1353 // RtspLinkPadToBranch() connects each pad to vdec or adec by media
1354 // type once rtspsrc creates it. Each decodebin therefore handles a
1355 // single stream and its own dynamic src pad links normally.
1356 pipelineDesc = srcElement
1357
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " decodebin name=vdec"
1358
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! videoconvert"
1359
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! videoscale"
1360
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! videorate"
1361
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
14 + " ! " + capsStr
1362
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! queue max-size-time=2000000000 max-size-buffers=0 max-size-bytes=0"
1363
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! clocksync"
1364
3/6
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
28 + " ! intervideosink sync=false channel=" + GstQuote(source.pipeWireNodeName)
1365
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " decodebin name=adec"
1366
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! audioconvert"
1367
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! audioresample"
1368
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! audio/x-raw,format=F32LE,rate=48000,channels=2,layout=interleaved"
1369
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! queue max-size-time=5000000000 max-size-buffers=0 max-size-bytes=0"
1370
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! clocksync"
1371
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! pipewiresink name=apwsink";
1372
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 59 times.
66 } else if (useUridecodebin) {
1373 // uridecodebin already decodes — just convert/scale/queue to intervideosink.
1374 // videorate converts the stream's native framerate to the configured one.
1375 // clocksync paces bursty HLS delivery at real-time.
1376 pipelineDesc = srcElement
1377
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! videoconvert"
1378
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! videoscale"
1379
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! videorate"
1380
2/4
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
14 + " ! " + capsStr
1381
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! queue max-size-time=2000000000 max-size-buffers=0 max-size-bytes=0"
1382
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
14 + " ! clocksync"
1383
3/6
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
21 + " ! intervideosink sync=false channel=" + GstQuote(source.pipeWireNodeName);
1384
2/2
✓ Branch 0 taken 9 times.
✓ Branch 1 taken 50 times.
59 } else if (useDecodebin) {
1385 // RTSP: decodebin handles codec negotiation, videoscale + caps enforce resolution.
1386 // videorate needed when stream framerate differs from configured value.
1387 // clocksync paces delivery at real-time.
1388 pipelineDesc = srcElement
1389
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
18 + " ! decodebin"
1390
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
18 + " ! videoconvert"
1391
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
18 + " ! videoscale"
1392
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
18 + " ! videorate"
1393
2/4
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
18 + " ! " + capsStr
1394
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
18 + " ! queue max-size-time=2000000000 max-size-buffers=0 max-size-bytes=0"
1395
1/2
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
18 + " ! clocksync"
1396
3/6
✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
27 + " ! intervideosink sync=false channel=" + GstQuote(source.pipeWireNodeName);
1397
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 50 times.
50 } else if (source.type == "v4l2src") {
1398 // Cameras only negotiate their own native modes. Asking v4l2src
1399 // directly for video/x-raw at an arbitrary width/height/framerate
1400 // fails to negotiate and the pipeline never leaves READY, which
1401 // surfaced only as "failed to start" in the log. decodebin covers
1402 // MJPEG-only and H.264 webcams (very common above VGA), and passes
1403 // already-raw formats straight through; videoscale/videorate then
1404 // convert whatever the camera gave us to the configured size/rate.
1405 //
1406 // Leaving the device *entirely* unconstrained, though, meant the
1407 // configured resolution and framerate never reached it at all:
1408 // v4l2src picked the device's own preferred mode (a 1920x1080@30
1409 // source was observed capturing 640x480@60) and videoscale/videorate
1410 // resampled that to the configured numbers, so the settings looked
1411 // applied while the capture ignored them. BuildDeviceCaps asks the
1412 // device what it actually supports and pins the closest real mode,
1413 // returning "" -- and so restoring exactly the unconstrained
1414 // behaviour above -- whenever it can't be sure.
1415 ✗ std::string deviceCaps = BuildDeviceCaps(source);
1416
1417 pipelineDesc = srcElement
1418 ✗ + (deviceCaps.empty() ? "" : " ! " + deviceCaps)
1419 ✗ + " ! decodebin"
1420 ✗ + " ! videoconvert"
1421 ✗ + " ! videoscale"
1422 ✗ + " ! videorate"
1423 ✗ + " ! " + capsStr
1424 ✗ + " ! queue max-size-buffers=2 leaky=downstream"
1425 ✗ + " ! intervideosink sync=false channel=" + GstQuote(source.pipeWireNodeName);
1426 ✗ } else {
1427 pipelineDesc = srcElement
1428
2/4
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
100 + " ! " + capsStr
1429
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
100 + " ! videoconvert"
1430
1/2
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
100 + " ! queue max-size-buffers=2"
1431
3/6
✓ Branch 0 taken 50 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 50 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 50 times.
✗ Branch 5 not taken.
146 + " ! intervideosink sync=false channel=" + GstQuote(source.pipeWireNodeName);
1432 }
1433
1434
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 LogInfo(VB_MEDIAOUT, "VideoInputManager: Starting source '%s' (%s): %s\n",
1435 source.name.c_str(), source.type.c_str(), pipelineDesc.c_str());
1436
1437 73 GError* error = nullptr;
1438
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 source.pipeline = gst_parse_launch(pipelineDesc.c_str(), &error);
1439
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 73 times.
73 if (!source.pipeline) {
1440 ✗ LogErr(VB_MEDIAOUT, "VideoInputManager: Failed to create pipeline for '%s': %s\n",
1441 source.name.c_str(), error ? error->message : "unknown error");
1442 ✗ if (error) g_error_free(error);
1443 ✗ return false;
1444 }
1445
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 GStreamerOutput::GuardVideoConvertInputs(source.pipeline);
1446
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 73 times.
73 if (error) {
1447 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Pipeline warning for '%s': %s\n",
1448 source.name.c_str(), error->message);
1449 ✗ g_error_free(error);
1450 }
1451
1452
3/4
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✓ Branch 3 taken 58 times.
73 if (source.type == "rtspsrc") {
1453
3/6
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 15 times.
✗ Branch 5 not taken.
15 GstElement* rtspEl = gst_bin_get_by_name(GST_BIN(source.pipeline), "rtspvsrc");
1454
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 if (rtspEl) {
1455 // Owned by the element: freed when rtspsrc is finalised with the
1456 // pipeline, so it outlives every handler below and the run
1457 // thread's error check (RtspFailedForNoVideo). The three handlers
1458 // run on rtspsrc's own thread, one media at a time, so the shared
1459 // state needs no locking.
1460
3/8
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 15 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
15 auto* filter = new RtspStreamFilter{ source.name, useRtspAudio, false, false };
1461
2/4
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
15 g_object_set_data_full(G_OBJECT(rtspEl), RTSP_FILTER_KEY, filter,
1462
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 [](gpointer d) { delete static_cast<RtspStreamFilter*>(d); });
1463
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 g_signal_connect(rtspEl, "select-stream", G_CALLBACK(RtspSelectStream), filter);
1464
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 8 times.
15 if (useRtspAudio) {
1465
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 g_signal_connect(rtspEl, "pad-added", G_CALLBACK(RtspLinkPadToBranch), filter);
1466 }
1467
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 g_signal_connect(rtspEl, "no-more-pads", G_CALLBACK(RtspNoMorePads), filter);
1468
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 gst_object_unref(rtspEl);
1469 }
1470 }
1471
1472
2/2
✓ Branch 0 taken 7 times.
✓ Branch 1 taken 66 times.
73 if (useRtspAudio) {
1473 // Publish the camera's audio as its own PipeWire Audio/Source node.
1474 // branchMayStayEmpty: unlike the YouTube path, nothing has confirmed
1475 // the camera actually offers audio, and it may advertise none.
1476
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 std::string audioNodeName = source.audioPipeWireNodeName;
1477
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 7 times.
7 if (audioNodeName.empty())
1478 ✗ audioNodeName = source.pipeWireNodeName + "_audio";
1479
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 ConfigureAudioSourceSink(source.pipeline, audioNodeName, source.name, true);
1480
1481 // Both branches pace against one clock rather than letting
1482 // pipewiresink provide its own, matching StartSourceWithAudio().
1483
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 GstClock* sysClock = gst_system_clock_obtain();
1484
3/6
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
7 gst_pipeline_use_clock(GST_PIPELINE(source.pipeline), sysClock);
1485
1/2
✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
7 gst_object_unref(sysClock);
1486 7 }
1487
1488 // Video sink is intervideosink — no PipeWire configuration needed.
1489 // The channel name (set in the pipeline description) matches the
1490 // consumer's intervideosrc channel for in-process video routing.
1491 // PipeWire's video driver scheduling (rate=0) cannot drive video
1492 // graph cycles, so we bypass PipeWire for video entirely.
1493
1494 // Start in a background thread — set_state(PLAYING) can block
1495 73 source.shutdownRequested = false;
1496 73 source.running = true;
1497
1498
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 std::string sourceName = source.name;
1499
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 std::string nodeNameCopy = source.pipeWireNodeName;
1500 73 const int sourceId = source.id;
1501 73 GstElement* pipeline = source.pipeline;
1502 73 std::atomic<bool>* shutdownFlag = &source.shutdownRequested;
1503 73 std::atomic<bool>* runningFlag = &source.running;
1504
1505 // Copied by value into the thread rather than reached through `source`:
1506 // the run thread outlives this call and m_sources can be reallocated by
1507 // a Reload while it is still going.
1508
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 std::string deviceCopy = source.device;
1509
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 V4L2Device::ControlSettings controls;
1510
2/4
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 73 times.
73 if (source.type == "v4l2src") {
1511 ✗ controls.powerLineFrequency = source.powerLineFrequency;
1512 ✗ controls.exposureMode = source.exposureMode;
1513 ✗ controls.exposureTime100us = source.exposureTime100us;
1514 ✗ controls.dynamicFramerate = source.dynamicFramerate;
1515 }
1516
1517
4/16
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 73 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 73 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 73 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
146 source.runThread = std::thread([pipeline, sourceName, nodeNameCopy, shutdownFlag, runningFlag,
1518 deviceCopy, controls, sourceId]() {
1519
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' thread starting pipeline (node=%s)\n",
1520 sourceName.c_str(), nodeNameCopy.c_str());
1521
1522 // Device controls (anti-flicker / exposure) are applied twice on
1523 // purpose: once here so v4l2src inherits them when it opens the
1524 // node, and again once the pipeline is actually PLAYING so a driver
1525 // that resets on open doesn't undo them. The writes are idempotent
1526 // and cost one ioctl each.
1527 348 auto applyControls = [deviceCopy, controls, sourceName]() {
1528
2/4
✓ Branch 0 taken 129 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 129 times.
✗ Branch 3 not taken.
129 if (!controls.AnyRequested())
1529 129 return;
1530 ✗ std::string summary;
1531 ✗ if (V4L2Device::ApplyControls(deviceCopy, controls, summary) && !summary.empty()) {
1532 ✗ LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' device controls: %s\n",
1533 sourceName.c_str(), summary.c_str());
1534 }
1535
3/10
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 73 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 73 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
73 };
1536
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 applyControls();
1537
1538
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING);
1539
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 72 times.
73 if (ret == GST_STATE_CHANGE_FAILURE) {
1540
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (!shutdownFlag->load()) {
1541
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogWarn(VB_MEDIAOUT, "VideoInputManager: Source '%s' failed to start\n", sourceName.c_str());
1542
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 AddSourceWarning("Video input '" + sourceName + "' failed to start");
1543 }
1544 1 *runningFlag = false;
1545
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (!shutdownFlag->load()) {
1546
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 VideoInputManager::Instance().ScheduleSourceRestart(sourceId);
1547 }
1548 1 return;
1549 }
1550
1551
3/6
✓ Branch 0 taken 72 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 72 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 72 times.
✗ Branch 5 not taken.
72 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' pipeline state=%s (waiting for data)\n",
1552 sourceName.c_str(),
1553 ret == GST_STATE_CHANGE_SUCCESS ? "PLAYING" :
1554 ret == GST_STATE_CHANGE_ASYNC ? "ASYNC" : "OTHER");
1555
1556 // If pipeline went straight to PLAYING (no async), notify consumers now.
1557
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 72 times.
72 if (ret == GST_STATE_CHANGE_SUCCESS) {
1558 ✗ applyControls();
1559 ✗ VideoOutputManager::Instance().NotifyProducerReady(nodeNameCopy);
1560 // Up again after a restart: retract the banner its failure raised.
1561 ✗ WarningHolder::RemoveWarning(56, "Video input '" + sourceName + "' failed to start");
1562 }
1563
1564 // Monitor the pipeline bus for errors / EOS
1565
3/6
✓ Branch 0 taken 72 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 72 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 72 times.
✗ Branch 5 not taken.
72 GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline));
1566
2/2
✓ Branch 0 taken 3070 times.
✓ Branch 1 taken 63 times.
3133 while (!shutdownFlag->load()) {
1567
1/2
✓ Branch 0 taken 3070 times.
✗ Branch 1 not taken.
3070 GstMessage* msg = gst_bus_timed_pop(bus, 500 * GST_MSECOND);
1568
2/2
✓ Branch 0 taken 252 times.
✓ Branch 1 taken 2818 times.
3070 if (!msg) continue;
1569
1570
5/5
✓ Branch 0 taken 5 times.
✓ Branch 1 taken 35 times.
✓ Branch 2 taken 2022 times.
✓ Branch 3 taken 4 times.
✓ Branch 4 taken 752 times.
2818 switch (GST_MESSAGE_TYPE(msg)) {
1571 5 case GST_MESSAGE_ERROR: {
1572 5 GError* err = nullptr;
1573 5 gchar* debug = nullptr;
1574
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_message_parse_error(msg, &err, &debug);
1575
6/8
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 4 times.
✓ Branch 7 taken 1 times.
5 if (!shutdownFlag->load() && !RtspFailedForNoVideo(pipeline)) {
1576
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 LogWarn(VB_MEDIAOUT, "VideoInputManager: Source '%s' error: %s (%s)\n",
1577 sourceName.c_str(), err ? err->message : "?", debug ? debug : "");
1578 }
1579
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
5 if (err) g_error_free(err);
1580
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 g_free(debug);
1581
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_message_unref(msg);
1582
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_object_unref(bus);
1583 // Drop the dead pipeline out of PLAYING so it releases
1584 // its device fd / socket / decoder buffers now instead
1585 // of holding them until the next Reload/Shutdown (this
1586 // pipeline is per-daemon-lifetime, so that could be
1587 // days). StopSource() still owns unref'ing source.pipeline.
1588
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 gst_element_set_state(pipeline, GST_STATE_NULL);
1589 5 *runningFlag = false;
1590
1/2
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
5 if (!shutdownFlag->load()) {
1591
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
5 VideoInputManager::Instance().ScheduleSourceRestart(sourceId);
1592 }
1593 5 return;
1594 }
1595 35 case GST_MESSAGE_BUFFERING: {
1596 // HLS / adaptive streams send buffering messages from
1597 // internal queue2 elements. For live streams, pausing
1598 // on low buffer levels starves downstream (pipewiresink)
1599 // and prevents PipeWire consumers from connecting.
1600 // Just log without pausing — the internal queues handle
1601 // buffering on their own.
1602 35 gint percent = 0;
1603
1/2
✓ Branch 0 taken 35 times.
✗ Branch 1 not taken.
35 gst_message_parse_buffering(msg, &percent);
1604
4/4
✓ Branch 0 taken 33 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 31 times.
35 if (percent == 0 || percent == 100) {
1605
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' buffering %d%%\n",
1606 sourceName.c_str(), percent);
1607 }
1608 35 break;
1609 }
1610 2022 case GST_MESSAGE_STATE_CHANGED: {
1611
4/6
✓ Branch 0 taken 2022 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2022 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 196 times.
✓ Branch 5 taken 1826 times.
2022 if (GST_MESSAGE_SRC(msg) == GST_OBJECT(pipeline)) {
1612 GstState oldState, newState, pending;
1613
1/2
✓ Branch 0 taken 196 times.
✗ Branch 1 not taken.
196 gst_message_parse_state_changed(msg, &oldState, &newState, &pending);
1614
4/8
✓ Branch 0 taken 196 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 196 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 196 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 196 times.
✗ Branch 7 not taken.
196 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' state %s -> %s (pending=%s)\n",
1615 sourceName.c_str(),
1616 gst_element_state_get_name(oldState),
1617 gst_element_state_get_name(newState),
1618 gst_element_state_get_name(pending));
1619
3/4
✓ Branch 0 taken 56 times.
✓ Branch 1 taken 140 times.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
196 if (newState == GST_STATE_PLAYING && pending == GST_STATE_VOID_PENDING) {
1620
1/2
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
56 applyControls();
1621
2/4
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
56 VideoOutputManager::Instance().NotifyProducerReady(nodeNameCopy);
1622 // Up again after a restart: retract the banner its failure raised.
1623
4/8
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 56 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 56 times.
✗ Branch 7 not taken.
116 WarningHolder::RemoveWarning(56, "Video input '" + sourceName + "' failed to start");
1624 }
1625 }
1626 2022 break;
1627 }
1628 4 case GST_MESSAGE_EOS:
1629
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (!shutdownFlag->load()) {
1630
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' received EOS\n", sourceName.c_str());
1631 }
1632
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_message_unref(msg);
1633
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_object_unref(bus);
1634 // Same reasoning as the ERROR case above — EOS also
1635 // means the pipeline is done producing and must not be
1636 // left in PLAYING.
1637
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 gst_element_set_state(pipeline, GST_STATE_NULL);
1638 4 *runningFlag = false;
1639
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 if (!shutdownFlag->load()) {
1640
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
4 VideoInputManager::Instance().ScheduleSourceRestart(sourceId);
1641 }
1642 4 return;
1643 752 default:
1644 752 break;
1645 }
1646
1/2
✓ Branch 0 taken 2809 times.
✗ Branch 1 not taken.
2809 gst_message_unref(msg);
1647 }
1648
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 gst_object_unref(bus);
1649 63 *runningFlag = false;
1650
1/2
✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
63 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' thread exiting\n", sourceName.c_str());
1651
3/4
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✓ Branch 3 taken 10 times.
146 });
1652
1653
1/2
✓ Branch 0 taken 73 times.
✗ Branch 1 not taken.
73 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' launched (node=%s)\n",
1654 source.name.c_str(), source.pipeWireNodeName.c_str());
1655 73 return true;
1656 #else
1657 LogWarn(VB_MEDIAOUT, "VideoInputManager: GStreamer not available\n");
1658 // Permanent: a build without GStreamer stays one until it is replaced.
1659 WarningHolder::AddWarning(56, "Video input requires GStreamer, which is not available on this device");
1660 return false;
1661 #endif
1662 86 }
1663
1664 // ─── Start source with separate video + audio streams (YouTube HLS) ───────
1665 2 bool VideoInputManager::StartSourceWithAudio(SourceInfo& source) {
1666 #ifdef HAS_GSTREAMER_VIDEO_INPUT
1667
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 GStreamerOutput::EnsureGStreamerInit();
1668
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if (source.running) {
1669 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Source '%s' already running\n", source.name.c_str());
1670 ✗ return true;
1671 }
1672
1673 2 setenv("PIPEWIRE_RUNTIME_DIR", "/run/pipewire-fpp", 0);
1674
1675
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogInfo(VB_MEDIAOUT, "VideoInputManager: Starting source '%s' with audio extraction\n",
1676 source.name.c_str());
1677
1678 // YouTube muxed HLS formats (91/92) return HTTP 403 for segment
1679 // downloads, so we resolve separate video-only and audio-only URLs
1680 // and use two independent uridecodebin elements.
1681
1682 // Resolve video-only URL and detect native framerate
1683 2 std::string videoUrl;
1684 {
1685
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 LogInfo(VB_MEDIAOUT, "VideoInputManager: Resolving YouTube video URL for '%s'\n",
1686 source.name.c_str());
1687
2/4
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
2 YtDlpResult yt = ResolveYtDlp(YtVideoFormat(source.height), source.uri, true);
1688
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 if (yt.url.empty()) {
1689
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 LogWarn(VB_MEDIAOUT, "VideoInputManager: Failed to resolve video URL for '%s': %s\n",
1690 source.name.c_str(),
1691 yt.diagnostics.empty() ? "no output" : yt.diagnostics.c_str());
1692
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 AddSourceWarning("Video input '" + source.name + "': could not resolve the video URL");
1693 1 return false;
1694 }
1695
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 videoUrl = yt.url;
1696
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "VideoInputManager: Video URL resolved (%zu chars)\n", videoUrl.size());
1697
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ApplyDetectedFps(yt.fps, source.framerate, source.name.c_str());
1698
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 }
1699
1700 // Resolve audio-only URL
1701 1 std::string audioUrl;
1702 {
1703
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "VideoInputManager: Resolving YouTube audio URL for '%s'\n",
1704 source.name.c_str());
1705
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 YtDlpResult yt = ResolveYtDlp(YT_AUDIO_FORMAT, source.uri, false);
1706
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (yt.url.empty()) {
1707 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Failed to resolve audio URL for '%s' (%s), "
1708 "falling back to video-only\n", source.name.c_str(),
1709 yt.diagnostics.empty() ? "no output" : yt.diagnostics.c_str());
1710 // Returning false here is not a no-op: our caller (StartSource())
1711 // retries with audioEnabled cleared and builds the regular
1712 // video-only pipeline instead of leaving the source unstarted.
1713 ✗ return false;
1714 }
1715
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 audioUrl = yt.url;
1716
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "VideoInputManager: Audio URL resolved (%zu chars)\n", audioUrl.size());
1717
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 }
1718
1719 // Build a SINGLE combined pipeline for video and audio.
1720 // Both paths share one pipeline clock and base_time, preventing
1721 // A/V drift that occurs when separate pipelines pace independently.
1722 // (intervideosink + pipewiresink in one pipeline works fine;
1723 // the original deadlock was two pipewiresinks in provide mode.)
1724 // pixel-aspect-ratio=1/1 is load-bearing. Forcing width and height
1725 // alone lets videoscale hit the requested size by changing the PAR
1726 // instead of actually rescaling: a 4:3 camera configured as 1280x720
1727 // produced caps of 1280x720 with pixel-aspect-ratio=3/4, i.e. a frame
1728 // that still displays 4:3. Pinning square pixels makes videoscale do
1729 // the real work, and its add-borders default letterboxes rather than
1730 // distorting when the source and target aspects differ.
1731
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
2 std::string capsStr = "video/x-raw,width=" + std::to_string(source.width)
1732
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
4 + ",height=" + std::to_string(source.height)
1733
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
2 + ",pixel-aspect-ratio=1/1"
1734
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
3 + ",framerate=" + std::to_string(source.framerate) + "/1";
1735
1736 // Convert bufferSec to nanoseconds for GStreamer properties.
1737 // This buffers decoded HLS content so clocksync can pace it smoothly.
1738 1 long long bufferNs = (long long)(source.bufferSec * 1000000000.0);
1739
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (bufferNs < 1000000000LL) bufferNs = 1000000000LL; // minimum 1s
1740
1741 // Two independent branches in one GstPipeline: they share the
1742 // pipeline clock, so both clocksync elements pace identically.
1743 std::string combinedPipelineDesc =
1744
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 "uridecodebin uri=" + GstQuote(videoUrl) + " caps=\"video/x-raw\""
1745
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
4 " buffer-duration=" + std::to_string(bufferNs) +
1746 " ! videoconvert"
1747 " ! videoscale"
1748 " ! videorate"
1749
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
2 " ! " + capsStr +
1750
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
4 " ! queue max-size-time=" + std::to_string(bufferNs) +
1751 " max-size-buffers=0 max-size-bytes=0"
1752 " ! clocksync"
1753
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
4 " ! intervideosink sync=false channel=" + GstQuote(source.pipeWireNodeName) +
1754
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
4 " uridecodebin uri=" + GstQuote(audioUrl) + " caps=\"audio/x-raw\""
1755 " ! audioconvert"
1756 " ! audioresample"
1757 " ! audio/x-raw,format=F32LE,rate=48000,channels=2,layout=interleaved"
1758 " ! queue max-size-time=5000000000 max-size-buffers=0 max-size-bytes=0"
1759 " ! clocksync"
1760
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 " ! pipewiresink name=apwsink";
1761
1762
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "VideoInputManager: '%s' combined A/V pipeline: %s\n",
1763 source.name.c_str(), combinedPipelineDesc.c_str());
1764
1765 // --- Create combined A/V pipeline ---
1766 1 GError* error = nullptr;
1767
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 source.pipeline = gst_parse_launch(combinedPipelineDesc.c_str(), &error);
1768
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (!source.pipeline) {
1769 ✗ LogErr(VB_MEDIAOUT, "VideoInputManager: Failed to create pipeline for '%s': %s\n",
1770 source.name.c_str(), error ? error->message : "unknown error");
1771 ✗ if (error) g_error_free(error);
1772 ✗ return false;
1773 }
1774
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 GStreamerOutput::GuardVideoConvertInputs(source.pipeline);
1775
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (error) {
1776 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Pipeline warning for '%s': %s\n",
1777 source.name.c_str(), error->message);
1778 ✗ g_error_free(error);
1779 }
1780
1781 // No separate audio pipeline — audio is in the combined pipeline.
1782 1 source.audioPipeline = nullptr;
1783
1784 // Configure audio pipewiresink as an Audio/Source in provide mode.
1785
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string audioNodeName = source.audioPipeWireNodeName;
1786
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (audioNodeName.empty())
1787
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 audioNodeName = source.pipeWireNodeName + "_audio";
1788
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 ConfigureAudioSourceSink(source.pipeline, audioNodeName, source.name, false);
1789
1790 // Force the combined pipeline to use the system clock instead of
1791 // letting pipewiresink provide its own. Both clocksync elements
1792 // then pace against the same monotonic clock.
1793
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 GstClock* sysClock = gst_system_clock_obtain();
1794
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 gst_pipeline_use_clock(GST_PIPELINE(source.pipeline), sysClock);
1795
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 gst_object_unref(sysClock);
1796
1797
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "VideoInputManager: '%s' combined pipeline built (video=%s, audio=%s)\n",
1798 source.name.c_str(), source.pipeWireNodeName.c_str(),
1799 source.audioPipeWireNodeName.c_str());
1800
1801 // Start combined pipeline in its own thread
1802 1 source.shutdownRequested = false;
1803 1 source.running = true;
1804
1805
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string sourceName = source.name;
1806
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string nodeNameCopy = source.pipeWireNodeName;
1807 1 const int sourceId = source.id;
1808 1 GstElement* combinedPipeline = source.pipeline;
1809 1 std::atomic<bool>* shutdownFlag = &source.shutdownRequested;
1810 1 std::atomic<bool>* runningFlag = &source.running;
1811
1812
2/8
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
2 source.runThread = std::thread([combinedPipeline, sourceName, nodeNameCopy, shutdownFlag, runningFlag, sourceId]() {
1813 1 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' A/V thread starting (node=%s)\n",
1814 sourceName.c_str(), nodeNameCopy.c_str());
1815
1816 1 GstStateChangeReturn ret = gst_element_set_state(combinedPipeline, GST_STATE_PLAYING);
1817
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (ret == GST_STATE_CHANGE_FAILURE) {
1818 ✗ if (!shutdownFlag->load()) {
1819 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Source '%s' failed to start\n",
1820 sourceName.c_str());
1821 ✗ AddSourceWarning("Video input '" + sourceName + "' failed to start");
1822 }
1823 ✗ *runningFlag = false;
1824 ✗ if (!shutdownFlag->load()) {
1825 ✗ VideoInputManager::Instance().ScheduleSourceRestart(sourceId);
1826 }
1827 ✗ return;
1828 }
1829
1830
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' pipeline state=%s\n",
1831 sourceName.c_str(),
1832 ret == GST_STATE_CHANGE_SUCCESS ? "PLAYING" :
1833 ret == GST_STATE_CHANGE_ASYNC ? "ASYNC" : "OTHER");
1834
1835
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (ret == GST_STATE_CHANGE_SUCCESS) {
1836 ✗ VideoOutputManager::Instance().NotifyProducerReady(nodeNameCopy);
1837 // Up again after a restart: retract the banner its failure raised.
1838 ✗ WarningHolder::RemoveWarning(56, "Video input '" + sourceName + "' failed to start");
1839 }
1840
1841 1 GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(combinedPipeline));
1842
2/2
✓ Branch 0 taken 168 times.
✓ Branch 1 taken 1 times.
169 while (!shutdownFlag->load()) {
1843 168 GstMessage* msg = gst_bus_timed_pop(bus, 500 * GST_MSECOND);
1844
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 166 times.
168 if (!msg) continue;
1845
1846
3/5
✗ Branch 0 not taken.
✓ Branch 1 taken 24 times.
✓ Branch 2 taken 98 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 44 times.
166 switch (GST_MESSAGE_TYPE(msg)) {
1847 ✗ case GST_MESSAGE_ERROR: {
1848 ✗ GError* err = nullptr;
1849 ✗ gchar* debug = nullptr;
1850 ✗ gst_message_parse_error(msg, &err, &debug);
1851 ✗ if (!shutdownFlag->load()) {
1852 ✗ const gchar* srcName = GST_MESSAGE_SRC(msg) ?
1853 ✗ GST_OBJECT_NAME(GST_MESSAGE_SRC(msg)) : "?";
1854 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: Source '%s' error [%s]: %s (%s)\n",
1855 sourceName.c_str(), srcName,
1856 err ? err->message : "?", debug ? debug : "");
1857 }
1858 ✗ if (err) g_error_free(err);
1859 ✗ g_free(debug);
1860 ✗ gst_message_unref(msg);
1861 ✗ gst_object_unref(bus);
1862 // Drop the dead combined A/V pipeline out of PLAYING so
1863 // it releases its device fd / socket / decoder buffers
1864 // now instead of holding them until the next
1865 // Reload/Shutdown. StopSource() still owns unref'ing
1866 // source.pipeline.
1867 ✗ gst_element_set_state(combinedPipeline, GST_STATE_NULL);
1868 ✗ *runningFlag = false;
1869 ✗ if (!shutdownFlag->load()) {
1870 ✗ VideoInputManager::Instance().ScheduleSourceRestart(sourceId);
1871 }
1872 ✗ return;
1873 }
1874 24 case GST_MESSAGE_BUFFERING: {
1875 24 gint percent = 0;
1876
1/2
✓ Branch 0 taken 24 times.
✗ Branch 1 not taken.
24 gst_message_parse_buffering(msg, &percent);
1877
4/4
✓ Branch 0 taken 22 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 20 times.
24 if (percent == 0 || percent == 100) {
1878
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 const gchar* srcName = GST_MESSAGE_SRC(msg) ?
1879 4 GST_OBJECT_NAME(GST_MESSAGE_SRC(msg)) : "?";
1880
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' [%s] buffering %d%%\n",
1881 sourceName.c_str(), srcName, percent);
1882 }
1883 24 break;
1884 }
1885 98 case GST_MESSAGE_STATE_CHANGED: {
1886
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 95 times.
98 if (GST_MESSAGE_SRC(msg) == GST_OBJECT(combinedPipeline)) {
1887 GstState oldState, newState, pending;
1888
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 gst_message_parse_state_changed(msg, &oldState, &newState, &pending);
1889
4/8
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
3 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' state %s -> %s (pending=%s)\n",
1890 sourceName.c_str(),
1891 gst_element_state_get_name(oldState),
1892 gst_element_state_get_name(newState),
1893 gst_element_state_get_name(pending));
1894
3/4
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
3 if (newState == GST_STATE_PLAYING && pending == GST_STATE_VOID_PENDING) {
1895
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 VideoOutputManager::Instance().NotifyProducerReady(nodeNameCopy);
1896 // Up again after a restart: retract the banner its failure raised.
1897
4/8
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
2 WarningHolder::RemoveWarning(56, "Video input '" + sourceName + "' failed to start");
1898 }
1899 }
1900 98 break;
1901 }
1902 ✗ case GST_MESSAGE_EOS:
1903 ✗ if (!shutdownFlag->load()) {
1904 ✗ LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' received EOS\n",
1905 sourceName.c_str());
1906 }
1907 ✗ gst_message_unref(msg);
1908 ✗ gst_object_unref(bus);
1909 // Same reasoning as the ERROR case above.
1910 ✗ gst_element_set_state(combinedPipeline, GST_STATE_NULL);
1911 ✗ *runningFlag = false;
1912 ✗ if (!shutdownFlag->load()) {
1913 ✗ VideoInputManager::Instance().ScheduleSourceRestart(sourceId);
1914 }
1915 ✗ return;
1916 44 default:
1917 44 break;
1918 }
1919 166 gst_message_unref(msg);
1920 }
1921 1 gst_object_unref(bus);
1922 1 *runningFlag = false;
1923 1 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' A/V thread exiting\n", sourceName.c_str());
1924
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 });
1925
1926 // No separate audio thread — audio runs in the combined pipeline.
1927
1928
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' launched with audio (node=%s)\n",
1929 source.name.c_str(), source.pipeWireNodeName.c_str());
1930 1 return true;
1931 #else
1932 LogWarn(VB_MEDIAOUT, "VideoInputManager: GStreamer not available\n");
1933 // Permanent: a build without GStreamer stays one until it is replaced.
1934 WarningHolder::AddWarning(56, "Video input requires GStreamer, which is not available on this device");
1935 return false;
1936 #endif
1937 2 }
1938
1939 88 void VideoInputManager::StopSource(SourceInfo& source) {
1940 #ifdef HAS_GSTREAMER_VIDEO_INPUT
1941 88 source.shutdownRequested = true;
1942 88 source.audioShutdownRequested = true;
1943
1944 // Join the bus monitor threads first — they check shutdown flags
1945 // every 500ms (gst_bus_timed_pop timeout) and exit promptly.
1946
2/2
✓ Branch 0 taken 74 times.
✓ Branch 1 taken 14 times.
88 if (source.runThread.joinable()) {
1947 74 source.runThread.join();
1948 }
1949
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 88 times.
88 if (source.audioRunThread.joinable()) {
1950 ✗ source.audioRunThread.join();
1951 }
1952
1953
2/2
✓ Branch 0 taken 74 times.
✓ Branch 1 taken 14 times.
88 if (source.pipeline) {
1954 74 GstElement* p = source.pipeline;
1955 74 source.pipeline = nullptr;
1956
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 std::string name = source.name;
1957 // Tracked (not detached) so Shutdown()/Reload() can join it before
1958 // the process exits or the same device/URI is reopened — see
1959 // JoinTeardownThreads().
1960
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 std::lock_guard<std::mutex> tlock(m_teardownMutex);
1961
2/4
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 74 times.
✗ Branch 3 not taken.
74 m_teardownThreads.emplace_back([p, name]() {
1962 74 gst_element_set_state(p, GST_STATE_NULL);
1963 74 gst_object_unref(p);
1964 74 LogInfo(VB_MEDIAOUT, "VideoInputManager: Video pipeline for '%s' released\n", name.c_str());
1965 74 });
1966 74 }
1967
1968
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 88 times.
88 if (source.audioPipeline) {
1969 ✗ GstElement* ap = source.audioPipeline;
1970 ✗ source.audioPipeline = nullptr;
1971 ✗ std::string name = source.name;
1972 ✗ std::lock_guard<std::mutex> tlock(m_teardownMutex);
1973 ✗ m_teardownThreads.emplace_back([ap, name]() {
1974 ✗ gst_element_set_state(ap, GST_STATE_NULL);
1975 ✗ gst_object_unref(ap);
1976 ✗ LogInfo(VB_MEDIAOUT, "VideoInputManager: Audio pipeline for '%s' released\n", name.c_str());
1977 ✗ });
1978 ✗ }
1979
1980 88 source.running = false;
1981 88 LogInfo(VB_MEDIAOUT, "VideoInputManager: Source '%s' stopped\n", source.name.c_str());
1982 #endif
1983 88 }
1984
1985 3767 void VideoInputManager::StopAllSources() {
1986 // The swap moves the vector's buffer, so the run threads' pointers to
1987 // each source's flags stay valid until the sources are destroyed below,
1988 // after StopSource() has joined those threads.
1989 3767 std::vector<SourceInfo> sources;
1990 {
1991
1/2
✓ Branch 0 taken 3767 times.
✗ Branch 1 not taken.
3767 std::lock_guard<std::mutex> lock(m_mutex);
1992 3767 sources.swap(m_sources);
1993 3767 }
1994
2/2
✓ Branch 0 taken 84 times.
✓ Branch 1 taken 3767 times.
3851 for (auto& source : sources) {
1995
1/2
✓ Branch 0 taken 84 times.
✗ Branch 1 not taken.
84 StopSource(source);
1996 }
1997 3767 }
1998
1999 3771 void VideoInputManager::JoinTeardownThreads() {
2000 // Swap the list out under the lock, then join outside it — joining can
2001 // block for a while if a pipeline is wedged in gst_element_set_state(),
2002 // and StopSource() (called from other sources' teardown, or a
2003 // concurrent caller) shouldn't have to wait on m_teardownMutex for that.
2004 3771 std::vector<std::thread> threads;
2005 {
2006
1/2
✓ Branch 0 taken 3771 times.
✗ Branch 1 not taken.
3771 std::lock_guard<std::mutex> tlock(m_teardownMutex);
2007 3771 threads.swap(m_teardownThreads);
2008 3771 }
2009
2/2
✓ Branch 0 taken 74 times.
✓ Branch 1 taken 3771 times.
3845 for (auto& t : threads) {
2010
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 if (t.joinable()) {
2011
1/2
✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
74 t.join();
2012 }
2013 }
2014 3771 }
2015
2016
2017 43 bool VideoInputManager::GrabSnapshotJPEG(int sourceId, int maxWidth, int timeoutMs,
2018 std::vector<uint8_t>& jpegOut) {
2019 43 jpegOut.clear();
2020 #ifdef HAS_GSTREAMER_VIDEO_INPUT
2021
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 43 times.
43 if (maxWidth < 32) maxWidth = 32;
2022
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 43 times.
43 if (maxWidth > 1280) maxWidth = 1280;
2023
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 43 times.
43 if (timeoutMs < 100) timeoutMs = 100;
2024
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 43 times.
43 if (timeoutMs > 10000) timeoutMs = 10000;
2025
2026
1/2
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
43 GStreamerOutput::EnsureGStreamerInit();
2027
2028 43 std::string channel;
2029 {
2030
1/2
✓ Branch 0 taken 43 times.
✗ Branch 1 not taken.
43 std::lock_guard<std::mutex> lock(m_mutex);
2031
2/2
✓ Branch 0 taken 28 times.
✓ Branch 1 taken 15 times.
43 for (const auto& src : m_sources) {
2032
1/2
✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
28 if (src.id == sourceId) {
2033
2/2
✓ Branch 0 taken 11 times.
✓ Branch 1 taken 17 times.
28 if (!src.running.load()) {
2034 11 return false;
2035 }
2036
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 channel = src.pipeWireNodeName;
2037 17 break;
2038 }
2039 }
2040
2/2
✓ Branch 0 taken 32 times.
✓ Branch 1 taken 11 times.
43 }
2041
11/18
✓ Branch 0 taken 17 times.
✓ Branch 1 taken 15 times.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 17 times.
✓ Branch 8 taken 17 times.
✓ Branch 9 taken 15 times.
✓ Branch 10 taken 17 times.
✓ Branch 11 taken 15 times.
✓ Branch 12 taken 15 times.
✓ Branch 13 taken 17 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
66 if (channel.empty() || !GstValueUsable(channel, "node name", "snapshot")) {
2042 15 return false;
2043 }
2044
2045 // pixel-aspect-ratio=1/1 is load-bearing. With a width-only capsfilter
2046 // videoscale satisfies the request by changing the PAR rather than the
2047 // height -- a 240x135 source came out as a 320x135 JPEG, and since JPEG
2048 // carries no PAR the preview showed a squashed picture. Pinning PAR to
2049 // square forces videoscale to pick the height instead, so the preview
2050 // has the same shape as the real output.
2051
2/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
34 std::string desc = "intervideosrc name=isrc timeout=" + std::to_string((long long)timeoutMs * 1000000LL)
2052
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
68 + " channel=" + GstQuote(channel)
2053
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
34 + " ! videoconvert"
2054
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
34 + " ! videoscale"
2055
2/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
68 + " ! video/x-raw,width=" + std::to_string(maxWidth)
2056
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
34 + ",pixel-aspect-ratio=1/1"
2057
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
34 + " ! jpegenc quality=70"
2058
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 + " ! appsink name=snap max-buffers=1 drop=true sync=false";
2059
2060 17 GError* error = nullptr;
2061
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 GstElement* pipeline = gst_parse_launch(desc.c_str(), &error);
2062
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 17 times.
17 if (!pipeline) {
2063 ✗ LogWarn(VB_MEDIAOUT, "VideoInputManager: snapshot pipeline failed for source %d: %s\n",
2064 sourceId, error ? error->message : "unknown error");
2065 ✗ if (error) g_error_free(error);
2066 ✗ return false;
2067 }
2068
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 GStreamerOutput::GuardVideoConvertInputs(pipeline);
2069
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 17 times.
17 if (error) {
2070 ✗ g_error_free(error);
2071 ✗ error = nullptr;
2072 }
2073
2074
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 GstElement* sink = gst_bin_get_by_name(GST_BIN(pipeline), "snap");
2075
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 17 times.
17 if (!sink) {
2076 ✗ gst_element_set_state(pipeline, GST_STATE_NULL);
2077 ✗ gst_object_unref(pipeline);
2078 ✗ return false;
2079 }
2080
2081 17 bool ok = false;
2082
2/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
17 if (gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE) {
2083 51 GstSample* sample = gst_app_sink_try_pull_sample(
2084
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 GST_APP_SINK(sink), (GstClockTime)timeoutMs * GST_MSECOND);
2085
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 if (sample) {
2086
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 GstBuffer* buf = gst_sample_get_buffer(sample);
2087 GstMapInfo map;
2088
4/8
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 17 times.
✗ Branch 7 not taken.
17 if (buf && gst_buffer_map(buf, &map, GST_MAP_READ)) {
2089
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 jpegOut.assign(map.data, map.data + map.size);
2090
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gst_buffer_unmap(buf, &map);
2091 17 ok = !jpegOut.empty();
2092 }
2093
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gst_sample_unref(sample);
2094
2095 // Log what intervideosrc actually negotiated off the shared
2096 // surface, now that a frame has arrived and caps are fixed.
2097 // This is the only in-process view of the channel's real caps:
2098 // an out-of-process gst-launch probe can never see it, because
2099 // the inter elements share a surface registry private to one
2100 // process and would report the element default (320x240) and
2101 // emit black frames instead.
2102
3/6
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 17 times.
✗ Branch 5 not taken.
17 GstElement* isrc = gst_bin_get_by_name(GST_BIN(pipeline), "isrc");
2103
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 if (isrc) {
2104
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 GstPad* pad = gst_element_get_static_pad(isrc, "src");
2105
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 if (pad) {
2106
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 GstCaps* c = gst_pad_get_current_caps(pad);
2107
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 if (c) {
2108
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gchar* cs = gst_caps_to_string(c);
2109
2/4
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 17 times.
✗ Branch 3 not taken.
17 LogDebug(VB_MEDIAOUT, "VideoInputManager: channel '%s' negotiated caps: %s\n",
2110 channel.c_str(), cs ? cs : "?");
2111
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 g_free(cs);
2112
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gst_caps_unref(c);
2113 }
2114
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gst_object_unref(pad);
2115 }
2116
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gst_object_unref(isrc);
2117 }
2118 } else {
2119 ✗ LogDebug(VB_MEDIAOUT, "VideoInputManager: snapshot timed out for source %d (no frames on channel %s)\n",
2120 sourceId, channel.c_str());
2121 }
2122 }
2123
2124
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gst_element_set_state(pipeline, GST_STATE_NULL);
2125
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gst_object_unref(sink);
2126
1/2
✓ Branch 0 taken 17 times.
✗ Branch 1 not taken.
17 gst_object_unref(pipeline);
2127 17 return ok;
2128 #else
2129 return false;
2130 #endif
2131 43 }
2132