diff --git a/.agents/skills/translate-docs-zh-en/references/translation-memory.md b/.agents/skills/translate-docs-zh-en/references/translation-memory.md
index 9bed39b9..340e42bd 100644
--- a/.agents/skills/translate-docs-zh-en/references/translation-memory.md
+++ b/.agents/skills/translate-docs-zh-en/references/translation-memory.md
@@ -38,6 +38,10 @@
- 卡顿 -> stuttering
- 后台 -> admin panel / admin interface
- 画质增强 -> video enhancement
+- 存活探测 -> liveness probe
+- 行为变更 -> Behavior change
+- 响应头 -> response header
+- 上游流 Metadata -> upstream stream metadata (the `R2H-*` headers)
### China-specific Terms
@@ -54,3 +58,12 @@
- Chinese tutorial URLs are kept unchanged when the target content is Chinese-only.
- `AI 网络诊断助手` -> `AI Network Troubleshooting` for the self-contained consumer AI prompt
page and its sidebar entry.
+
+## Admonition Style
+
+Both doc trees use GitHub alert syntax (`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`,
+`> [!WARNING]`, `> [!CAUTION]`), **not** VitePress `::: tip` / `::: warning` containers.
+VitePress renders both, but the whole repo uses the GitHub form — keep it that way.
+
+GitHub alerts cannot carry a custom title. If a Chinese source needs one, put it as a
+bold lead-in inside the alert body (`> **行为变更**:…`) and mirror that in English.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 44de69da..32714733 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -151,7 +151,8 @@ endif()
# ── Libraries ───────────────────────────────────────────────────────
find_package(Threads REQUIRED)
-target_link_libraries(rtp2httpd PRIVATE Threads::Threads)
+# Math functions require libm on some Unix platforms; macOS maps it to libSystem.
+target_link_libraries(rtp2httpd PRIVATE Threads::Threads m)
if(PLATFORM_LINUX)
# rt library for clock_gettime (not needed on macOS)
diff --git a/docs/en/guide/url-formats.md b/docs/en/guide/url-formats.md
index 495cd795..2b3064bd 100644
--- a/docs/en/guide/url-formats.md
+++ b/docs/en/guide/url-formats.md
@@ -91,6 +91,21 @@ This is caused by timezone mismatch. You need to perform timezone conversion. Tr
For detailed information on time-shift parameter handling (timezone, offset), see [Time Processing Guide](/en/guide/time-processing).
+## Upstream Stream Metadata Response Headers
+
+RTSP, standard multicast, and FCC streams carry the following `R2H-*` metadata in the HTTP response headers:
+
+| Response Header | Meaning |
+| --- | --- |
+| `R2H-Upstream-Protocol` | Upstream type: `rtsp` or `multicast`. FCC streams are also reported as `multicast`. |
+| `R2H-Upstream-Transport` | Transport negotiated by RTSP: `tcp-interleaved` or `udp`. |
+| `R2H-Upstream-Payload` | Confirmed MPEG-TS encapsulation: `mp2t-rtp` or `mp2t-direct`. |
+| `R2H-Playback-Scale` | Playback scale explicitly confirmed by the RTSP `PLAY` response. |
+| `R2H-Playback-Range` | Playback range explicitly confirmed by the RTSP `PLAY` response. |
+| `R2H-Media-Duration` | Duration of a finite SDP `npt` range, in seconds. |
+| `R2H-FCC-Type` | Configured FCC protocol: `telecom` or `huawei`. |
+| `R2H-FCC-Status` | `active` when playback starts from FCC unicast; `fallback` when it starts from multicast. |
+
## HTTP Reverse Proxy
```url
diff --git a/docs/en/guide/video-snapshot.md b/docs/en/guide/video-snapshot.md
index ae5b91f7..02163244 100644
--- a/docs/en/guide/video-snapshot.md
+++ b/docs/en/guide/video-snapshot.md
@@ -181,6 +181,8 @@ http://192.168.1.1:5140/CCTV-1?snapshot=1
curl -H "Accept: image/jpeg" http://192.168.1.1:5140/rtp/239.253.64.120:5140
```
+When JPEG generation succeeds, the response includes both `Content-Length` and the `R2H-*` upstream stream metadata. See [URL Formats](/en/guide/url-formats#upstream-stream-metadata-response-headers) for the field reference.
+
## Troubleshooting
### Snapshot Request Returns Video Stream Instead of Image
diff --git a/docs/guide/url-formats.md b/docs/guide/url-formats.md
index f4294095..1867f7ee 100644
--- a/docs/guide/url-formats.md
+++ b/docs/guide/url-formats.md
@@ -91,6 +91,21 @@ http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?r2h-ifname=eth0
关于时移回看的参数处理(时区、偏移),详见 [时间处理说明](./time-processing.md)。
+## 上游流 Metadata 响应头
+
+RTSP、普通组播和 FCC 流会在 HTTP Headers 带上以下 `R2H-*` Metadata:
+
+| 响应头 | 含义 |
+| --- | --- |
+| `R2H-Upstream-Protocol` | 上游类型:`rtsp` 或 `multicast`。FCC 流也属于 `multicast`。 |
+| `R2H-Upstream-Transport` | RTSP 实际协商的传输方式:`tcp-interleaved` 或 `udp`。 |
+| `R2H-Upstream-Payload` | 已确认的 MPEG-TS 封装:`mp2t-rtp` 或 `mp2t-direct`。 |
+| `R2H-Playback-Scale` | RTSP `PLAY` 响应明确确认的播放速率。 |
+| `R2H-Playback-Range` | RTSP `PLAY` 响应明确确认的播放范围。 |
+| `R2H-Media-Duration` | SDP 有限 `npt` 范围的时长,单位为秒。 |
+| `R2H-FCC-Type` | 已配置的 FCC 协议:`telecom` 或 `huawei`。 |
+| `R2H-FCC-Status` | 从 FCC 单播起播为 `active`;从组播起播为 `fallback`。 |
+
## HTTP 反向代理
```url
diff --git a/docs/guide/video-snapshot.md b/docs/guide/video-snapshot.md
index e290983e..7335cd12 100644
--- a/docs/guide/video-snapshot.md
+++ b/docs/guide/video-snapshot.md
@@ -181,6 +181,8 @@ http://192.168.1.1:5140/CCTV-1?snapshot=1
curl -H "Accept: image/jpeg" http://192.168.1.1:5140/rtp/239.253.64.120:5140
```
+成功生成 JPEG 时,响应会同时包含 `Content-Length` 和 `R2H-*` 上游流 Metadata。字段说明见 [URL 格式说明](./url-formats.md#上游流-metadata-响应头)。
+
## 故障排查
### 快照请求返回视频流而不是图片
diff --git a/e2e/helpers/__init__.py b/e2e/helpers/__init__.py
index 5cabde09..ca4ecdb5 100644
--- a/e2e/helpers/__init__.py
+++ b/e2e/helpers/__init__.py
@@ -32,6 +32,7 @@
get_upstream_path,
http_get,
http_request,
+ raw_http_request,
stream_get,
unix_http_get,
unix_http_request,
@@ -90,6 +91,7 @@
"ipv6_loopback_available",
"make_m3u_rtsp_config",
"make_rtp_packet",
+ "raw_http_request",
"stream_get",
"unix_http_get",
"unix_http_request",
diff --git a/e2e/helpers/http.py b/e2e/helpers/http.py
index 185180bc..d30ca156 100644
--- a/e2e/helpers/http.py
+++ b/e2e/helpers/http.py
@@ -155,6 +155,37 @@ def _parse_raw_http_response(data: bytes, lower_header_names: bool = False) -> t
return status_code, hdrs, body
+def raw_http_request(
+ host: str,
+ port: int,
+ method: str,
+ path: str,
+ timeout: float = 5.0,
+) -> tuple[int, dict, bytes]:
+ """Send a request over a bare socket and return the response verbatim.
+
+ Unlike ``http_request``, this does not use ``http.client``, which silently
+ treats HEAD responses as bodiless. Use it to assert that a HEAD response
+ really carries no content (RFC 9110 9.3.2).
+ """
+ sock = socket.create_connection((host, port), timeout=timeout)
+ try:
+ sock.sendall(("%s %s HTTP/1.1\r\nHost: %s\r\n\r\n" % (method, path, host)).encode())
+ data = b""
+ while True:
+ try:
+ chunk = sock.recv(4096)
+ except socket.timeout:
+ break
+ if not chunk:
+ break
+ data += chunk
+ finally:
+ sock.close()
+
+ return _parse_raw_http_response(data, lower_header_names=True)
+
+
def unix_http_request(
socket_path: str,
method: str,
diff --git a/e2e/helpers/mock_rtsp.py b/e2e/helpers/mock_rtsp.py
index d9ce8702..e315e785 100644
--- a/e2e/helpers/mock_rtsp.py
+++ b/e2e/helpers/mock_rtsp.py
@@ -8,7 +8,7 @@
import time
from .ports import find_free_port, find_free_udp_port_pair
-from .rtp import make_rtp_packet
+from .rtp import TS_NULL_PACKET, make_rtp_packet
# ---------------------------------------------------------------------------
@@ -30,6 +30,11 @@ def __init__(
content_base: str | None = "auto",
custom_sdp: str | None = None,
options_session_id: str | None = None,
+ play_response_headers: list[tuple[str, str]] | None = None,
+ close_after_describe: bool = False,
+ reset_after_describe: bool = False,
+ redirect_describe_to: str | None = None,
+ challenge_describe_once: bool = False,
host: str = "127.0.0.1",
):
"""
@@ -44,6 +49,16 @@ def __init__(
options_session_id: If set, OPTIONS responds with this Session ID
and every subsequent request (DESCRIBE, SETUP, PLAY, ...)
must echo it (simulates HMS-style servers).
+ play_response_headers: Extra headers to append to the PLAY 200 OK
+ (e.g. ``[("Scale", "2.0"), ("Range", "npt=0-30")]``).
+ close_after_describe: Close the control connection immediately after
+ sending the DESCRIBE response.
+ reset_after_describe: Reset the control connection immediately after
+ sending the DESCRIBE response.
+ redirect_describe_to: If set, DESCRIBE answers ``302`` with this
+ ``Location`` instead of an SDP body.
+ challenge_describe_once: If set, the first DESCRIBE answers ``401``
+ with a Basic challenge and the retry is served normally.
host: Address to listen on (use "::1" for IPv6 loopback).
"""
self.host = host
@@ -52,6 +67,12 @@ def __init__(
self._content_base = content_base
self._custom_sdp = custom_sdp
self._options_session_id = options_session_id
+ self._play_response_headers = play_response_headers or []
+ self._close_after_describe = close_after_describe
+ self._reset_after_describe = reset_after_describe
+ self._redirect_describe_to = redirect_describe_to
+ self._challenge_describe_once = challenge_describe_once
+ self._describe_challenged = False
self._server_sock: socket.socket | None = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
@@ -161,6 +182,23 @@ def _handle(self, conn: socket.socket, addr: tuple) -> None:
).encode()
)
elif method == "DESCRIBE":
+ if self._redirect_describe_to:
+ conn.sendall(
+ (
+ "RTSP/1.0 302 Moved Temporarily\r\nCSeq: %s\r\n"
+ "Location: %s\r\n\r\n" % (cseq, self._redirect_describe_to)
+ ).encode()
+ )
+ return
+ if self._challenge_describe_once and not self._describe_challenged:
+ self._describe_challenged = True
+ conn.sendall(
+ (
+ "RTSP/1.0 401 Unauthorized\r\nCSeq: %s\r\n"
+ 'WWW-Authenticate: Basic realm="mock"\r\n\r\n' % cseq
+ ).encode()
+ )
+ continue
if self._custom_sdp is not None:
sdp = self._custom_sdp
else:
@@ -191,11 +229,20 @@ def _handle(self, conn: socket.socket, addr: tuple) -> None:
"Content-Length: %d\r\n\r\n%s" % (cseq, cb_header, len(sdp), sdp)
).encode()
)
+ if self._reset_after_describe:
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
+ return
+ if self._close_after_describe:
+ return
elif method == "SETUP":
conn.sendall(self._setup_response(cseq, transport_hdr).encode())
elif method == "PLAY":
+ extra_headers = "".join("%s: %s\r\n" % item for item in self._play_response_headers)
conn.sendall(
- ("RTSP/1.0 200 OK\r\nCSeq: %s\r\nSession: %s\r\n\r\n" % (cseq, self._session_id())).encode()
+ (
+ "RTSP/1.0 200 OK\r\nCSeq: %s\r\nSession: %s\r\n%s\r\n"
+ % (cseq, self._session_id(), extra_headers)
+ ).encode()
)
self._after_play(conn, addr)
return
@@ -223,6 +270,9 @@ class MockRTSPServer(_RTSPServerBase):
that rtp2httpd's kqueue-based event loop reliably flushes data to the
HTTP client (macOS kqueue doesn't always wake for partial writes while
the RTSP source is still connected).
+
+ With ``encapsulate_rtp=False`` the interleaved frames carry bare MPEG-TS
+ instead, which is how ``MP2T/TCP`` upstreams behave.
"""
def __init__(
@@ -233,6 +283,13 @@ def __init__(
content_base: str | None = "auto",
custom_sdp: str | None = None,
options_session_id: str | None = None,
+ play_response_headers: list[tuple[str, str]] | None = None,
+ close_after_describe: bool = False,
+ reset_after_describe: bool = False,
+ redirect_describe_to: str | None = None,
+ challenge_describe_once: bool = False,
+ encapsulate_rtp: bool = True,
+ setup_transport: str = "RTP/AVP/TCP;unicast;interleaved=0-1",
host: str = "127.0.0.1",
):
super().__init__(
@@ -241,15 +298,22 @@ def __init__(
content_base=content_base,
custom_sdp=custom_sdp,
options_session_id=options_session_id,
+ play_response_headers=play_response_headers,
+ close_after_describe=close_after_describe,
+ reset_after_describe=reset_after_describe,
+ redirect_describe_to=redirect_describe_to,
+ challenge_describe_once=challenge_describe_once,
host=host,
)
self._num_packets = num_packets
+ self._encapsulate_rtp = encapsulate_rtp
+ self._setup_transport = setup_transport
def _setup_response(self, cseq: str, transport_hdr: str) -> str:
- return (
- "RTSP/1.0 200 OK\r\nCSeq: %s\r\n"
- "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n"
- "Session: %s\r\n\r\n" % (cseq, self._session_id())
+ return "RTSP/1.0 200 OK\r\nCSeq: %s\r\nTransport: %s\r\nSession: %s\r\n\r\n" % (
+ cseq,
+ self._setup_transport,
+ self._session_id(),
)
def _after_play(self, conn: socket.socket, addr: tuple) -> None:
@@ -259,8 +323,12 @@ def _after_play(self, conn: socket.socket, addr: tuple) -> None:
for _ in range(self._num_packets):
if self._stop.is_set():
break
- rtp = make_rtp_packet(seq, ts)
- frame = b"\x24" + struct.pack("!BH", 0, len(rtp)) + rtp
+ if self._encapsulate_rtp:
+ payload = make_rtp_packet(seq, ts)
+ else:
+ # Bare MPEG-TS, one 1316-byte chunk (7 x 188) per frame.
+ payload = TS_NULL_PACKET * 7
+ frame = b"\x24" + struct.pack("!BH", 0, len(payload)) + payload
conn.sendall(frame)
seq = (seq + 1) & 0xFFFF
ts = (ts + 3600) & 0xFFFFFFFF
diff --git a/e2e/helpers/rtp.py b/e2e/helpers/rtp.py
index 0b1669ef..4f2085bb 100644
--- a/e2e/helpers/rtp.py
+++ b/e2e/helpers/rtp.py
@@ -10,7 +10,7 @@
from .ports import find_free_udp_port
# A minimal 188-byte MPEG-TS null packet (sync byte 0x47, PID 0x1FFF = null)
-_TS_NULL_PACKET = b"\x47\x1f\xff\x10" + b"\xff" * 184
+TS_NULL_PACKET = b"\x47\x1f\xff\x10" + b"\xff" * 184
def _make_ts_with_marker(marker: int) -> bytes:
@@ -27,7 +27,7 @@ def make_rtp_packet(
) -> bytes:
"""Create a 12-byte-header RTP packet carrying one TS null packet."""
if payload is None:
- payload = _TS_NULL_PACKET
+ payload = TS_NULL_PACKET
header = struct.pack(
"!BBHII",
0x80, # V=2, P=0, X=0, CC=0
@@ -56,6 +56,7 @@ def __init__(
reorder_distance: int = 0,
unique_payloads: bool = False,
send_duplicates: bool = False,
+ encapsulate_rtp: bool = True,
):
self.addr = addr
self.port = port or find_free_udp_port()
@@ -64,7 +65,8 @@ def __init__(
self.reorder_distance = reorder_distance
self.unique_payloads = unique_payloads
self.send_duplicates = send_duplicates
- self._payload = _TS_NULL_PACKET * ts_per_rtp
+ self.encapsulate_rtp = encapsulate_rtp
+ self._payload = TS_NULL_PACKET * ts_per_rtp
self._sock: socket.socket | None = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
@@ -92,7 +94,7 @@ def _loop(self) -> None:
payload = _make_ts_with_marker(seq) * self.ts_per_rtp
else:
payload = self._payload
- pkt = make_rtp_packet(seq, ts, payload=payload)
+ pkt = make_rtp_packet(seq, ts, payload=payload) if self.encapsulate_rtp else payload
if self.reorder_distance > 1:
buf.append(pkt)
diff --git a/e2e/test_config.py b/e2e/test_config.py
index 79dce61f..452c7274 100644
--- a/e2e/test_config.py
+++ b/e2e/test_config.py
@@ -195,7 +195,7 @@ def _assert_rtsp_user_agent(port: int, expected_user_agent: str):
rtsp = MockRTSPServer(custom_sdp=_RTSP_DURATION_SDP)
rtsp.start()
try:
- status, _, body = http_get(
+ status, headers, body = http_get(
"127.0.0.1",
port,
"/rtsp/127.0.0.1:%d/stream?r2h-duration=1" % rtsp.port,
@@ -203,6 +203,7 @@ def _assert_rtsp_user_agent(port: int, expected_user_agent: str):
)
assert status == 200, f"Expected 200, got {status}"
assert b'"duration"' in body, "Expected RTSP duration response"
+ assert not any(name.lower().startswith("r2h-") for name in headers)
option_reqs = [req for req in rtsp.requests_detailed if req["method"] == "OPTIONS"]
assert option_reqs, "Expected OPTIONS request"
diff --git a/e2e/test_fcc.py b/e2e/test_fcc.py
index dfd65668..bf890b54 100644
--- a/e2e/test_fcc.py
+++ b/e2e/test_fcc.py
@@ -19,6 +19,8 @@
find_free_port,
find_free_udp_port,
find_free_udp_port_pair,
+ get_header,
+ http_request,
stream_get,
)
@@ -60,7 +62,7 @@ def test_fcc_unicast_stream(self, shared_r2h):
)
fcc.start()
try:
- status, _, body = stream_get(
+ status, headers, body = stream_get(
"127.0.0.1",
shared_r2h.port,
f"/rtp/{MCAST_ADDR}:{mcast_port}?fcc=127.0.0.1:{fcc.port}",
@@ -71,6 +73,10 @@ def test_fcc_unicast_stream(self, shared_r2h):
assert len(body) > 0, "Expected to receive unicast stream data"
assert body[0] == 0x47, f"Expected TS sync byte 0x47, got 0x{body[0]:02x}"
assert fcc.requests_received >= 1
+ assert headers["r2h-upstream-protocol"] == "multicast"
+ assert headers["r2h-upstream-payload"] == "mp2t-rtp"
+ assert headers["r2h-fcc-type"] == "telecom"
+ assert headers["r2h-fcc-status"] == "active"
finally:
fcc.stop()
@@ -87,7 +93,7 @@ def test_fcc_single_socket_keeps_media_when_signal_port_changes(self, shared_r2h
)
fcc.start()
try:
- status, _, body = stream_get(
+ status, headers, body = stream_get(
"127.0.0.1",
shared_r2h.port,
f"/rtp/{MCAST_ADDR}:{mcast_port}?fcc=127.0.0.1:{fcc.port}",
@@ -98,6 +104,8 @@ def test_fcc_single_socket_keeps_media_when_signal_port_changes(self, shared_r2h
assert len(body) > 0, "Expected to receive unicast stream data"
assert body[0] == 0x47, f"Expected TS sync byte 0x47, got 0x{body[0]:02x}"
assert fcc.requests_received >= 1
+ assert headers["r2h-fcc-type"] == "telecom"
+ assert headers["r2h-fcc-status"] == "active"
finally:
fcc.stop()
@@ -214,7 +222,7 @@ def test_huawei_fcc_unicast_stream(self, shared_r2h):
fcc.start()
try:
url = f"/rtp/{MCAST_ADDR}:{mcast_port}?fcc=127.0.0.1:{fcc.port}&fcc-type=huawei"
- status, _, body = stream_get(
+ status, headers, body = stream_get(
"127.0.0.1",
shared_r2h.port,
url,
@@ -225,6 +233,8 @@ def test_huawei_fcc_unicast_stream(self, shared_r2h):
assert len(body) > 0, "Expected to receive unicast stream data"
assert body[0] == 0x47, f"Expected TS sync byte 0x47, got 0x{body[0]:02x}"
assert fcc.requests_received >= 1
+ assert headers["r2h-fcc-type"] == "huawei"
+ assert headers["r2h-fcc-status"] == "active"
finally:
fcc.stop()
@@ -417,7 +427,7 @@ def test_fcc_timeout_falls_back_to_multicast(self, shared_r2h):
# Use a port where no FCC server is listening
dead_fcc_port = find_free_udp_port()
try:
- status, _, body = stream_get(
+ status, headers, body = stream_get(
"127.0.0.1",
shared_r2h.port,
f"/rtp/{MCAST_ADDR}:{mcast_port}?fcc=127.0.0.1:{dead_fcc_port}",
@@ -426,5 +436,32 @@ def test_fcc_timeout_falls_back_to_multicast(self, shared_r2h):
)
assert status == 200
assert len(body) > 0, "Expected multicast fallback to deliver data"
+ assert headers["r2h-upstream-protocol"] == "multicast"
+ assert headers["r2h-upstream-payload"] == "mp2t-rtp"
+ assert headers["r2h-fcc-type"] == "telecom"
+ assert headers["r2h-fcc-status"] == "fallback"
finally:
sender.stop()
+
+ def test_fcc_head_returns_static_metadata_without_request(self, shared_r2h):
+ mcast_port = find_free_udp_port()
+ fcc = MockFCCServer(mcast_addr=MCAST_ADDR, protocol="huawei", sync_after=0)
+ fcc.start()
+ try:
+ status, headers, body = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ f"/rtp/{MCAST_ADDR}:{mcast_port}?fcc=127.0.0.1:{fcc.port}&fcc-type=huawei",
+ timeout=3.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert get_header(headers, "R2H-Upstream-Protocol") == "multicast"
+ assert get_header(headers, "R2H-FCC-Type") == "huawei"
+ assert get_header(headers, "R2H-Upstream-Payload") == ""
+ assert get_header(headers, "R2H-FCC-Status") == ""
+ time.sleep(0.1)
+ assert fcc.requests_received == 0
+ finally:
+ fcc.stop()
diff --git a/e2e/test_http_proxy.py b/e2e/test_http_proxy.py
index 9f9d2e9b..bc38f842 100644
--- a/e2e/test_http_proxy.py
+++ b/e2e/test_http_proxy.py
@@ -62,6 +62,7 @@ def test_proxy_200(self, shared_r2h):
)
assert status == 200
assert body == b"world"
+ assert not any(name.lower().startswith("r2h-") for name in hdrs)
finally:
upstream.stop()
diff --git a/e2e/test_multicast.py b/e2e/test_multicast.py
index dfe286fe..c60ae5f3 100644
--- a/e2e/test_multicast.py
+++ b/e2e/test_multicast.py
@@ -72,6 +72,29 @@ def test_rtp_stream_returns_200(self, multicast_r2h):
)
assert status == 200
assert len(body) > 0, "Expected to receive stream data"
+ assert hdrs["r2h-upstream-protocol"] == "multicast"
+ assert hdrs["r2h-upstream-payload"] == "mp2t-rtp"
+ assert "r2h-fec-status" not in hdrs
+ finally:
+ sender.stop()
+
+ def test_direct_ts_metadata(self, multicast_r2h):
+ """Raw MPEG-TS multicast should be identified without RTP."""
+ mcast_port = find_free_udp_port()
+ sender = MulticastSender(addr=MCAST_ADDR, port=mcast_port, pps=200, encapsulate_rtp=False)
+ sender.start()
+ try:
+ status, headers, body = stream_get(
+ "127.0.0.1",
+ multicast_r2h.port,
+ f"/rtp/{MCAST_ADDR}:{mcast_port}",
+ read_bytes=4096,
+ timeout=_MCAST_STREAM_TIMEOUT,
+ )
+ assert status == 200
+ assert body
+ assert headers["r2h-upstream-protocol"] == "multicast"
+ assert headers["r2h-upstream-payload"] == "mp2t-direct"
finally:
sender.stop()
@@ -217,7 +240,7 @@ def test_head_rtp(self, r2h_binary):
r2h = R2HProcess(
r2h_binary,
port,
- extra_args=["-v", "4", "-m", "100"],
+ extra_args=["-v", "4", "-m", "100", "-O", "*"],
)
try:
r2h.start()
@@ -232,10 +255,58 @@ def test_head_rtp(self, r2h_binary):
)
assert status == 200
assert len(body) == 0 # HEAD should have empty body
+ assert hdrs.get("R2H-Upstream-Protocol") == "multicast"
+ assert "R2H-Upstream-Payload" not in hdrs
+ assert "R2H-FCC-Status" not in hdrs
+ exposed = {name.strip() for name in hdrs["Access-Control-Expose-Headers"].split(",")}
+ assert exposed == {
+ "R2H-Upstream-Protocol",
+ "R2H-Upstream-Transport",
+ "R2H-Upstream-Payload",
+ "R2H-Playback-Scale",
+ "R2H-Playback-Range",
+ "R2H-Media-Duration",
+ "R2H-FCC-Type",
+ "R2H-FCC-Status",
+ }
finally:
r2h.stop()
+class TestSnapshotFallbackMetadata:
+ """A failed JPEG capture must fall back to one MPEG-TS response."""
+
+ def test_snapshot_fallback_uses_stream_metadata(self, r2h_binary):
+ port = find_free_port()
+ mcast_port = find_free_udp_port()
+ r2h = R2HProcess(
+ r2h_binary,
+ port,
+ extra_args=["-v", "4", "-m", "100", "-r", LOOPBACK_IF, "-S"],
+ )
+ sender = MulticastSender(addr=MCAST_ADDR, port=mcast_port, pps=300)
+ try:
+ r2h.start()
+ sender.start()
+ status, headers, body = stream_get(
+ "127.0.0.1",
+ port,
+ f"/rtp/{MCAST_ADDR}:{mcast_port}",
+ read_bytes=4096,
+ timeout=_MCAST_STREAM_TIMEOUT,
+ headers={"Accept": "image/jpeg"},
+ )
+ assert status == 200
+ assert body
+ assert headers["content-type"] == "video/mp2t"
+ assert headers["r2h-upstream-protocol"] == "multicast"
+ assert headers["r2h-upstream-payload"] == "mp2t-rtp"
+ assert "content-length" not in headers
+ finally:
+ sender.stop()
+ r2h.stop()
+
+
# ---------------------------------------------------------------------------
# RTP reorder
# ---------------------------------------------------------------------------
diff --git a/e2e/test_rtsp_misc.py b/e2e/test_rtsp_misc.py
index 53a04c42..a17dd3d1 100644
--- a/e2e/test_rtsp_misc.py
+++ b/e2e/test_rtsp_misc.py
@@ -97,15 +97,16 @@ def shared_r2h(r2h_binary):
class TestRTSPHeadRequest:
- def test_head_rtsp(self, shared_r2h):
+ def test_head_rtsp_unreachable_returns_503(self, shared_r2h):
+ dead_port = find_free_port()
status, _, body = http_request(
"127.0.0.1",
shared_r2h.port,
"HEAD",
- "/rtsp/127.0.0.1:554/test",
+ "/rtsp/127.0.0.1:%d/test" % dead_port,
timeout=3.0,
)
- assert status == 200
+ assert status == 503
assert len(body) == 0
diff --git a/e2e/test_rtsp_stun.py b/e2e/test_rtsp_stun.py
index a049341c..c4823449 100644
--- a/e2e/test_rtsp_stun.py
+++ b/e2e/test_rtsp_stun.py
@@ -20,6 +20,7 @@
MockRTSPServer,
R2HProcess,
find_free_port,
+ http_request,
stream_get,
)
from helpers.mock_stun import MockSTUNServer
@@ -29,6 +30,38 @@
_STREAM_TIMEOUT = 20.0
+class TestHeadProbeSkipsSTUN:
+ def test_head_does_not_allocate_media_transport(self, r2h_binary):
+ stun = MockSTUNServer(mapped_port=50000)
+ rtsp = MockRTSPServer()
+ stun.start()
+ rtsp.start()
+ r2h_port = find_free_port()
+ r2h = R2HProcess(
+ r2h_binary,
+ r2h_port,
+ extra_args=["-v", "4", "-m", "100", "-N", "127.0.0.1:%d" % stun.port],
+ )
+ r2h.start()
+ try:
+ status, _, body = http_request(
+ "127.0.0.1",
+ r2h_port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ timeout=8.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert rtsp.requests_received == ["OPTIONS", "DESCRIBE"]
+ time.sleep(0.1)
+ assert stun.requests_received == 0
+ finally:
+ r2h.stop()
+ rtsp.stop()
+ stun.stop()
+
+
class TestSTUNMappedPort:
"""When STUN succeeds, the mapped port should appear in SETUP."""
diff --git a/e2e/test_rtsp_transport.py b/e2e/test_rtsp_transport.py
index cadf76dd..c6270166 100644
--- a/e2e/test_rtsp_transport.py
+++ b/e2e/test_rtsp_transport.py
@@ -9,9 +9,13 @@
from helpers import (
MockRTSPServer,
+ MockRTSPServerSilent,
MockRTSPServerUDP,
R2HProcess,
find_free_port,
+ get_header,
+ http_request,
+ raw_http_request,
stream_get,
)
@@ -54,6 +58,380 @@ def test_tcp_stream_returns_200(self, shared_r2h):
finally:
rtsp.stop()
+ def test_tcp_stream_metadata_headers(self, shared_r2h):
+ sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 RTP/AVP 33\r\n"
+ "a=range:npt=10.25-40.5\r\na=control:*\r\n"
+ )
+ rtsp = MockRTSPServer(
+ num_packets=500,
+ custom_sdp=sdp,
+ play_response_headers=[("sCaLe", "1.500000"), ("rAnGe", "npt=10.25-40.5")],
+ )
+ rtsp.start()
+ try:
+ status, headers, body = stream_get(
+ "127.0.0.1",
+ shared_r2h.port,
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ read_bytes=4096,
+ timeout=_STREAM_TIMEOUT,
+ )
+ assert status == 200
+ assert body
+ assert headers["r2h-upstream-protocol"] == "rtsp"
+ assert headers["r2h-upstream-transport"] == "tcp-interleaved"
+ assert headers["r2h-upstream-payload"] == "mp2t-rtp"
+ assert headers["r2h-playback-scale"] == "1.5"
+ assert headers["r2h-playback-range"] == "npt=10.25-40.5"
+ assert headers["r2h-media-duration"] == "30.25"
+ assert "r2h-metadata-version" not in headers
+ assert not any(name.startswith("r2h-fec-") for name in headers)
+ assert "session" not in headers
+ assert "content-base" not in headers
+ finally:
+ rtsp.stop()
+
+ def test_head_stops_after_describe(self, shared_r2h):
+ sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 RTP/AVP 33\r\n"
+ "a=range:npt=2-12.125\r\na=control:*\r\n"
+ )
+ rtsp = MockRTSPServer(custom_sdp=sdp)
+ rtsp.start()
+ try:
+ status, headers, body = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert get_header(headers, "R2H-Upstream-Protocol") == "rtsp"
+ assert get_header(headers, "R2H-Upstream-Payload") == "mp2t-rtp"
+ assert get_header(headers, "R2H-Media-Duration") == "10.125"
+ assert get_header(headers, "R2H-Upstream-Transport") == ""
+ assert get_header(headers, "R2H-Playback-Scale") == ""
+ assert get_header(headers, "R2H-Playback-Range") == ""
+ assert rtsp.requests_received == ["OPTIONS", "DESCRIBE"]
+ finally:
+ rtsp.stop()
+
+ def test_head_parses_clock_form_npt_duration(self, shared_r2h):
+ sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 RTP/AVP 33\r\n"
+ "a=range:npt=00:00:10.25-01:30:40.75\r\na=control:*\r\n"
+ )
+ rtsp = MockRTSPServer(custom_sdp=sdp)
+ rtsp.start()
+ try:
+ status, headers, body = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert get_header(headers, "R2H-Media-Duration") == "5430.5"
+ assert rtsp.requests_received == ["OPTIONS", "DESCRIBE"]
+ finally:
+ rtsp.stop()
+
+ def test_head_metadata_takes_precedence_over_duration_query(self, shared_r2h):
+ sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 RTP/AVP 33\r\n"
+ "a=range:npt=2-12.125\r\na=control:*\r\n"
+ )
+ rtsp = MockRTSPServer(custom_sdp=sdp)
+ rtsp.start()
+ try:
+ status, headers, body = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream?r2h-duration=1" % rtsp.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert get_header(headers, "Content-Type") == "video/mp2t"
+ assert get_header(headers, "R2H-Upstream-Protocol") == "rtsp"
+ assert get_header(headers, "R2H-Upstream-Payload") == "mp2t-rtp"
+ assert get_header(headers, "R2H-Media-Duration") == "10.125"
+ assert rtsp.requests_received == ["OPTIONS", "DESCRIBE"]
+ finally:
+ rtsp.stop()
+
+ def test_head_survives_upstream_close_after_describe(self, shared_r2h):
+ rtsp = MockRTSPServer(close_after_describe=True)
+ rtsp.start()
+ try:
+ status, headers, body = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert get_header(headers, "R2H-Upstream-Protocol") == "rtsp"
+ assert get_header(headers, "R2H-Upstream-Payload") == "mp2t-rtp"
+ assert rtsp.requests_received == ["OPTIONS", "DESCRIBE"]
+ finally:
+ rtsp.stop()
+
+ def test_head_survives_upstream_reset_after_describe(self, shared_r2h):
+ rtsp = MockRTSPServer(reset_after_describe=True)
+ rtsp.start()
+ try:
+ status, headers, body = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert get_header(headers, "R2H-Upstream-Protocol") == "rtsp"
+ assert get_header(headers, "R2H-Upstream-Payload") == "mp2t-rtp"
+ assert rtsp.requests_received == ["OPTIONS", "DESCRIBE"]
+ finally:
+ rtsp.stop()
+
+ def test_head_probe_timeout_returns_503(self, shared_r2h):
+ rtsp = MockRTSPServerSilent()
+ rtsp.start()
+ try:
+ # raw_http_request, not http_request: http.client discards a HEAD
+ # body, which would hide a spec violation here.
+ status, _, body = raw_http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ timeout=8.0,
+ )
+ assert status == 503
+ assert body == b"", "a HEAD response must not carry content"
+ finally:
+ rtsp.stop()
+
+ def test_head_unreachable_upstream_sends_no_body(self, shared_r2h):
+ dead_port = find_free_port()
+ status, _, body = raw_http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % dead_port,
+ timeout=8.0,
+ )
+ assert status == 503
+ assert body == b"", "a HEAD response must not carry content"
+
+ def test_get_error_still_sends_body(self, shared_r2h):
+ """The HEAD carve-out must not strip bodies from ordinary GET errors."""
+ dead_port = find_free_port()
+ status, _, body = raw_http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "GET",
+ "/rtsp/127.0.0.1:%d/stream" % dead_port,
+ timeout=8.0,
+ )
+ assert status == 503
+ assert b"503" in body
+
+ def test_head_omits_unrepresentable_duration(self, shared_r2h):
+ """A duration we cannot render exactly is dropped, never approximated."""
+ sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 RTP/AVP 33\r\n"
+ "a=range:npt=0-1e300\r\na=control:*\r\n"
+ )
+ rtsp = MockRTSPServer(custom_sdp=sdp)
+ rtsp.start()
+ try:
+ status, headers, _ = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert get_header(headers, "R2H-Upstream-Protocol") == "rtsp"
+ assert get_header(headers, "R2H-Media-Duration") == ""
+ finally:
+ rtsp.stop()
+
+ def test_head_omits_open_ended_npt_range(self, shared_r2h):
+ """Live streams advertise `npt=now-`, which has no finite duration."""
+ sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 RTP/AVP 33\r\n"
+ "a=range:npt=now-\r\na=control:*\r\n"
+ )
+ rtsp = MockRTSPServer(custom_sdp=sdp)
+ rtsp.start()
+ try:
+ status, headers, _ = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert get_header(headers, "R2H-Media-Duration") == ""
+ finally:
+ rtsp.stop()
+
+ def test_head_after_auth_challenge_reports_retry_metadata(self, shared_r2h):
+ """A 401 clears the DESCRIBE stage; the retry must refill it."""
+ sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 RTP/AVP 33\r\n"
+ "a=range:npt=0-42.5\r\na=control:*\r\n"
+ )
+ rtsp = MockRTSPServer(custom_sdp=sdp, challenge_describe_once=True)
+ rtsp.start()
+ try:
+ status, headers, body = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/user:pass@127.0.0.1:%d/stream" % rtsp.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert get_header(headers, "R2H-Upstream-Payload") == "mp2t-rtp"
+ assert get_header(headers, "R2H-Media-Duration") == "42.5"
+ assert rtsp.requests_received == ["OPTIONS", "DESCRIBE", "DESCRIBE"]
+ finally:
+ rtsp.stop()
+
+ def test_head_redirect_reports_only_final_server_metadata(self, shared_r2h):
+ """Metadata learned before a redirect must not leak into the response."""
+ target_sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 MP2T/AVP 33\r\n"
+ "a=range:npt=0-7.5\r\na=control:*\r\n"
+ )
+ target = MockRTSPServer(custom_sdp=target_sdp)
+ target.start()
+ # The first server advertises RTP-encapsulated TS and a 900s range;
+ # neither may survive the 302 to a server that advertises neither.
+ source_sdp = (
+ "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\n"
+ "t=0 0\r\nm=video 0 RTP/AVP 33\r\n"
+ "a=range:npt=0-900\r\na=control:*\r\n"
+ )
+ redirect = MockRTSPServer(
+ custom_sdp=source_sdp,
+ redirect_describe_to="rtsp://127.0.0.1:%d/stream" % target.port,
+ )
+ redirect.start()
+ try:
+ status, headers, body = http_request(
+ "127.0.0.1",
+ shared_r2h.port,
+ "HEAD",
+ "/rtsp/127.0.0.1:%d/stream" % redirect.port,
+ timeout=10.0,
+ )
+ assert status == 200
+ assert body == b""
+ assert get_header(headers, "R2H-Upstream-Protocol") == "rtsp"
+ assert get_header(headers, "R2H-Media-Duration") == "7.5"
+ # MP2T/AVP is not RTP-encapsulated, and a HEAD sees no media, so the
+ # payload must be absent rather than the pre-redirect mp2t-rtp.
+ assert get_header(headers, "R2H-Upstream-Payload") == ""
+ assert target.requests_received == ["OPTIONS", "DESCRIBE"]
+ finally:
+ redirect.stop()
+ target.stop()
+
+ def test_stream_ignores_invalid_play_metadata(self, shared_r2h):
+ """Unparsable Scale / control-character Range are dropped, not echoed."""
+ rtsp = MockRTSPServer(
+ num_packets=500,
+ play_response_headers=[("Scale", "fast"), ("Range", "npt=0-\x0130")],
+ )
+ rtsp.start()
+ try:
+ status, headers, body = stream_get(
+ "127.0.0.1",
+ shared_r2h.port,
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ read_bytes=4096,
+ timeout=_STREAM_TIMEOUT,
+ )
+ assert status == 200
+ assert body
+ assert headers["r2h-upstream-transport"] == "tcp-interleaved"
+ assert "r2h-playback-scale" not in headers
+ assert "r2h-playback-range" not in headers
+ finally:
+ rtsp.stop()
+
+ def test_stream_reports_direct_ts_payload(self, shared_r2h):
+ """An MP2T/TCP upstream sends bare TS in the interleaved frames."""
+ sdp = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\nt=0 0\r\nm=video 0 MP2T/AVP 33\r\na=control:*\r\n"
+ rtsp = MockRTSPServer(
+ num_packets=500,
+ custom_sdp=sdp,
+ encapsulate_rtp=False,
+ setup_transport="MP2T/TCP;unicast;interleaved=0-1",
+ )
+ rtsp.start()
+ try:
+ status, headers, body = stream_get(
+ "127.0.0.1",
+ shared_r2h.port,
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ read_bytes=4096,
+ timeout=_STREAM_TIMEOUT,
+ )
+ assert status == 200
+ assert body
+ assert body[0] == 0x47, "expected raw MPEG-TS to be relayed unchanged"
+ assert headers["r2h-upstream-protocol"] == "rtsp"
+ assert headers["r2h-upstream-transport"] == "tcp-interleaved"
+ assert headers["r2h-upstream-payload"] == "mp2t-direct"
+ assert "access-control-expose-headers" not in headers
+ finally:
+ rtsp.stop()
+
+ def test_stream_sdp_alone_does_not_decide_payload(self, shared_r2h):
+ """A non-RTP SDP must not veto mp2t-rtp when the media is RTP after all."""
+ sdp = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=T\r\nt=0 0\r\nm=video 0 MP2T/AVP 33\r\na=control:*\r\n"
+ rtsp = MockRTSPServer(num_packets=500, custom_sdp=sdp)
+ rtsp.start()
+ try:
+ status, headers, body = stream_get(
+ "127.0.0.1",
+ shared_r2h.port,
+ "/rtsp/127.0.0.1:%d/stream" % rtsp.port,
+ read_bytes=4096,
+ timeout=_STREAM_TIMEOUT,
+ )
+ assert status == 200
+ assert body
+ assert headers["r2h-upstream-payload"] == "mp2t-rtp"
+ finally:
+ rtsp.stop()
+
def test_tcp_data_is_ts(self, shared_r2h):
"""Relayed data should be raw MPEG-TS (RTP headers stripped)."""
rtsp = MockRTSPServer(num_packets=500)
@@ -129,7 +507,7 @@ def test_udp_stream_returns_200(self, shared_r2h):
rtsp = MockRTSPServerUDP()
rtsp.start()
try:
- status, _, body = stream_get(
+ status, headers, body = stream_get(
"127.0.0.1",
shared_r2h.port,
"/rtsp/127.0.0.1:%d/stream" % rtsp.port,
@@ -138,6 +516,9 @@ def test_udp_stream_returns_200(self, shared_r2h):
)
assert status == 200, "Expected 200 for UDP RTSP"
assert len(body) > 0
+ assert headers["r2h-upstream-protocol"] == "rtsp"
+ assert headers["r2h-upstream-transport"] == "udp"
+ assert headers["r2h-upstream-payload"] == "mp2t-rtp"
finally:
rtsp.stop()
diff --git a/src/connection.c b/src/connection.c
index 0f5b29ce..7f1e3348 100644
--- a/src/connection.c
+++ b/src/connection.c
@@ -1073,22 +1073,37 @@ int connection_route_and_start(connection_t *c) {
return 0;
}
- /* Handle HEAD requests for RTP/RTSP streams - return success without
- * connecting upstream. HTTP services forward HEAD to the upstream server
- * so the real Content-Type (e.g. application/vnd.apple.mpegurl for HLS)
- * is returned to the client. */
+ if (c->http_req.user_agent[0]) {
+ service->user_agent = strdup(c->http_req.user_agent);
+ }
+
+ /* HTTP services forward HEAD upstream unchanged. Multicast HEAD requests
+ * return only static metadata. RTSP HEAD performs an asynchronous
+ * OPTIONS/DESCRIBE probe without opening media resources. */
if (strcasecmp(c->http_req.method, "HEAD") == 0 && service->service_type != SERVICE_HTTP) {
- logger(LOG_INFO, "HEAD request detected, returning success without upstream connection");
- send_http_headers(c, STATUS_200, "video/mp2t", NULL);
+ if (service->service_type == SERVICE_RTSP) {
+ logger(LOG_INFO, "RTSP HEAD request detected, starting metadata probe");
+ if (stream_context_init_rtsp_metadata_probe(&c->stream, c, service, c->epfd) == 0) {
+ c->streaming = 1;
+ c->service = service;
+ c->state = CONN_STREAMING;
+ return 0;
+ }
+
+ stream_context_cleanup(&c->stream);
+ http_send_503(c);
+ service_free(service);
+ return 0;
+ }
+
+ logger(LOG_INFO, "Multicast HEAD request detected, returning static metadata");
+ stream_metadata_init(&c->stream.metadata, service);
+ stream_send_http_headers(c, "video/mp2t", NULL);
connection_queue_output_and_flush(c, NULL, 0);
service_free(service);
return 0;
}
- if (c->http_req.user_agent[0]) {
- service->user_agent = strdup(c->http_req.user_agent);
- }
-
/* Check if this is a snapshot request (X-Request-Snapshot, Accept:
* image/jpeg, or snapshot=1) */
/* 1 = snapshot=1, 2 = X-Request-Snapshot or Accept: image/jpeg */
diff --git a/src/fcc.c b/src/fcc.c
index 1bb4366e..63d1db87 100644
--- a/src/fcc.c
+++ b/src/fcc.c
@@ -662,7 +662,7 @@ int fcc_handle_unicast_media(stream_context_t *ctx, buffer_ref_t *buf_ref) {
}
/* Forward RTP payload to client (with reordering) */
- stream_process_rtp_payload(ctx, buf_ref);
+ stream_process_rtp_payload(ctx, buf_ref, STREAM_MEDIA_ORIGIN_FCC_UNICAST);
/* Check if we should terminate FCC based on reorder's delivered sequence.
* base_seq - 1 is the last sequence number successfully delivered.
@@ -765,7 +765,7 @@ int fcc_handle_mcast_active(stream_context_t *ctx, buffer_ref_t *buf_ref) {
while (node) {
/* Queue each buffer for zero-copy send */
buffer_ref_t *next = node->send_next;
- int processed_bytes = stream_process_rtp_payload(ctx, node);
+ int processed_bytes = stream_process_rtp_payload(ctx, node, STREAM_MEDIA_ORIGIN_FCC_MULTICAST);
if (likely(processed_bytes > 0)) {
flushed_bytes += (uint64_t)processed_bytes;
}
@@ -782,7 +782,7 @@ int fcc_handle_mcast_active(stream_context_t *ctx, buffer_ref_t *buf_ref) {
/* Forward multicast data to client (true zero-copy) or capture I-frame
* (snapshot) */
- stream_process_rtp_payload(ctx, buf_ref);
+ stream_process_rtp_payload(ctx, buf_ref, STREAM_MEDIA_ORIGIN_FCC_MULTICAST);
return 0;
}
diff --git a/src/http.c b/src/http.c
index 8084cc75..6576dfca 100644
--- a/src/http.c
+++ b/src/http.c
@@ -735,54 +735,49 @@ int http_filter_query_param(const char *query_string, const char *exclude_param,
return (int)out_len;
}
+/**
+ * Send a short error response. A response to HEAD carries the same headers as
+ * the equivalent GET but must not include content (RFC 9110 9.3.2), so the body
+ * is dropped in that case.
+ */
+static void http_send_error(connection_t *conn, http_status_t status, const char *extra_headers, const char *body,
+ size_t body_len) {
+ send_http_headers(conn, status, "text/html; charset=utf-8", extra_headers);
+
+ if (conn && strcasecmp(conn->http_req.method, "HEAD") == 0)
+ body_len = 0;
+
+ connection_queue_output_and_flush(conn, body_len ? (const uint8_t *)body : NULL, body_len);
+}
+
void http_send_400(connection_t *conn) {
static const char body[] = "
400Bad Request";
- /* Send headers */
- send_http_headers(conn, STATUS_400, "text/html; charset=utf-8", NULL);
-
- /* Send body and flush */
- connection_queue_output_and_flush(conn, (const uint8_t *)body, sizeof(body) - 1);
+ http_send_error(conn, STATUS_400, NULL, body, sizeof(body) - 1);
}
void http_send_404(connection_t *conn) {
static const char body[] = "404Not Found";
- /* Send headers */
- send_http_headers(conn, STATUS_404, "text/html; charset=utf-8", NULL);
-
- /* Send body and flush */
- connection_queue_output_and_flush(conn, (const uint8_t *)body, sizeof(body) - 1);
+ http_send_error(conn, STATUS_404, NULL, body, sizeof(body) - 1);
}
void http_send_500(connection_t *conn) {
static const char body[] = "500Internal Server Error";
- /* Send headers */
- send_http_headers(conn, STATUS_500, "text/html; charset=utf-8", NULL);
-
- /* Send body and flush */
- connection_queue_output_and_flush(conn, (const uint8_t *)body, sizeof(body) - 1);
+ http_send_error(conn, STATUS_500, NULL, body, sizeof(body) - 1);
}
void http_send_503(connection_t *conn) {
static const char body[] = "503Service Unavailable";
- /* Send headers */
- send_http_headers(conn, STATUS_503, "text/html; charset=utf-8", NULL);
-
- /* Send body and flush */
- connection_queue_output_and_flush(conn, (const uint8_t *)body, sizeof(body) - 1);
+ http_send_error(conn, STATUS_503, NULL, body, sizeof(body) - 1);
}
void http_send_401(connection_t *conn) {
static const char body[] = "401Unauthorized";
- /* Send headers with WWW-Authenticate */
- send_http_headers(conn, STATUS_401, "text/html; charset=utf-8", "WWW-Authenticate: Bearer\r\n");
-
- /* Send body and flush */
- connection_queue_output_and_flush(conn, (const uint8_t *)body, sizeof(body) - 1);
+ http_send_error(conn, STATUS_401, "WWW-Authenticate: Bearer\r\n", body, sizeof(body) - 1);
}
int http_parse_url_components(const char *url, char *protocol, char *host, char *port, char *path) {
diff --git a/src/multicast.c b/src/multicast.c
index 64896fd5..0f4488f6 100644
--- a/src/multicast.c
+++ b/src/multicast.c
@@ -482,7 +482,7 @@ int mcast_session_handle_event(mcast_session_t *session, stream_context_t *ctx,
/* Handle based on FCC state (if FCC initialized) */
if (!ctx->fcc.initialized) {
/* Direct multicast without FCC - forward to client */
- stream_process_rtp_payload(ctx, recv_buf);
+ stream_process_rtp_payload(ctx, recv_buf, STREAM_MEDIA_ORIGIN_MULTICAST);
buffer_ref_put(recv_buf);
continue; /* Read next packet */
}
diff --git a/src/rtp.c b/src/rtp.c
index 3cf11129..31c287f5 100644
--- a/src/rtp.c
+++ b/src/rtp.c
@@ -87,7 +87,7 @@ int rtp_get_payload(uint8_t *buf, int recv_len, uint8_t **payload, int *size, ui
int rtp_queue_buf_direct(connection_t *conn, buffer_ref_t *buf_ref) {
/* Send headers lazily on first data packet */
if (!conn->headers_sent) {
- send_http_headers(conn, STATUS_200, "video/mp2t", NULL);
+ stream_send_http_headers(conn, "video/mp2t", NULL);
}
/* Queue for zero-copy send */
diff --git a/src/rtp_reorder.c b/src/rtp_reorder.c
index fb0cd495..81ec69d4 100644
--- a/src/rtp_reorder.c
+++ b/src/rtp_reorder.c
@@ -83,7 +83,7 @@ static int deliver_raw_packet(uint8_t *data, int len, connection_t *conn, int is
}
/* Send headers lazily on first data packet (same as rtp_queue_buf_direct) */
if (!conn->headers_sent) {
- send_http_headers(conn, STATUS_200, "video/mp2t", NULL);
+ stream_send_http_headers(conn, "video/mp2t", NULL);
}
return connection_queue_output(conn, data, len) == 0 ? len : -1;
}
diff --git a/src/rtsp.c b/src/rtsp.c
index 4e9a111d..ace92e3a 100644
--- a/src/rtsp.c
+++ b/src/rtsp.c
@@ -12,13 +12,16 @@
#include "stream.h"
#include "utils.h"
#include "worker.h"
+#include
#include
+#include
#include
#include
#include
#include
#include
#include
+#include
#include
#include
@@ -36,6 +39,7 @@ static const char rtsp_default_user_agent[] = "rtp2httpd/" VERSION;
#define RTSP_RESPONSE_ADVANCE 1
#define RTSP_RESPONSE_KEEPALIVE 2
#define RTSP_RESPONSE_DURATION 3
+#define RTSP_RESPONSE_METADATA_READY 4
#define RTSP_RESPONSE_ERROR -1
/* Helper function prototypes */
static int rtsp_prepare_request(rtsp_session_t *session, const char *method, const char *url,
@@ -52,6 +56,7 @@ static void rtsp_send_udp_nat_probe(rtsp_session_t *session);
static int rtsp_process_interleaved_buffer(rtsp_session_t *session, connection_t *conn);
static int rtsp_handle_redirect(rtsp_session_t *session, const char *location);
static void rtsp_parse_describe_sdp(rtsp_session_t *session, const char *header_start, const char *sdp_body);
+static void rtsp_parse_play_metadata(rtsp_session_t *session, const char *response);
static int rtsp_initiate_teardown(rtsp_session_t *session);
static int rtsp_reconnect_for_teardown(rtsp_session_t *session);
static void rtsp_force_cleanup(rtsp_session_t *session);
@@ -60,6 +65,18 @@ static int rtsp_parse_www_authenticate(rtsp_session_t *session, const char *www_
static void rtsp_build_digest_response(rtsp_session_t *session, const char *method, const char *uri, char *response_out,
size_t response_size);
static int rtsp_build_basic_auth_header(rtsp_session_t *session, char *output, size_t output_size);
+static int rtsp_handle_terminal_socket_event(rtsp_session_t *session, uint32_t events);
+
+/**
+ * Return the metadata block this session should update, or NULL when there is
+ * nothing to update (no client connection, or the response headers have
+ * already been sent and the snapshot is therefore final).
+ */
+static stream_metadata_t *rtsp_metadata(const rtsp_session_t *session) {
+ if (!session || !session->conn || session->conn->stream.metadata.frozen)
+ return NULL;
+ return &session->conn->stream.metadata;
+}
static const char *rtsp_get_user_agent(void) {
if (config.rtsp_user_agent && config.rtsp_user_agent[0] != '\0') {
@@ -280,6 +297,9 @@ void rtsp_session_init(rtsp_session_t *session) {
session->use_playseek_range = 0;
session->r2h_duration = 0;
session->r2h_duration_value = -1;
+ session->metadata_probe = 0;
+ session->peer_closed = 0;
+ session->connect_generation = 0;
/* Initialize transport parameters - mode will be negotiated during SETUP */
session->transport_mode = RTSP_TRANSPORT_TCP; /* Default preference */
@@ -824,6 +844,11 @@ int rtsp_connect(rtsp_session_t *session) {
char port_str[16];
int gai_result;
+ /* A fresh control connection: any close seen on the previous one (redirect,
+ * TEARDOWN reconnect) no longer applies. */
+ session->peer_closed = 0;
+ session->connect_generation++;
+
/* Resolve hostname first (dual-stack: IPv6 and IPv4 candidates), so the
* upstream address family is known before creating UDP/STUN sockets */
rtsp_free_connect_results(session);
@@ -849,8 +874,8 @@ int rtsp_connect(rtsp_session_t *session) {
* This allows STUN to run in parallel with TCP connection establishment
* Only do this on initial connect (not on redirect or reconnect for TEARDOWN)
* Check: UDP socket not yet created and STUN not already in progress/completed */
- if (config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0' && session->rtp_socket < 0 &&
- !session->stun.in_progress && !session->stun.completed) {
+ if (!session->metadata_probe && config.rtsp_stun_server && config.rtsp_stun_server[0] != '\0' &&
+ session->rtp_socket < 0 && !session->stun.in_progress && !session->stun.completed) {
if (rtsp_setup_udp_sockets(session) == 0) {
if (stun_send_request(&session->stun, session->rtp_socket) == 0) {
logger(LOG_DEBUG, "RTSP: Started STUN discovery before TCP connect");
@@ -861,6 +886,50 @@ int rtsp_connect(rtsp_session_t *session) {
return rtsp_try_next_candidate(session);
}
+/**
+ * Handle a terminal condition (peer close or socket error) on the control
+ * socket. Called either directly from the event handler, or after the read
+ * path has parsed a final response that arrived together with the close.
+ * @return the value rtsp_handle_socket_event() should propagate.
+ */
+static int rtsp_handle_terminal_socket_event(rtsp_session_t *session, uint32_t events) {
+ if (events & POLLER_ERR) {
+ int sock_error = 0;
+ socklen_t error_len = sizeof(sock_error);
+ if (getsockopt(session->socket, SOL_SOCKET, SO_ERROR, &sock_error, &error_len) == 0 && sock_error != 0) {
+ logger(LOG_ERROR, "RTSP: Socket error: %s", strerror(sock_error));
+ } else {
+ logger(LOG_ERROR, "RTSP: Socket error event received");
+ }
+ } else {
+ logger(LOG_INFO, "RTSP: Server closed connection");
+ }
+
+ /* If we're in TEARDOWN states, server closing connection is acceptable
+ * (some servers don't send TEARDOWN response before closing) */
+ if (session->state == RTSP_STATE_SENDING_TEARDOWN || session->state == RTSP_STATE_AWAITING_TEARDOWN) {
+ logger(LOG_DEBUG, "RTSP: Server closed connection during TEARDOWN (acceptable)");
+ rtsp_force_cleanup(session);
+ return STREAM_EVENT_CLOSE;
+ }
+
+ /* During PLAYING: upstream is done — drain pending client output
+ * before disconnecting regardless of error/hangup distinction. */
+ if (session->state == RTSP_STATE_PLAYING) {
+ logger(LOG_INFO, "RTSP: Upstream closed during PLAYING, draining client");
+ rtsp_force_cleanup(session);
+ if (session->conn && session->conn->state != CONN_CLOSING) {
+ session->conn->state = CONN_CLOSING;
+ connection_epoll_update_events(session->conn->epfd, session->conn->fd,
+ POLLER_IN | POLLER_OUT | POLLER_RDHUP | POLLER_HUP | POLLER_ERR);
+ }
+ return STREAM_EVENT_OK;
+ }
+
+ rtsp_session_set_state(session, RTSP_STATE_ERROR);
+ return STREAM_EVENT_CLOSE; /* Connection closed or error */
+}
+
int rtsp_handle_socket_event(rtsp_session_t *session, uint32_t events) {
int result;
@@ -926,43 +995,15 @@ int rtsp_handle_socket_event(rtsp_session_t *session, uint32_t events) {
/* Now pending_request is ready, will be sent when POLLER_OUT fires */
}
- /* Check for connection errors or hangup */
- else if (events & (POLLER_HUP | POLLER_ERR | POLLER_RDHUP)) {
- if (events & POLLER_ERR) {
- int sock_error = 0;
- socklen_t error_len = sizeof(sock_error);
- if (getsockopt(session->socket, SOL_SOCKET, SO_ERROR, &sock_error, &error_len) == 0 && sock_error != 0) {
- logger(LOG_ERROR, "RTSP: Socket error: %s", strerror(sock_error));
- } else {
- logger(LOG_ERROR, "RTSP: Socket error event received");
- }
- } else if (events & (POLLER_HUP | POLLER_RDHUP)) {
- logger(LOG_INFO, "RTSP: Server closed connection");
- }
-
- /* If we're in TEARDOWN states, server closing connection is acceptable
- * (some servers don't send TEARDOWN response before closing) */
- if (session->state == RTSP_STATE_SENDING_TEARDOWN || session->state == RTSP_STATE_AWAITING_TEARDOWN) {
- logger(LOG_DEBUG, "RTSP: Server closed connection during TEARDOWN (acceptable)");
- rtsp_force_cleanup(session);
- return -1;
- }
-
- /* During PLAYING: upstream is done — drain pending client output
- * before disconnecting regardless of error/hangup distinction. */
- if (session->state == RTSP_STATE_PLAYING) {
- logger(LOG_INFO, "RTSP: Upstream closed during PLAYING, draining client");
- rtsp_force_cleanup(session);
- if (session->conn && session->conn->state != CONN_CLOSING) {
- session->conn->state = CONN_CLOSING;
- connection_epoll_update_events(session->conn->epfd, session->conn->fd,
- POLLER_IN | POLLER_OUT | POLLER_RDHUP | POLLER_HUP | POLLER_ERR);
- }
- return 0;
- }
-
- rtsp_session_set_state(session, RTSP_STATE_ERROR);
- return -1; /* Connection closed or error */
+ /* Check for connection errors or hangup. When the socket is also readable
+ * with a response outstanding, drain it first: a peer can deliver its final
+ * response and EOF in a single edge-triggered event, and that response may be
+ * exactly what completes the exchange (RTSP HEAD probes routinely see the
+ * DESCRIBE response and FIN together). The read path below reports the
+ * close through this same helper once the response has been parsed. */
+ else if ((events & (POLLER_HUP | POLLER_ERR | POLLER_RDHUP)) &&
+ !(session->awaiting_response && (events & POLLER_IN))) {
+ return rtsp_handle_terminal_socket_event(session, events);
}
/* Handle writable socket - try to send pending data */
@@ -996,15 +1037,41 @@ int rtsp_handle_socket_event(rtsp_session_t *session, uint32_t events) {
/* Handle readable socket - try to receive response */
if (events & POLLER_IN) {
if (session->awaiting_response) {
- int response_result = rtsp_try_receive_response(session);
+ int response_result;
+ size_t previous_buffer_pos;
+ unsigned connect_generation = session->connect_generation;
+ do {
+ previous_buffer_pos = session->response_buffer_pos;
+ response_result = rtsp_try_receive_response(session);
+ /* A closing socket can deliver its final response and EOF in one
+ * edge-triggered event, and there will be no further readable event to
+ * finish the job. Keep draining while each recv makes progress so a
+ * fragmented response is parsed before the close is acted on. */
+ } while (response_result == RTSP_RESPONSE_OK && session->awaiting_response &&
+ (events & (POLLER_HUP | POLLER_ERR | POLLER_RDHUP)) &&
+ session->response_buffer_pos > previous_buffer_pos);
if (response_result < 0) {
logger(LOG_ERROR, "RTSP: Failed to receive response");
rtsp_session_set_state(session, RTSP_STATE_ERROR);
- return -1;
+ return STREAM_EVENT_CLOSE;
}
if (response_result == RTSP_RESPONSE_DURATION) {
- return -2;
+ return STREAM_EVENT_DURATION_READY;
+ }
+ if (response_result == RTSP_RESPONSE_METADATA_READY) {
+ return STREAM_EVENT_METADATA_READY;
+ }
+
+ /* The response that arrived with the close (if any) has now been parsed.
+ * Terminal successes returned above; everything else still needs a live
+ * connection, so honour the close now instead of letting the session wait
+ * for a reply that can never come. A response that replaced the control
+ * connection (redirect, TEARDOWN reconnect) is exempt: the close belonged
+ * to the connection we just walked away from. */
+ if (session->connect_generation == connect_generation &&
+ (session->peer_closed || (events & (POLLER_HUP | POLLER_ERR | POLLER_RDHUP)))) {
+ return rtsp_handle_terminal_socket_event(session, events);
}
/* Re-enable POLLER_OUT for next request */
@@ -1282,6 +1349,7 @@ static int rtsp_try_send_pending(rtsp_session_t *session) {
* RTSP_RESPONSE_OK: Waiting for more data, or response processed internally
* RTSP_RESPONSE_ERROR: recv failure, connection closed, or parse error
* RTSP_RESPONSE_DURATION: Duration query completed
+ * RTSP_RESPONSE_METADATA_READY: HEAD metadata probe completed
*/
static int rtsp_try_receive_response(rtsp_session_t *session) {
if (!session->awaiting_response) {
@@ -1304,12 +1372,19 @@ static int rtsp_try_receive_response(rtsp_session_t *session) {
}
if (received == 0) {
- logger(LOG_ERROR, "RTSP: Connection closed by server");
- session->awaiting_keepalive_response = 0;
- return RTSP_RESPONSE_ERROR;
+ if (session->response_buffer_pos == 0) {
+ logger(LOG_ERROR, "RTSP: Connection closed by server");
+ session->awaiting_keepalive_response = 0;
+ return RTSP_RESPONSE_ERROR;
+ }
+ /* The peer may close immediately after its final response. Parse any
+ * bytes already buffered before treating EOF as an error. The flag is
+ * sticky so the caller still ends the session once that last response
+ * has been handled. */
+ session->peer_closed = 1;
+ } else {
+ session->response_buffer_pos += (size_t)received;
}
-
- session->response_buffer_pos += (size_t)received;
}
/* NUL-terminate for strstr-based parsing. Clamp to buffer bounds since
@@ -1329,6 +1404,11 @@ static int rtsp_try_receive_response(rtsp_session_t *session) {
rtsp_parse_response_header(session, (const char *)session->response_buffer, &response_offset, &response_len);
if (parse_result == 1) {
+ if (session->peer_closed) {
+ logger(LOG_ERROR, "RTSP: Connection closed with an incomplete response");
+ session->awaiting_keepalive_response = 0;
+ return RTSP_RESPONSE_ERROR;
+ }
/* Need more data - for TCP interleaved mode, drain '$'-prefixed frames
* to free buffer space and forward media data while waiting for RTSP
* response */
@@ -1454,6 +1534,16 @@ static int rtsp_try_receive_response(rtsp_session_t *session) {
if (session->state == RTSP_STATE_AWAITING_DESCRIBE) {
rtsp_parse_describe_sdp(session, (const char *)session->response_buffer + response_offset,
(const char *)session->response_buffer + response_offset + response_len);
+ /* HEAD metadata probes always stop after DESCRIBE, even when the URL also
+ * contains the legacy r2h-duration query parameter. Neither terminal case
+ * advances past DESCRIBE, so the state is deliberately left on
+ * AWAITING_DESCRIBE: moving it to DESCRIBED would make the periodic tick
+ * eligible to start a SETUP for a request whose response has already been
+ * produced, and would drop the handshake timeout that bounds it. */
+ if (session->metadata_probe) {
+ session->response_buffer_pos = 0;
+ return RTSP_RESPONSE_METADATA_READY;
+ }
if (session->r2h_duration) {
session->response_buffer_pos = 0;
return RTSP_RESPONSE_DURATION;
@@ -1829,7 +1919,7 @@ static int rtsp_process_interleaved_buffer(rtsp_session_t *session, connection_t
session->first_media_received = 1;
logger(LOG_DEBUG, "RTSP: First media packet received (TCP)");
}
- int pb = stream_process_rtp_payload(&conn->stream, packet_buf);
+ int pb = stream_process_rtp_payload(&conn->stream, packet_buf, STREAM_MEDIA_ORIGIN_RTSP);
if (pb > 0)
bytes_forwarded += pb;
buffer_ref_put(packet_buf);
@@ -1973,7 +2063,7 @@ int rtsp_handle_udp_rtp_data(rtsp_session_t *session, connection_t *conn) {
session->first_media_received = 1;
logger(LOG_DEBUG, "RTSP: First media packet received (UDP)");
}
- int pb = stream_process_rtp_payload(&conn->stream, rtp_buf);
+ int pb = stream_process_rtp_payload(&conn->stream, rtp_buf, STREAM_MEDIA_ORIGIN_RTSP);
buffer_ref_put(rtp_buf);
if (pb > 0)
total_bytes_written += pb;
@@ -2295,6 +2385,16 @@ static int rtsp_parse_response_header(rtsp_session_t *session, const char *respo
/* Increment retry counter */
session->auth_retry_count++;
+ /* The request about to be retried will answer these fields again; drop the
+ * unauthenticated attempt's answers so a failed retry cannot report them. */
+ if (session->state == RTSP_STATE_AWAITING_DESCRIBE) {
+ stream_metadata_forget(rtsp_metadata(session), STREAM_METADATA_STAGE_DESCRIBE);
+ } else if (session->state == RTSP_STATE_AWAITING_SETUP) {
+ stream_metadata_forget(rtsp_metadata(session), STREAM_METADATA_STAGE_SETUP);
+ } else if (session->state == RTSP_STATE_AWAITING_PLAY) {
+ stream_metadata_forget(rtsp_metadata(session), STREAM_METADATA_STAGE_PLAY);
+ }
+
/* Move state back to retry the same request */
if (session->state == RTSP_STATE_AWAITING_OPTIONS) {
rtsp_session_set_state(session, RTSP_STATE_CONNECTED);
@@ -2330,6 +2430,10 @@ static int rtsp_parse_response_header(rtsp_session_t *session, const char *respo
goto cleanup;
}
+ if (session->state == RTSP_STATE_AWAITING_PLAY) {
+ rtsp_parse_play_metadata(session, rtsp_start);
+ }
+
/* Parse Public header from OPTIONS response to determine supported methods */
if (session->state == RTSP_STATE_AWAITING_OPTIONS) {
public_header = rtsp_find_header(rtsp_start, "Public");
@@ -2591,33 +2695,99 @@ static void rtsp_close_udp_sockets(rtsp_session_t *session, const char *reason)
}
static char *rtsp_find_header(const char *response, const char *header_name) {
- char *header_start, *header_end;
- char header_prefix[RTSP_HEADER_PREFIX_SIZE];
- int header_len;
+ const char *line;
+ size_t header_name_len;
- snprintf(header_prefix, sizeof(header_prefix), "%s:", header_name);
- header_start = strstr(response, header_prefix);
- if (!header_start)
+ if (!response || !header_name)
return NULL;
- header_start += strlen(header_prefix);
- while (*header_start == ' ')
- header_start++; /* Skip whitespace */
-
- header_end = strstr(header_start, "\r\n");
- if (!header_end)
+ line = strstr(response, "\r\n");
+ if (!line)
return NULL;
+ line += 2;
+ header_name_len = strlen(header_name);
+
+ while (*line != '\0') {
+ const char *line_end = strstr(line, "\r\n");
+ const char *colon;
+ const char *value_start;
+ const char *value_end;
+ size_t value_len;
+ char *result;
+
+ if (!line_end || line_end == line)
+ return NULL;
+
+ colon = memchr(line, ':', (size_t)(line_end - line));
+ if (!colon || (size_t)(colon - line) != header_name_len || strncasecmp(line, header_name, header_name_len) != 0) {
+ line = line_end + 2;
+ continue;
+ }
- header_len = header_end - header_start;
- char *result = malloc(header_len + 1);
- if (!result) {
- logger(LOG_ERROR, "RTSP: Failed to allocate memory for header");
- return NULL;
+ value_start = colon + 1;
+ while (value_start < line_end && (*value_start == ' ' || *value_start == '\t'))
+ value_start++;
+ value_end = line_end;
+ while (value_end > value_start && (value_end[-1] == ' ' || value_end[-1] == '\t'))
+ value_end--;
+
+ value_len = (size_t)(value_end - value_start);
+ result = malloc(value_len + 1);
+ if (!result) {
+ logger(LOG_ERROR, "RTSP: Failed to allocate memory for header");
+ return NULL;
+ }
+ memcpy(result, value_start, value_len);
+ result[value_len] = '\0';
+ return result;
}
- strncpy(result, header_start, header_len);
- result[header_len] = '\0';
- return result;
+ return NULL;
+}
+
+static void rtsp_parse_play_metadata(rtsp_session_t *session, const char *response) {
+ stream_metadata_t *metadata;
+ char *scale_header;
+ char *range_header;
+
+ if (!response)
+ return;
+
+ metadata = rtsp_metadata(session);
+ if (!metadata)
+ return;
+
+ scale_header = rtsp_find_header(response, "Scale");
+ if (scale_header) {
+ char *end = NULL;
+ double scale;
+
+ errno = 0;
+ scale = strtod(scale_header, &end);
+ while (end && (*end == ' ' || *end == '\t'))
+ end++;
+ if (end != scale_header && end && *end == '\0' && errno != ERANGE && isfinite(scale)) {
+ metadata->playback_scale = scale;
+ metadata->playback_scale_known = 1;
+ }
+ free(scale_header);
+ }
+
+ range_header = rtsp_find_header(response, "Range");
+ if (range_header) {
+ size_t len = strlen(range_header);
+ int valid = len > 0 && len < sizeof(metadata->playback_range);
+
+ for (size_t i = 0; valid && i < len; i++) {
+ unsigned char ch = (unsigned char)range_header[i];
+ if (ch < 0x20 || ch > 0x7e)
+ valid = 0;
+ }
+ if (valid) {
+ memcpy(metadata->playback_range, range_header, len + 1);
+ }
+ free(range_header);
+ }
}
/**
@@ -2650,6 +2820,101 @@ static void rtsp_resolve_relative_url(const char *base_url, const char *relative
}
}
+static int rtsp_sdp_describes_mp2t_rtp(const char *sdp_body) {
+ const char *line = sdp_body;
+
+ while (line && *line != '\0') {
+ const char *line_end = strpbrk(line, "\r\n");
+ size_t line_len = line_end ? (size_t)(line_end - line) : strlen(line);
+
+ if (line_len > 9 && strncasecmp(line, "a=rtpmap:", 9) == 0) {
+ int payload_type;
+ char encoding[32];
+ if (sscanf(line, "a=rtpmap:%d %31[^/\r\n]", &payload_type, encoding) == 2 && strcasecmp(encoding, "MP2T") == 0) {
+ return 1;
+ }
+ } else if (line_len > 2 && strncasecmp(line, "m=", 2) == 0) {
+ char media[32];
+ char transport[64];
+ char payloads[128];
+ int port;
+
+ if (sscanf(line, "m=%31s %d %63s %127[^\r\n]", media, &port, transport, payloads) == 4 &&
+ strncasecmp(transport, "RTP/", 4) == 0) {
+ char *cursor = payloads;
+ while (*cursor != '\0') {
+ char *end;
+ long payload_type;
+
+ while (*cursor == ' ' || *cursor == '\t')
+ cursor++;
+ if (*cursor == '\0')
+ break;
+ payload_type = strtol(cursor, &end, 10);
+ if (end == cursor)
+ break;
+ if (payload_type == 33)
+ return 1;
+ cursor = end;
+ }
+ }
+ }
+
+ if (!line_end)
+ break;
+ line = line_end;
+ while (*line == '\r' || *line == '\n')
+ line++;
+ }
+
+ return 0;
+}
+
+static int rtsp_parse_npt_time(const char *value, const char **end_out, double *seconds_out) {
+ char *component_end;
+ double first_component;
+
+ errno = 0;
+ first_component = strtod(value, &component_end);
+ if (component_end == value || errno == ERANGE || !isfinite(first_component) || first_component < 0.0)
+ return -1;
+
+ if (*component_end != ':') {
+ *end_out = component_end;
+ *seconds_out = first_component;
+ return 0;
+ }
+
+ if (floor(first_component) != first_component)
+ return -1;
+
+ const char *minutes_start = component_end + 1;
+ char *minutes_end;
+ long minutes;
+
+ errno = 0;
+ minutes = strtol(minutes_start, &minutes_end, 10);
+ if (minutes_end == minutes_start || errno == ERANGE || minutes < 0 || minutes > 59 || *minutes_end != ':')
+ return -1;
+
+ const char *seconds_start = minutes_end + 1;
+ char *seconds_end;
+ double seconds;
+
+ errno = 0;
+ seconds = strtod(seconds_start, &seconds_end);
+ if (seconds_end == seconds_start || errno == ERANGE || !isfinite(seconds) || seconds < 0.0 || seconds >= 60.0)
+ return -1;
+
+ double total = first_component * 3600.0 + (double)minutes * 60.0 + seconds;
+ if (!isfinite(total))
+ return -1;
+
+ *end_out = seconds_end;
+ *seconds_out = total;
+ return 0;
+}
+
/**
* Parse DESCRIBE response SDP body in a single pass.
*
@@ -2668,6 +2933,7 @@ static void rtsp_resolve_relative_url(const char *base_url, const char *relative
* @param sdp_body Start of the SDP body (right after \\r\\n\\r\\n)
*/
static void rtsp_parse_describe_sdp(rtsp_session_t *session, const char *header_start, const char *sdp_body) {
+ stream_metadata_t *metadata = rtsp_metadata(session);
session->setup_url[0] = '\0';
char *content_base = rtsp_find_header(header_start, "Content-Base");
@@ -2676,6 +2942,10 @@ static void rtsp_parse_describe_sdp(rtsp_session_t *session, const char *header_
if (*sdp_body == '\0')
goto done;
+ if (metadata && rtsp_sdp_describes_mp2t_rtp(sdp_body)) {
+ metadata->upstream_payload = STREAM_PAYLOAD_MP2T_RTP;
+ }
+
/* --- a=control: → setup_url ----------------------------------------- */
/* Find the first media-level a=control: (after first m= line).
@@ -2718,26 +2988,41 @@ static void rtsp_parse_describe_sdp(rtsp_session_t *session, const char *header_
/* --- a=range:npt= → r2h_duration_value ------------------------------ */
- if (session->r2h_duration) {
+ {
const char *range = strstr(sdp_body, "a=range:npt=");
if (range) {
const char *val = range + 12; /* skip "a=range:npt=" */
const char *val_end = strpbrk(val, "\r\n");
+ size_t len;
+ char buf[64];
+
if (!val_end)
val_end = val + strlen(val);
+ len = (size_t)(val_end - val);
- size_t len = val_end - val;
- char buf[64];
if (len > 0 && len < sizeof(buf)) {
+ const char *start_end;
+ const char *end_end;
+ double range_start;
+ double range_end;
+
memcpy(buf, val, len);
buf[len] = '\0';
-
- float range_start, range_end;
- if (sscanf(buf, "%f-%f", &range_start, &range_end) == 2) {
- logger(LOG_DEBUG, "RTSP: Range: %.3f-%.3f", range_start, range_end);
- session->r2h_duration_value = range_end;
+ if (rtsp_parse_npt_time(buf, &start_end, &range_start) == 0 && *start_end == '-') {
+ if (rtsp_parse_npt_time(start_end + 1, &end_end, &range_end) == 0 && *end_end == '\0' &&
+ range_end >= range_start) {
+ logger(LOG_DEBUG, "RTSP: Range: %.3f-%.3f", range_start, range_end);
+ if (session->r2h_duration)
+ session->r2h_duration_value = (float)range_end;
+ if (metadata) {
+ metadata->media_duration = range_end - range_start;
+ metadata->media_duration_known = 1;
+ }
+ } else {
+ logger(LOG_DEBUG, "RTSP Range: %s, cannot find finite range", buf);
+ }
} else {
- logger(LOG_DEBUG, "RTSP Range: %s, cannot find right range!", buf);
+ logger(LOG_DEBUG, "RTSP Range: %s, cannot find finite range", buf);
}
}
}
@@ -2821,6 +3106,7 @@ static void rtsp_parse_transport_header(rtsp_session_t *session, const char *tra
char *client_port_param;
char *source_param;
char source_address[RTSP_SERVER_HOST_SIZE] = {0};
+ stream_metadata_t *metadata = rtsp_metadata(session);
logger(LOG_DEBUG, "RTSP: Parsing server transport response: %s", transport);
@@ -2843,6 +3129,8 @@ static void rtsp_parse_transport_header(rtsp_session_t *session, const char *tra
if (strstr(transport, "TCP") || strstr(transport, "interleaved=")) {
/* TCP transport mode */
session->transport_mode = RTSP_TRANSPORT_TCP;
+ if (metadata)
+ metadata->upstream_transport = STREAM_TRANSPORT_TCP_INTERLEAVED;
session->keepalive_interval_ms = RTSP_KEEPALIVE_INTERVAL_MS;
session->last_keepalive_ms = 0;
session->keepalive_pending = 0;
@@ -2864,6 +3152,8 @@ static void rtsp_parse_transport_header(rtsp_session_t *session, const char *tra
} else {
/* UDP transport mode */
session->transport_mode = RTSP_TRANSPORT_UDP;
+ if (metadata)
+ metadata->upstream_transport = STREAM_TRANSPORT_UDP;
session->keepalive_interval_ms = RTSP_KEEPALIVE_INTERVAL_MS;
session->last_keepalive_ms = 0;
session->keepalive_pending = 0;
@@ -2940,6 +3230,8 @@ static int rtsp_handle_redirect(rtsp_session_t *session, const char *location) {
* session learned from the previous server so requests to the
* redirected-to server don't carry a stale Session header. */
session->session_id[0] = '\0';
+ /* Same for metadata: the new server renegotiates every stage from scratch. */
+ stream_metadata_forget(rtsp_metadata(session), STREAM_METADATA_STAGE_ALL);
/* Close current connection and remove from poller properly */
if (session->socket >= 0) {
diff --git a/src/rtsp.h b/src/rtsp.h
index 96f456bb..73789911 100644
--- a/src/rtsp.h
+++ b/src/rtsp.h
@@ -142,6 +142,15 @@ typedef struct {
int use_playseek_range;
int r2h_duration;
float r2h_duration_value;
+ int metadata_probe;
+ /* Set once the control socket reports EOF. Sticky: the session can never
+ * continue after this, even if a complete response arrived alongside it. */
+ int peer_closed;
+ /* Bumped by every rtsp_connect(). Lets an event handler tell whether the
+ * response it just processed replaced the control connection (redirect or
+ * TEARDOWN reconnect), in which case events for the old one must be ignored.
+ * fd numbers cannot be used for this: close()+socket() often reuses them. */
+ unsigned connect_generation;
/* Authentication state */
char username[RTSP_CREDENTIAL_SIZE]; /* RTSP username for authentication */
diff --git a/src/snapshot.c b/src/snapshot.c
index 9a91d956..a3d53ce5 100644
--- a/src/snapshot.c
+++ b/src/snapshot.c
@@ -463,7 +463,7 @@ int snapshot_process_packet(snapshot_context_t *ctx, int recv_len, uint8_t *buf,
char content_length_header[64];
snprintf(content_length_header, sizeof(content_length_header), "Content-Length: %zu\r\n", jpeg_size);
- send_http_headers(conn, STATUS_200, "image/jpeg", content_length_header);
+ stream_send_http_headers(conn, "image/jpeg", content_length_header);
/* Queue JPEG file for non-blocking sendfile() */
if (connection_queue_file(conn, jpeg_fd, 0, jpeg_size) < 0) {
diff --git a/src/stream.c b/src/stream.c
index 40964694..744e0a56 100644
--- a/src/stream.c
+++ b/src/stream.c
@@ -1,6 +1,8 @@
#include "stream.h"
+#include "configuration.h"
#include "connection.h"
#include "fcc.h"
+#include "http.h"
#include "http_proxy.h"
#include "multicast.h"
#include "rtp.h"
@@ -11,14 +13,260 @@
#include "status.h"
#include "utils.h"
#include
+#include
#include
#include
#include
+#include
#include
#include
#include
#include
+#define STREAM_METADATA_HEADERS_SIZE 1024
+#define TS_PACKET_SIZE 188
+#define TS_SYNC_BYTE 0x47
+
+/* Single source of truth for the R2H-* response header names. The order here
+ * is the order they appear in the response and in
+ * Access-Control-Expose-Headers, so a new field only has to be added once. */
+enum {
+ STREAM_HDR_UPSTREAM_PROTOCOL = 0,
+ STREAM_HDR_UPSTREAM_TRANSPORT,
+ STREAM_HDR_UPSTREAM_PAYLOAD,
+ STREAM_HDR_PLAYBACK_SCALE,
+ STREAM_HDR_PLAYBACK_RANGE,
+ STREAM_HDR_MEDIA_DURATION,
+ STREAM_HDR_FCC_TYPE,
+ STREAM_HDR_FCC_STATUS,
+ STREAM_HDR_COUNT
+};
+
+static const char *const stream_metadata_header_names[STREAM_HDR_COUNT] = {
+ "R2H-Upstream-Protocol", "R2H-Upstream-Transport", "R2H-Upstream-Payload", "R2H-Playback-Scale",
+ "R2H-Playback-Range", "R2H-Media-Duration", "R2H-FCC-Type", "R2H-FCC-Status",
+};
+
+/* Enum value -> header value. A NULL entry means "not known", which omits the
+ * header entirely. */
+static const char *const stream_upstream_protocol_values[] = {NULL, "rtsp", "multicast"};
+static const char *const stream_upstream_transport_values[] = {NULL, "tcp-interleaved", "udp"};
+static const char *const stream_upstream_payload_values[] = {NULL, "mp2t-rtp", "mp2t-direct"};
+static const char *const stream_fcc_type_values[] = {NULL, "telecom", "huawei"};
+static const char *const stream_fcc_status_values[] = {NULL, "active", "fallback"};
+
+static int stream_metadata_append_raw(char *buffer, size_t buffer_size, size_t *length, const char *value) {
+ size_t value_len;
+
+ if (!buffer || !length || !value || *length >= buffer_size)
+ return -1;
+
+ value_len = strlen(value);
+ if (value_len >= buffer_size - *length)
+ return -1;
+
+ memcpy(buffer + *length, value, value_len + 1);
+ *length += value_len;
+ return 0;
+}
+
+static int stream_metadata_append_header(char *buffer, size_t buffer_size, size_t *length, const char *name,
+ const char *value) {
+ char line[512];
+ int written;
+
+ written = snprintf(line, sizeof(line), "%s: %s\r\n", name, value);
+ if (written < 0 || (size_t)written >= sizeof(line))
+ return -1;
+ return stream_metadata_append_raw(buffer, buffer_size, length, line);
+}
+
+/* Append the header for an enum-valued field, or nothing when the value is
+ * unknown or out of range. */
+static int stream_metadata_append_enum(char *buffer, size_t buffer_size, size_t *length, int field,
+ const char *const *values, size_t value_count, int value) {
+ if (value < 0 || (size_t)value >= value_count || !values[value])
+ return 0;
+ return stream_metadata_append_header(buffer, buffer_size, length, stream_metadata_header_names[field], values[value]);
+}
+
+static int stream_metadata_append_expose_headers(char *buffer, size_t buffer_size, size_t *length) {
+ char list[512];
+ size_t used = 0;
+
+ for (int i = 0; i < STREAM_HDR_COUNT; i++) {
+ int written = snprintf(list + used, sizeof(list) - used, "%s%s", used ? ", " : "", stream_metadata_header_names[i]);
+ if (written < 0 || (size_t)written >= sizeof(list) - used)
+ return -1;
+ used += (size_t)written;
+ }
+
+ return stream_metadata_append_header(buffer, buffer_size, length, "Access-Control-Expose-Headers", list);
+}
+
+/**
+ * Render a double as the shortest exact decimal that round-trips for our
+ * purposes, without a trailing '.' or redundant zeros.
+ * @return 0 on success, -1 if the value does not fit (the caller must then omit
+ * the field: stripping trailing zeros off a truncated number would
+ * silently report a wildly different value).
+ */
+static int stream_metadata_format_number(double value, char *buffer, size_t buffer_size) {
+ char *end;
+ int written;
+
+ if (!buffer || buffer_size == 0)
+ return -1;
+
+ written = snprintf(buffer, buffer_size, "%.6f", value);
+ if (written < 0 || (size_t)written >= buffer_size)
+ return -1;
+
+ end = buffer + strlen(buffer);
+ while (end > buffer && end[-1] == '0')
+ *--end = '\0';
+ if (end > buffer && end[-1] == '.')
+ *--end = '\0';
+ if (strcmp(buffer, "-0") == 0)
+ snprintf(buffer, buffer_size, "0");
+ return 0;
+}
+
+static int stream_payload_is_mpegts(const uint8_t *payload, int payload_len) {
+ int checked = 0;
+
+ if (!payload || payload_len < TS_PACKET_SIZE || payload[0] != TS_SYNC_BYTE)
+ return 0;
+
+ for (int offset = 0; offset + TS_PACKET_SIZE <= payload_len && checked < 3; offset += TS_PACKET_SIZE) {
+ if (payload[offset] != TS_SYNC_BYTE)
+ return 0;
+ checked++;
+ }
+
+ return checked > 0;
+}
+
+void stream_metadata_init(stream_metadata_t *metadata, const service_t *service) {
+ if (!metadata)
+ return;
+
+ memset(metadata, 0, sizeof(*metadata));
+ if (!service)
+ return;
+
+ if (service->service_type == SERVICE_RTSP) {
+ metadata->upstream_protocol = STREAM_UPSTREAM_RTSP;
+ } else if (service->service_type == SERVICE_MRTP) {
+ metadata->upstream_protocol = STREAM_UPSTREAM_MULTICAST;
+ if (service->fcc_addr) {
+ metadata->fcc_type = service->fcc_type == FCC_TYPE_HUAWEI ? STREAM_FCC_TYPE_HUAWEI : STREAM_FCC_TYPE_TELECOM;
+ }
+ }
+}
+
+void stream_metadata_forget(stream_metadata_t *metadata, unsigned stages) {
+ if (!metadata || metadata->frozen)
+ return;
+
+ if (stages & STREAM_METADATA_STAGE_DESCRIBE) {
+ metadata->upstream_payload = STREAM_PAYLOAD_UNKNOWN;
+ metadata->media_duration_known = 0;
+ }
+ if (stages & STREAM_METADATA_STAGE_SETUP)
+ metadata->upstream_transport = STREAM_TRANSPORT_UNKNOWN;
+ if (stages & STREAM_METADATA_STAGE_PLAY) {
+ metadata->playback_scale_known = 0;
+ metadata->playback_range[0] = '\0';
+ }
+}
+
+static void stream_metadata_note_media(stream_context_t *ctx, int packet_type, const uint8_t *payload, int payload_len,
+ stream_media_origin_t origin) {
+ stream_metadata_t *metadata;
+
+ if (!ctx || ctx->metadata.frozen || !stream_payload_is_mpegts(payload, payload_len))
+ return;
+
+ metadata = &ctx->metadata;
+ metadata->upstream_payload = packet_type == 1 ? STREAM_PAYLOAD_MP2T_RTP : STREAM_PAYLOAD_MP2T_DIRECT;
+
+ if (metadata->fcc_type != STREAM_FCC_TYPE_UNKNOWN && metadata->fcc_status == STREAM_FCC_STATUS_UNKNOWN) {
+ if (origin == STREAM_MEDIA_ORIGIN_FCC_UNICAST) {
+ metadata->fcc_status = STREAM_FCC_STATUS_ACTIVE;
+ } else if (origin == STREAM_MEDIA_ORIGIN_FCC_MULTICAST) {
+ metadata->fcc_status = STREAM_FCC_STATUS_FALLBACK;
+ }
+ }
+}
+
+void stream_send_http_headers(connection_t *conn, const char *content_type, const char *extra_headers) {
+ stream_metadata_t *metadata;
+ char headers[STREAM_METADATA_HEADERS_SIZE];
+ char number[64];
+ size_t length = 0;
+ int failed = 0;
+
+ if (!conn) {
+ return;
+ }
+
+ metadata = &conn->stream.metadata;
+ headers[0] = '\0';
+
+ if (extra_headers && extra_headers[0])
+ failed |= stream_metadata_append_raw(headers, sizeof(headers), &length, extra_headers) < 0;
+
+ failed |= stream_metadata_append_enum(headers, sizeof(headers), &length, STREAM_HDR_UPSTREAM_PROTOCOL,
+ stream_upstream_protocol_values, ARRAY_SIZE(stream_upstream_protocol_values),
+ metadata->upstream_protocol) < 0;
+ failed |= stream_metadata_append_enum(headers, sizeof(headers), &length, STREAM_HDR_UPSTREAM_TRANSPORT,
+ stream_upstream_transport_values, ARRAY_SIZE(stream_upstream_transport_values),
+ metadata->upstream_transport) < 0;
+ failed |= stream_metadata_append_enum(headers, sizeof(headers), &length, STREAM_HDR_UPSTREAM_PAYLOAD,
+ stream_upstream_payload_values, ARRAY_SIZE(stream_upstream_payload_values),
+ metadata->upstream_payload) < 0;
+
+ /* A value we cannot render exactly is dropped rather than approximated. */
+ if (metadata->playback_scale_known && isfinite(metadata->playback_scale)) {
+ if (stream_metadata_format_number(metadata->playback_scale, number, sizeof(number)) == 0)
+ failed |= stream_metadata_append_header(headers, sizeof(headers), &length,
+ stream_metadata_header_names[STREAM_HDR_PLAYBACK_SCALE], number) < 0;
+ else
+ logger(LOG_DEBUG, "Stream: upstream Scale %g is not representable, omitting header", metadata->playback_scale);
+ }
+ if (metadata->playback_range[0]) {
+ failed |= stream_metadata_append_header(headers, sizeof(headers), &length,
+ stream_metadata_header_names[STREAM_HDR_PLAYBACK_RANGE],
+ metadata->playback_range) < 0;
+ }
+ if (metadata->media_duration_known && isfinite(metadata->media_duration)) {
+ if (stream_metadata_format_number(metadata->media_duration, number, sizeof(number)) == 0)
+ failed |= stream_metadata_append_header(headers, sizeof(headers), &length,
+ stream_metadata_header_names[STREAM_HDR_MEDIA_DURATION], number) < 0;
+ else
+ logger(LOG_DEBUG, "Stream: media duration %g is not representable, omitting header", metadata->media_duration);
+ }
+
+ failed |= stream_metadata_append_enum(headers, sizeof(headers), &length, STREAM_HDR_FCC_TYPE, stream_fcc_type_values,
+ ARRAY_SIZE(stream_fcc_type_values), metadata->fcc_type) < 0;
+ failed |=
+ stream_metadata_append_enum(headers, sizeof(headers), &length, STREAM_HDR_FCC_STATUS, stream_fcc_status_values,
+ ARRAY_SIZE(stream_fcc_status_values), metadata->fcc_status) < 0;
+
+ if (config.cors_allow_origin && config.cors_allow_origin[0]) {
+ failed |= stream_metadata_append_expose_headers(headers, sizeof(headers), &length) < 0;
+ }
+
+ if (failed) {
+ logger(LOG_ERROR, "Failed to build stream metadata HTTP headers");
+ send_http_headers(conn, STATUS_200, content_type, extra_headers);
+ } else {
+ send_http_headers(conn, STATUS_200, content_type, headers);
+ }
+ metadata->frozen = 1;
+}
+
void stream_on_client_drain(stream_context_t *ctx) {
/* Hot path: every successful client write hits this. Bail out cheaply when
* no upstream is paused (vast majority of streams). */
@@ -33,7 +281,7 @@ void stream_on_client_drain(stream_context_t *ctx) {
rtsp_resume_upstream(&ctx->rtsp);
}
-int stream_process_rtp_payload(stream_context_t *ctx, buffer_ref_t *buf_ref) {
+int stream_process_rtp_payload(stream_context_t *ctx, buffer_ref_t *buf_ref, stream_media_origin_t origin) {
uint8_t *data_ptr = (uint8_t *)buf_ref->data + buf_ref->data_offset;
uint8_t *payload;
int payload_len;
@@ -52,6 +300,8 @@ int stream_process_rtp_payload(stream_context_t *ctx, buffer_ref_t *buf_ref) {
return 0;
}
+ stream_metadata_note_media(ctx, pkt_type, payload, payload_len, origin);
+
if (pkt_type == 0) {
/* Non-RTP packet - pass through directly (no reordering needed) */
if (ctx->snapshot.initialized) {
@@ -105,13 +355,16 @@ int stream_handle_fd_event(stream_context_t *ctx, int fd, uint32_t events, int64
/* Handle RTSP socket events (handshake and RTP data in PLAYING state) */
int result = rtsp_handle_socket_event(&ctx->rtsp, events);
if (result < 0) {
- if (result == -2) {
+ if (result == STREAM_EVENT_DURATION_READY) {
logger(LOG_DEBUG, "RTSP: found duration: %0.3f", ctx->rtsp.r2h_duration_value);
- return -2;
+ return STREAM_EVENT_DURATION_READY;
}
- return -1;
+ if (result == STREAM_EVENT_METADATA_READY) {
+ return STREAM_EVENT_METADATA_READY;
+ }
+ return STREAM_EVENT_CLOSE;
}
- return 0;
+ return STREAM_EVENT_OK;
}
/* Process RTSP RTP socket events (UDP mode) */
@@ -147,6 +400,70 @@ int stream_handle_fd_event(stream_context_t *ctx, int fd, uint32_t events, int64
return 0;
}
+static int stream_init_rtsp_control(stream_context_t *ctx, service_t *service, int status_index, int metadata_probe) {
+ seek_parse_result_t seek_parse_result;
+ const char *resolved_seek_param_name = service->seek_param_name;
+ char resolved_rtsp_url[2048];
+
+ rtsp_session_init(&ctx->rtsp);
+ ctx->rtsp.status_index = status_index;
+ ctx->rtsp.epoll_fd = ctx->epoll_fd;
+ ctx->rtsp.conn = ctx->conn;
+ ctx->rtsp.metadata_probe = metadata_probe;
+ ctx->rtsp.upstream_ifname = get_upstream_interface_for_rtsp(service->ifname);
+
+ if (!service->rtsp_url) {
+ logger(LOG_ERROR, "RTSP URL not found in service configuration");
+ return -1;
+ }
+
+ if (service_parse_seek_value(service->seek_param_value, service->seek_begin_offset_seconds,
+ service->seek_end_offset_seconds, service->user_agent, service->seek_mode,
+ service->seek_mode_tz_explicit, service->seek_mode_tz_offset_seconds,
+ service->seek_mode_window_seconds, &seek_parse_result) != 0) {
+ logger(LOG_ERROR, "RTSP: Failed to parse seek parameters");
+ return -1;
+ }
+
+ if (service_format_recent_seek_range(&seek_parse_result, ctx->rtsp.playseek_range_start,
+ sizeof(ctx->rtsp.playseek_range_start)) > 0) {
+ ctx->rtsp.use_playseek_range = 1;
+ resolved_seek_param_name = NULL;
+ }
+
+ if (service_resolve_upstream_url(service->rtsp_url, resolved_seek_param_name, &seek_parse_result, resolved_rtsp_url,
+ sizeof(resolved_rtsp_url)) < 0) {
+ logger(LOG_ERROR, "RTSP: Failed to resolve upstream URL");
+ return -1;
+ }
+ if (rtsp_parse_server_url(&ctx->rtsp, resolved_rtsp_url, NULL, NULL) < 0) {
+ logger(LOG_ERROR, "RTSP: Failed to parse URL");
+ return -1;
+ }
+ if (rtsp_connect(&ctx->rtsp) < 0) {
+ logger(LOG_ERROR, "RTSP: Failed to initiate connection");
+ return -1;
+ }
+
+ logger(LOG_DEBUG, "RTSP: Async connection initiated, state=%d", ctx->rtsp.state);
+ return 0;
+}
+
+int stream_context_init_rtsp_metadata_probe(stream_context_t *ctx, connection_t *conn, service_t *service,
+ int epoll_fd) {
+ if (!ctx || !conn || !service || service->service_type != SERVICE_RTSP)
+ return -1;
+
+ memset(ctx, 0, sizeof(*ctx));
+ ctx->conn = conn;
+ ctx->service = service;
+ ctx->epoll_fd = epoll_fd;
+ ctx->status_index = -1;
+ ctx->last_status_update = get_time_ms();
+ stream_metadata_init(&ctx->metadata, service);
+ return stream_init_rtsp_control(ctx, service, -1, 1);
+}
+
/* Initialize context for unified worker epoll (non-blocking, no own loop) */
int stream_context_init_for_worker(stream_context_t *ctx, connection_t *conn, service_t *service, int epoll_fd,
int status_index, int is_snapshot) {
@@ -160,6 +477,7 @@ int stream_context_init_for_worker(stream_context_t *ctx, connection_t *conn, se
ctx->total_bytes_sent = 0;
ctx->last_bytes_sent = 0;
ctx->last_status_update = get_time_ms();
+ stream_metadata_init(&ctx->metadata, service);
/* Initialize media path depending on service type */
if (service->service_type == SERVICE_HTTP) {
@@ -244,58 +562,8 @@ int stream_context_init_for_worker(stream_context_t *ctx, connection_t *conn, se
fec_init(&ctx->fec, service->fec_port, &ctx->reorder);
if (service->service_type == SERVICE_RTSP) {
- /* Initialize RTSP session */
- seek_parse_result_t seek_parse_result;
- const char *resolved_seek_param_name = service->seek_param_name;
-
- rtsp_session_init(&ctx->rtsp);
- ctx->rtsp.status_index = status_index;
- ctx->rtsp.epoll_fd = ctx->epoll_fd;
- ctx->rtsp.conn = conn;
- ctx->rtsp.upstream_ifname = get_upstream_interface_for_rtsp(service->ifname);
- if (!service->rtsp_url) {
- logger(LOG_ERROR, "RTSP URL not found in service configuration");
+ if (stream_init_rtsp_control(ctx, service, status_index, 0) < 0)
return -1;
- }
-
- if (service_parse_seek_value(service->seek_param_value, service->seek_begin_offset_seconds,
- service->seek_end_offset_seconds, service->user_agent, service->seek_mode,
- service->seek_mode_tz_explicit, service->seek_mode_tz_offset_seconds,
- service->seek_mode_window_seconds, &seek_parse_result) != 0) {
- logger(LOG_ERROR, "RTSP: Failed to parse seek parameters");
- return -1;
- }
-
- if (service_format_recent_seek_range(&seek_parse_result, ctx->rtsp.playseek_range_start,
- sizeof(ctx->rtsp.playseek_range_start)) > 0) {
- ctx->rtsp.use_playseek_range = 1;
- resolved_seek_param_name = NULL;
- }
-
- /* Resolve URL templates / seek params, then parse. Always forward the
- * parsed seek result so URL templates (${start}/${end}/...) expand even
- * when use_playseek_range=1; suppression of the literal playseek query
- * append on the non-template branch is already handled via the
- * resolved_seek_param_name=NULL gating above. */
- char resolved_rtsp_url[2048];
- if (service_resolve_upstream_url(service->rtsp_url, resolved_seek_param_name, &seek_parse_result,
- resolved_rtsp_url, sizeof(resolved_rtsp_url)) < 0) {
- logger(LOG_ERROR, "RTSP: Failed to resolve upstream URL");
- return -1;
- }
- if (rtsp_parse_server_url(&ctx->rtsp, resolved_rtsp_url, NULL, NULL) < 0) {
- logger(LOG_ERROR, "RTSP: Failed to parse URL");
- return -1;
- }
-
- if (rtsp_connect(&ctx->rtsp) < 0) {
- logger(LOG_ERROR, "RTSP: Failed to initiate connection");
- return -1;
- }
-
- /* Connection initiated - handshake will proceed asynchronously via event
- * loop */
- logger(LOG_DEBUG, "RTSP: Async connection initiated, state=%d", ctx->rtsp.state);
} else {
/* Multicast-based services (FCC or direct multicast) */
mcast_session_init(&ctx->mcast);
diff --git a/src/stream.h b/src/stream.h
index c708571a..fa8d476a 100644
--- a/src/stream.h
+++ b/src/stream.h
@@ -18,6 +18,70 @@
* fallback to streaming */
#define SNAPSHOT_TIMEOUT_SEC 2
+#define STREAM_PLAYBACK_RANGE_SIZE 256
+
+/* Return values of stream_handle_fd_event() / rtsp_handle_socket_event(). */
+#define STREAM_EVENT_OK 0
+#define STREAM_EVENT_CLOSE (-1)
+#define STREAM_EVENT_DURATION_READY (-2)
+#define STREAM_EVENT_METADATA_READY (-3)
+
+/* RTSP handshake stages a metadata field can be learned from. Used to forget
+ * the right subset when a stage is retried (auth) or replayed against another
+ * server (redirect). */
+#define STREAM_METADATA_STAGE_DESCRIBE 0x1u
+#define STREAM_METADATA_STAGE_SETUP 0x2u
+#define STREAM_METADATA_STAGE_PLAY 0x4u
+#define STREAM_METADATA_STAGE_ALL \
+ (STREAM_METADATA_STAGE_DESCRIBE | STREAM_METADATA_STAGE_SETUP | STREAM_METADATA_STAGE_PLAY)
+
+typedef enum {
+ STREAM_UPSTREAM_UNKNOWN = 0,
+ STREAM_UPSTREAM_RTSP,
+ STREAM_UPSTREAM_MULTICAST
+} stream_upstream_protocol_t;
+
+typedef enum {
+ STREAM_TRANSPORT_UNKNOWN = 0,
+ STREAM_TRANSPORT_TCP_INTERLEAVED,
+ STREAM_TRANSPORT_UDP
+} stream_upstream_transport_t;
+
+typedef enum {
+ STREAM_PAYLOAD_UNKNOWN = 0,
+ STREAM_PAYLOAD_MP2T_RTP,
+ STREAM_PAYLOAD_MP2T_DIRECT
+} stream_upstream_payload_t;
+
+typedef enum { STREAM_FCC_TYPE_UNKNOWN = 0, STREAM_FCC_TYPE_TELECOM, STREAM_FCC_TYPE_HUAWEI } stream_fcc_type_t;
+
+typedef enum {
+ STREAM_FCC_STATUS_UNKNOWN = 0,
+ STREAM_FCC_STATUS_ACTIVE,
+ STREAM_FCC_STATUS_FALLBACK
+} stream_fcc_status_t;
+
+typedef enum {
+ STREAM_MEDIA_ORIGIN_RTSP = 0,
+ STREAM_MEDIA_ORIGIN_MULTICAST,
+ STREAM_MEDIA_ORIGIN_FCC_UNICAST,
+ STREAM_MEDIA_ORIGIN_FCC_MULTICAST
+} stream_media_origin_t;
+
+typedef struct stream_metadata_s {
+ stream_upstream_protocol_t upstream_protocol;
+ stream_upstream_transport_t upstream_transport;
+ stream_upstream_payload_t upstream_payload;
+ stream_fcc_type_t fcc_type;
+ stream_fcc_status_t fcc_status;
+ double playback_scale;
+ double media_duration;
+ char playback_range[STREAM_PLAYBACK_RANGE_SIZE];
+ uint8_t playback_scale_known;
+ uint8_t media_duration_known;
+ uint8_t frozen;
+} stream_metadata_t;
+
/* Stream processing context */
typedef struct stream_context_s {
int epoll_fd;
@@ -31,6 +95,9 @@ typedef struct stream_context_s {
uint64_t last_bytes_sent; /* Bytes sent at last bandwidth calculation */
int64_t last_status_update; /* Last status update time in milliseconds */
+ /* Metadata exposed as R2H-* HTTP response headers. */
+ stream_metadata_t metadata;
+
/* FCC session for Fast Channel Change */
fcc_session_t fcc;
@@ -69,16 +136,26 @@ typedef struct stream_context_s {
int stream_context_init_for_worker(stream_context_t *ctx, connection_t *conn, service_t *service, int epoll_fd,
int status_index, int is_snapshot);
+/**
+ * Initialize an RTSP control-plane-only HEAD probe. The probe performs
+ * OPTIONS and DESCRIBE but never SETUP or PLAY and allocates no media sockets.
+ */
+int stream_context_init_rtsp_metadata_probe(stream_context_t *ctx, connection_t *conn, service_t *service,
+ int epoll_fd);
+
/**
* Handle an event-ready fd that belongs to this stream context.
* @param ctx Stream context
* @param fd File descriptor that has events
* @param events Epoll event mask (EPOLLIN, EPOLLOUT, etc.)
* @param now Current timestamp in milliseconds (from get_time_ms())
- * @return Return values:
- * 0: Success, continue processing
- * -1: Connection should be closed (error or graceful TEARDOWN complete)
- * -2: Duration query completed, send response to client
+ * @return One of the STREAM_EVENT_* codes:
+ * STREAM_EVENT_OK: continue processing
+ * STREAM_EVENT_CLOSE: close the connection (error or graceful
+ * TEARDOWN complete)
+ * STREAM_EVENT_DURATION_READY: duration query completed, send response
+ * STREAM_EVENT_METADATA_READY: RTSP metadata probe completed, send HEAD
+ * response to client
*/
int stream_handle_fd_event(stream_context_t *ctx, int fd, uint32_t events, int64_t now);
@@ -105,7 +182,23 @@ int stream_context_cleanup(stream_context_t *ctx);
* @return bytes forwarded (>= 0) for streaming, 1 if I-frame captured for
* snapshot, -1 on error
*/
-int stream_process_rtp_payload(stream_context_t *ctx, buffer_ref_t *buf_ref);
+int stream_process_rtp_payload(stream_context_t *ctx, buffer_ref_t *buf_ref, stream_media_origin_t origin);
+
+/** Initialize static metadata from a parsed service. */
+void stream_metadata_init(stream_metadata_t *metadata, const service_t *service);
+
+/**
+ * Forget every metadata field learned from the given RTSP handshake stages, so
+ * a retried or redirected request cannot report a previous server's answer.
+ * @param stages Bitmask of STREAM_METADATA_STAGE_* values.
+ */
+void stream_metadata_forget(stream_metadata_t *metadata, unsigned stages);
+
+/**
+ * Send a successful HTTP response with any known stream metadata appended to
+ * extra_headers. extra_headers may be NULL.
+ */
+void stream_send_http_headers(connection_t *conn, const char *content_type, const char *extra_headers);
/**
* Notify that the client send queue has just been drained (some buffers
diff --git a/src/worker.c b/src/worker.c
index 448085e0..63875a95 100644
--- a/src/worker.c
+++ b/src/worker.c
@@ -507,12 +507,24 @@ int worker_run_event_loop(int *listen_sockets, int num_sockets, int notif_fd) {
int res = stream_handle_fd_event(&c->stream, fd_ready, events[e].events, now);
if (res < 0) {
/* Send 200 for r2h-duration request */
- if (res == -2) {
+ if (res == STREAM_EVENT_DURATION_READY) {
send_http_headers(c, STATUS_200, "application/json", NULL);
char response[64];
snprintf(response, sizeof(response), "{\"duration\": \"%0.3f\"}", c->stream.rtsp.r2h_duration_value);
connection_queue_output_and_flush(c, (const uint8_t *)response, strlen(response));
+ } else if (res == STREAM_EVENT_METADATA_READY) {
+ /* A metadata probe has no RTSP media session to tear down. Close
+ * and unregister its control socket before queuing the HEAD
+ * response so a later upstream HUP cannot close the client. */
+ if (stream_context_cleanup(&c->stream) != 0) {
+ logger(LOG_ERROR, "Worker: Metadata probe unexpectedly required async cleanup");
+ http_send_503(c);
+ continue;
+ }
+ c->streaming = 0;
+ stream_send_http_headers(c, "video/mp2t", NULL);
+ connection_queue_output_and_flush(c, NULL, 0);
} else if (!c->headers_sent && c->state != CONN_CLOSING) {
/* Send 503 if headers not sent yet (no data ever arrived) */
http_send_503(c);