Handle wrapped go2rtc stream URLs

This commit is contained in:
2026-08-03 00:27:48 +02:00
parent 26e042d2d7
commit 73b302a965
2 changed files with 78 additions and 15 deletions
+35 -15
View File
@@ -23,6 +23,7 @@ from pathlib import Path
VERSION = "1.0" VERSION = "1.0"
DEFAULT_CONFIG = "/etc/frigate-camera-control-bridge.json" DEFAULT_CONFIG = "/etc/frigate-camera-control-bridge.json"
LOG = logging.getLogger("frigate-camera-control-bridge") 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 # The bridge is intentionally a single-file Python service with no runtime
# dependencies beyond the standard library. That keeps the same container image # dependencies beyond the standard library. That keeps the same container image
@@ -773,9 +774,9 @@ class CameraControlBridge:
return actions return actions
def discover_inventory(self): def discover_inventory(self):
# Frigate may reference cameras through go2rtc stream names or direct # Frigate may reference cameras either through direct ffmpeg URLs or
# ffmpeg URLs. We inspect both sources and extract camera IPs from every # through local go2rtc gateway URLs. The selected source URLs are then
# URL we can find. # used to discover the camera API host without hardcoded addresses.
discovery = self.config.get("discovery", {}) discovery = self.config.get("discovery", {})
if not discovery.get("enabled", False): if not discovery.get("enabled", False):
return [] return []
@@ -897,21 +898,39 @@ class CameraControlBridge:
for item in camera_cfg.get("ffmpeg", {}).get("inputs", []): for item in camera_cfg.get("ffmpeg", {}).get("inputs", []):
path = str(item.get("path", "")) path = str(item.get("path", ""))
if path and not CameraControlBridge.go2rtc_stream_name_from_url(path): if path and not CameraControlBridge.go2rtc_stream_name_from_url(path):
urls.append(path) urls.extend(CameraControlBridge.stream_source_urls(path))
return urls return urls
@staticmethod @staticmethod
def go2rtc_stream_name_from_url(url): def go2rtc_stream_name_from_url(url):
try: # go2rtc and Frigate often wrap real stream URLs, for example:
parsed = urllib.parse.urlsplit(str(url)) # ffmpeg:rtsp://127.0.0.1:8554/front-door#video=copy. Normalize that
except ValueError: # first so loopback gateway detection works for wrapped and raw values.
return None for candidate in CameraControlBridge.stream_source_urls(url) or [str(url)]:
host = parsed.hostname or "" try:
if parsed.scheme != "rtsp" or parsed.port != 8554: parsed = urllib.parse.urlsplit(candidate)
return None except ValueError:
if host not in ("127.0.0.1", "localhost", "::1"): continue
return None host = parsed.hostname or ""
return parsed.path.lstrip("/").split("/", 1)[0] or None 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 @staticmethod
def resolve_go2rtc_stream_urls(streams, stream_name): def resolve_go2rtc_stream_urls(streams, stream_name):
@@ -927,7 +946,8 @@ class CameraControlBridge:
if value is None: if value is None:
return [] return []
if isinstance(value, str): if isinstance(value, str):
return [value] urls = CameraControlBridge.stream_source_urls(value)
return urls or [value]
if isinstance(value, list): if isinstance(value, list):
out = [] out = []
for item in value: for item in value:
+43
View File
@@ -35,6 +35,30 @@ class DiscoveryMappingTest(unittest.TestCase):
self.assertEqual(urls, ["rtsp://camera-a.example.test:554/Streaming/Channels/101"]) 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"]) 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): def test_direct_ffmpeg_path_is_used_without_go2rtc(self):
camera_cfg = { camera_cfg = {
"ffmpeg": { "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(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"]) 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): def test_direct_path_wins_when_loopback_is_also_present(self):
camera_cfg = { camera_cfg = {
"ffmpeg": { "ffmpeg": {