| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | /* | ||
| 3 | * This file is part of the Falcon Player (FPP) and is Copyright (C) | ||
| 4 | * 2013-2026 by the Falcon Player Developers. | ||
| 5 | * | ||
| 6 | * The Falcon Player (FPP) is free software, and is covered under | ||
| 7 | * multiple Open Source licenses. Please see the included 'LICENSES' | ||
| 8 | * file for descriptions of what files are covered by each license. | ||
| 9 | * | ||
| 10 | * This source file is covered under the LGPL v2.1 as described in the | ||
| 11 | * included LICENSE.LGPL file. | ||
| 12 | */ | ||
| 13 | |||
| 14 | #include <atomic> | ||
| 15 | #include <chrono> | ||
| 16 | #include <condition_variable> | ||
| 17 | #include <cstdint> | ||
| 18 | #include <map> | ||
| 19 | #include <mutex> | ||
| 20 | #include <string> | ||
| 21 | #include <thread> | ||
| 22 | #include <vector> | ||
| 23 | |||
| 24 | #if __has_include(<gst/gst.h>) | ||
| 25 | #define HAS_GSTREAMER_VIDEO_INPUT | ||
| 26 | #include <gst/gst.h> | ||
| 27 | #endif | ||
| 28 | |||
| 29 | /** | ||
| 30 | * VideoInputManager — manages persistent PipeWire video source pipelines. | ||
| 31 | * | ||
| 32 | * Each video input source runs a GStreamer producer pipeline: | ||
| 33 | * <source> → videoconvert → pipewiresink (mode=provide, Video/Source) | ||
| 34 | * | ||
| 35 | * Sources start at Init() and persist independently of media playback. | ||
| 36 | * They appear as Video/Source nodes in the PipeWire graph. Consumers | ||
| 37 | * (VideoOutputManager) target them via the pipewiresrc target-object | ||
| 38 | * property using the source's node name. | ||
| 39 | * | ||
| 40 | * Requires pipewiresink mode=provide support (PipeWire GStreamer plugin | ||
| 41 | * >= 1.7.0 or equivalent patched build). | ||
| 42 | * | ||
| 43 | * Config file: $mediaDirectory/config/pipewire-video-input-sources.json | ||
| 44 | */ | ||
| 45 | class VideoInputManager { | ||
| 46 | public: | ||
| 47 | static VideoInputManager& Instance(); | ||
| 48 | |||
| 49 | /// Load config and start enabled sources, on the reload worker (see | ||
| 50 | /// RequestReload()), so this returns before they are running. | ||
| 51 | void Init(); | ||
| 52 | |||
| 53 | /// Reload config — stops all sources, reloads, restarts enabled ones. | ||
| 54 | void Reload(); | ||
| 55 | |||
| 56 | /// Reload() on the manager's own worker thread, so whoever asks (fppd's | ||
| 57 | /// main loop, for the reloadVideoInputs command) doesn't wait out yt-dlp | ||
| 58 | /// and pipeline teardown. A request made while a reload is running | ||
| 59 | /// makes the worker go round once more. | ||
| 60 | void RequestReload(); | ||
| 61 | |||
| 62 | /// Called by a source's run thread when its pipeline fails to start, | ||
| 63 | /// errors or ends: restart that source on the reload worker after a | ||
| 64 | /// backoff (2s, doubling to 60s; back to 2s once it has run for five | ||
| 65 | /// minutes). Takes only m_reloadMutex -- the run thread must never | ||
| 66 | /// wait on m_mutex, since RestartSource() joins it under that lock. | ||
| 67 | void ScheduleSourceRestart(int sourceId); | ||
| 68 | |||
| 69 | /// Stop all source pipelines and clean up. | ||
| 70 | void Shutdown(); | ||
| 71 | |||
| 72 | /// Check if any sources are configured. | ||
| 73 | bool HasSources() const; | ||
| 74 | |||
| 75 | /// Check if any sources are currently running. | ||
| 76 | bool HasActiveSources() const; | ||
| 77 | |||
| 78 | /// Get count of running source pipelines. | ||
| 79 | int ActiveSourceCount() const; | ||
| 80 | |||
| 81 | /// Get the PipeWire node name for a source by its config ID. | ||
| 82 | /// Returns empty string if not found or not running. | ||
| 83 | std::string GetSourceNodeName(int sourceId) const; | ||
| 84 | |||
| 85 | /// Get list of all configured source node names (for UI/API). | ||
| 86 | std::vector<std::pair<int, std::string>> GetSourceList() const; | ||
| 87 | |||
| 88 | /// Get the framerate of a source by its intervideo channel name. | ||
| 89 | /// Returns the detected (or configured) fps, or 0 if not found. | ||
| 90 | int GetSourceFramerate(const std::string& channelName) const; | ||
| 91 | |||
| 92 | /// True when the source publishing `channelName` has a live pipeline. | ||
| 93 | /// Lets a consumer that missed NotifyProducerReady -- because the source | ||
| 94 | /// was already up when the consumer was (re)configured -- start anyway. | ||
| 95 | bool IsSourceRunning(const std::string& channelName) const; | ||
| 96 | |||
| 97 | /// Grab a single JPEG frame from a running source, for UI preview. | ||
| 98 | /// | ||
| 99 | /// Taps the source's intervideo channel with a short-lived | ||
| 100 | /// intervideosrc, so it costs the source pipeline nothing and — unlike | ||
| 101 | /// opening /dev/videoN a second time — never contends with the capture | ||
| 102 | /// device (UVC cameras allow a single opener). | ||
| 103 | /// | ||
| 104 | /// Returns false if the source isn't running or no frame arrived within | ||
| 105 | /// timeoutMs. | ||
| 106 | bool GrabSnapshotJPEG(int sourceId, int maxWidth, int timeoutMs, | ||
| 107 | std::vector<uint8_t>& jpegOut); | ||
| 108 | |||
| 109 | private: | ||
| 110 | 1887 | VideoInputManager() = default; | |
| 111 | ~VideoInputManager(); | ||
| 112 | |||
| 113 | VideoInputManager(const VideoInputManager&) = delete; | ||
| 114 | VideoInputManager& operator=(const VideoInputManager&) = delete; | ||
| 115 | |||
| 116 | struct SourceInfo { | ||
| 117 | int id = 0; | ||
| 118 | std::string name; | ||
| 119 | std::string type; // "videotestsrc", "v4l2src", "rtspsrc", "urisrc", "rtpsrc" | ||
| 120 | std::string pipeWireNodeName; | ||
| 121 | bool enabled = true; | ||
| 122 | |||
| 123 | // videotestsrc settings | ||
| 124 | std::string pattern; // "smpte", "snow", "black", etc. | ||
| 125 | |||
| 126 | // v4l2src settings | ||
| 127 | std::string device; // "/dev/video0" | ||
| 128 | |||
| 129 | // v4l2src device controls. Defaults mean "leave the camera's own | ||
| 130 | // setting alone", so a config written before these existed behaves | ||
| 131 | // exactly as it did. See V4L2Device.h for why these are device | ||
| 132 | // controls and not something the pipeline can do downstream. | ||
| 133 | int powerLineFrequency = -1; // -1 camera default, 0 off, 1 = 50Hz, 2 = 60Hz | ||
| 134 | std::string exposureMode = "camera"; // "camera", "auto", "manual" | ||
| 135 | int exposureTime100us = -1; // manual shutter, 100us units | ||
| 136 | int dynamicFramerate = -1; // -1 camera default, 0 hold fps, 1 allow AE to drop it | ||
| 137 | |||
| 138 | // rtspsrc settings | ||
| 139 | std::string uri; // "rtsp://host/path" | ||
| 140 | int latency = 200; // ms | ||
| 141 | |||
| 142 | // urisrc settings (uri field shared with rtspsrc) | ||
| 143 | double bufferSec = 3.0; // seconds of buffering | ||
| 144 | |||
| 145 | // rtpsrc settings | ||
| 146 | int port = 5004; // UDP port | ||
| 147 | std::string encoding; // "H264", "H265", "MP2T", "RAW", "JPEG" | ||
| 148 | std::string multicastGroup; // optional multicast address | ||
| 149 | |||
| 150 | // Common settings | ||
| 151 | int width = 320; | ||
| 152 | int height = 240; | ||
| 153 | int framerate = 10; | ||
| 154 | |||
| 155 | // Audio extraction settings | ||
| 156 | bool audioEnabled = false; // Extract and publish audio from this source | ||
| 157 | std::string audioPipeWireNodeName; // Generated: PipeWire audio source node name | ||
| 158 | |||
| 159 | #ifdef HAS_GSTREAMER_VIDEO_INPUT | ||
| 160 | GstElement* pipeline = nullptr; | ||
| 161 | GstElement* audioPipeline = nullptr; | ||
| 162 | #endif | ||
| 163 | std::thread runThread; | ||
| 164 | std::thread audioRunThread; | ||
| 165 | std::atomic<bool> shutdownRequested{false}; | ||
| 166 | std::atomic<bool> audioShutdownRequested{false}; | ||
| 167 | // atomic: read/written from both the owning thread and the bus | ||
| 168 | // monitor thread spawned in StartSource()/StartSourceWithAudio() | ||
| 169 | // without holding m_mutex. | ||
| 170 | std::atomic<bool> running{false}; | ||
| 171 | |||
| 172 |
1/2✓ Branch 0 taken 86 times.
✗ Branch 1 not taken.
|
258 | SourceInfo() = default; |
| 173 | 108 | SourceInfo(SourceInfo&& o) noexcept | |
| 174 | 108 | : id(o.id), name(std::move(o.name)), type(std::move(o.type)), | |
| 175 | 108 | pipeWireNodeName(std::move(o.pipeWireNodeName)), | |
| 176 | 108 | enabled(o.enabled), pattern(std::move(o.pattern)), | |
| 177 | 108 | device(std::move(o.device)), | |
| 178 | 108 | powerLineFrequency(o.powerLineFrequency), | |
| 179 | 108 | exposureMode(std::move(o.exposureMode)), | |
| 180 | 108 | exposureTime100us(o.exposureTime100us), | |
| 181 | 108 | dynamicFramerate(o.dynamicFramerate), | |
| 182 | 108 | uri(std::move(o.uri)), latency(o.latency), | |
| 183 | 108 | bufferSec(o.bufferSec), | |
| 184 | 108 | port(o.port), encoding(std::move(o.encoding)), | |
| 185 | 108 | multicastGroup(std::move(o.multicastGroup)), | |
| 186 | 108 | width(o.width), height(o.height), framerate(o.framerate), | |
| 187 | 108 | audioEnabled(o.audioEnabled), | |
| 188 | 108 | audioPipeWireNodeName(std::move(o.audioPipeWireNodeName)), | |
| 189 | #ifdef HAS_GSTREAMER_VIDEO_INPUT | ||
| 190 | 108 | pipeline(o.pipeline), | |
| 191 | 108 | audioPipeline(o.audioPipeline), | |
| 192 | #endif | ||
| 193 | 108 | runThread(std::move(o.runThread)), | |
| 194 | 108 | audioRunThread(std::move(o.audioRunThread)), | |
| 195 | 108 | shutdownRequested(o.shutdownRequested.load()), | |
| 196 | 108 | audioShutdownRequested(o.audioShutdownRequested.load()), | |
| 197 | 108 | running(o.running.load()) { | |
| 198 | #ifdef HAS_GSTREAMER_VIDEO_INPUT | ||
| 199 | 108 | o.pipeline = nullptr; | |
| 200 | 108 | o.audioPipeline = nullptr; | |
| 201 | #endif | ||
| 202 | 108 | } | |
| 203 | SourceInfo(const SourceInfo&) = delete; | ||
| 204 | SourceInfo& operator=(const SourceInfo&) = delete; | ||
| 205 | }; | ||
| 206 | |||
| 207 | /// Load source config from JSON file. | ||
| 208 | bool LoadConfig(); | ||
| 209 | |||
| 210 | /// Start a single source pipeline. | ||
| 211 | bool StartSource(SourceInfo& source); | ||
| 212 | |||
| 213 | /// Build and start pipeline with separate video + audio streams. | ||
| 214 | bool StartSourceWithAudio(SourceInfo& source); | ||
| 215 | |||
| 216 | /// Build the capsfilter constraining what we ask the capture device | ||
| 217 | /// for, or "" to leave the device unconstrained. See the call site | ||
| 218 | /// for why an unsupported mode must not be pinned. | ||
| 219 | std::string BuildDeviceCaps(const SourceInfo& source); | ||
| 220 | |||
| 221 | /// Start every enabled source just loaded into m_sources. Called with | ||
| 222 | /// m_mutex held through `lock`; releases it while yt-dlp resolves any | ||
| 223 | /// YouTube sources. | ||
| 224 | void StartLoadedSources(std::unique_lock<std::mutex>& lock); | ||
| 225 | |||
| 226 | /// Stop a single source pipeline. | ||
| 227 | void StopSource(SourceInfo& source); | ||
| 228 | |||
| 229 | /// Restart one dead source in place (reload worker only). | ||
| 230 | void RestartSource(int sourceId); | ||
| 231 | |||
| 232 | /// Start the reload worker if it isn't running. m_reloadMutex held. | ||
| 233 | void EnsureReloadWorkerLocked(); | ||
| 234 | |||
| 235 | /// Take every source out of m_sources (under m_mutex), then stop them | ||
| 236 | /// with the lock released. StopSource() joins each source's run thread, | ||
| 237 | /// and a run thread can be waiting on m_mutex: it calls | ||
| 238 | /// VideoOutputManager::NotifyProducerReady(), which reads the framerate | ||
| 239 | /// back through GetSourceFramerate(). Joining it under m_mutex | ||
| 240 | /// deadlocks both managers. | ||
| 241 | void StopAllSources(); | ||
| 242 | |||
| 243 | /// Join (and discard) any pipeline-teardown threads queued up by | ||
| 244 | /// StopSource(). Must be called before re-opening the same device/URI | ||
| 245 | /// (start of Reload()) and at Shutdown() so nothing outlives the | ||
| 246 | /// manager. | ||
| 247 | void JoinTeardownThreads(); | ||
| 248 | |||
| 249 | mutable std::mutex m_mutex; | ||
| 250 | std::vector<SourceInfo> m_sources; | ||
| 251 | bool m_initialized = false; | ||
| 252 | // Guards Shutdown() so the destructor doesn't re-run it after main() already | ||
| 253 | // did (the "Shutdown complete" was logged twice). Keeps teardown single-shot. | ||
| 254 | std::atomic<bool> m_shutdownDone{false}; | ||
| 255 | |||
| 256 | // StopSource() offloads gst_element_set_state(..., GST_STATE_NULL) + | ||
| 257 | // unref to a background thread so callers (Shutdown/Reload) don't block | ||
| 258 | // on a wedged pipeline. Those threads used to be detach()'d, which let | ||
| 259 | // them (and their pipeline refs) pile up across repeated Reloads and | ||
| 260 | // race a subsequent StartSource() trying to reopen the same device. | ||
| 261 | // They're tracked here instead and joined before the device can be | ||
| 262 | // reopened, and at Shutdown(). | ||
| 263 | std::mutex m_teardownMutex; | ||
| 264 | std::vector<std::thread> m_teardownThreads; | ||
| 265 | |||
| 266 | // RequestReload()'s worker, which also runs the restarts that | ||
| 267 | // ScheduleSourceRestart() queues. m_reloadMutex guards all of these. | ||
| 268 | struct RestartState { | ||
| 269 | std::chrono::steady_clock::time_point due; | ||
| 270 | std::chrono::steady_clock::time_point lastFailure; | ||
| 271 | int delayMs = 0; | ||
| 272 | bool pending = false; | ||
| 273 | }; | ||
| 274 | std::mutex m_reloadMutex; | ||
| 275 | std::condition_variable m_reloadCv; | ||
| 276 | std::thread m_reloadThread; | ||
| 277 | bool m_reloadPending = false; | ||
| 278 | bool m_reloadRunning = false; | ||
| 279 | bool m_stopReloads = false; | ||
| 280 | std::map<int, RestartState> m_restarts; | ||
| 281 | |||
| 282 | static constexpr int RESTART_MIN_DELAY_MS = 2000; | ||
| 283 | static constexpr int RESTART_MAX_DELAY_MS = 60000; | ||
| 284 | static constexpr int RESTART_QUIET_RESET_MS = 5 * 60 * 1000; | ||
| 285 | }; | ||
| 286 |