diff --git a/frigate-camera-control-bridge.py b/frigate-camera-control-bridge.py index 0be67ec..bce4f68 100644 --- a/frigate-camera-control-bridge.py +++ b/frigate-camera-control-bridge.py @@ -23,6 +23,7 @@ from pathlib import Path VERSION = "1.0" DEFAULT_CONFIG = "/etc/frigate-camera-control-bridge.json" LOG = logging.getLogger("frigate-camera-control-bridge") +STREAM_URL_RE = re.compile(r"(?:rtsp|rtsps|http|https)://[^\s'\"<>]+", re.IGNORECASE) # The bridge is intentionally a single-file Python service with no runtime # dependencies beyond the standard library. That keeps the same container image @@ -773,9 +774,9 @@ class CameraControlBridge: return actions def discover_inventory(self): - # Frigate may reference cameras through go2rtc stream names or direct - # ffmpeg URLs. We inspect both sources and extract camera IPs from every - # URL we can find. + # Frigate may reference cameras either through direct ffmpeg URLs or + # through local go2rtc gateway URLs. The selected source URLs are then + # used to discover the camera API host without hardcoded addresses. discovery = self.config.get("discovery", {}) if not discovery.get("enabled", False): return [] @@ -897,21 +898,39 @@ class CameraControlBridge: for item in camera_cfg.get("ffmpeg", {}).get("inputs", []): path = str(item.get("path", "")) if path and not CameraControlBridge.go2rtc_stream_name_from_url(path): - urls.append(path) + urls.extend(CameraControlBridge.stream_source_urls(path)) return urls @staticmethod def go2rtc_stream_name_from_url(url): - try: - parsed = urllib.parse.urlsplit(str(url)) - except ValueError: - return None - host = parsed.hostname or "" - if parsed.scheme != "rtsp" or parsed.port != 8554: - return None - if host not in ("127.0.0.1", "localhost", "::1"): - return None - return parsed.path.lstrip("/").split("/", 1)[0] or None + # go2rtc and Frigate often wrap real stream URLs, for example: + # ffmpeg:rtsp://127.0.0.1:8554/front-door#video=copy. Normalize that + # first so loopback gateway detection works for wrapped and raw values. + for candidate in CameraControlBridge.stream_source_urls(url) or [str(url)]: + try: + parsed = urllib.parse.urlsplit(candidate) + except ValueError: + continue + host = parsed.hostname or "" + if parsed.scheme != "rtsp" or parsed.port != 8554: + continue + if host not in ("127.0.0.1", "localhost", "::1"): + continue + return parsed.path.lstrip("/").split("/", 1)[0] or None + return None + + @staticmethod + def stream_source_urls(value): + # go2rtc supports source wrappers such as "ffmpeg:rtsp://..." and may + # append options after a URL fragment. Extract URL tokens instead of + # treating the wrapper prefix as the network scheme. + text = str(value or "") + urls = STREAM_URL_RE.findall(text) + if urls: + return urls + if "://" in text: + return [text] + return [] @staticmethod def resolve_go2rtc_stream_urls(streams, stream_name): @@ -927,7 +946,8 @@ class CameraControlBridge: if value is None: return [] if isinstance(value, str): - return [value] + urls = CameraControlBridge.stream_source_urls(value) + return urls or [value] if isinstance(value, list): out = [] for item in value: diff --git a/tests/test_discovery_mapping.py b/tests/test_discovery_mapping.py index 2306bbb..1f1916d 100644 --- a/tests/test_discovery_mapping.py +++ b/tests/test_discovery_mapping.py @@ -35,6 +35,30 @@ class DiscoveryMappingTest(unittest.TestCase): self.assertEqual(urls, ["rtsp://camera-a.example.test:554/Streaming/Channels/101"]) self.assertEqual(self.bridge.CameraControlBridge.hosts_from_urls(urls), ["camera-a.example.test"]) + def test_wrapped_go2rtc_stream_resolves_inner_camera_url(self): + camera_cfg = { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://127.0.0.1:8554/stream-a"}, + ] + } + } + streams = { + "stream-a": [ + "ffmpeg:rtsp://camera-a.example.test:554/Streaming/Channels/101?profile=main#hardware", + ] + } + + urls = self.bridge.CameraControlBridge.camera_candidate_urls("camera-a", camera_cfg, streams) + + self.assertEqual( + urls, + [ + "rtsp://camera-a.example.test:554/Streaming/Channels/101?profile=main#hardware", + ], + ) + self.assertEqual(self.bridge.CameraControlBridge.hosts_from_urls(urls), ["camera-a.example.test"]) + def test_direct_ffmpeg_path_is_used_without_go2rtc(self): camera_cfg = { "ffmpeg": { @@ -49,6 +73,25 @@ class DiscoveryMappingTest(unittest.TestCase): self.assertEqual(urls, ["rtsp://camera-b.example.test:554/cam/realmonitor?channel=1&subtype=0"]) self.assertEqual(self.bridge.CameraControlBridge.hosts_from_urls(urls), ["camera-b.example.test"]) + def test_wrapped_direct_ffmpeg_path_is_used_without_go2rtc(self): + camera_cfg = { + "ffmpeg": { + "inputs": [ + {"path": "ffmpeg:rtsp://camera-b.example.test:554/cam/realmonitor?channel=1&subtype=0#video=copy"}, + ] + } + } + + urls = self.bridge.CameraControlBridge.camera_candidate_urls("camera-b", camera_cfg, {}) + + self.assertEqual( + urls, + [ + "rtsp://camera-b.example.test:554/cam/realmonitor?channel=1&subtype=0#video=copy", + ], + ) + self.assertEqual(self.bridge.CameraControlBridge.hosts_from_urls(urls), ["camera-b.example.test"]) + def test_direct_path_wins_when_loopback_is_also_present(self): camera_cfg = { "ffmpeg": {