Handle wrapped go2rtc stream URLs
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user