Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
3 changes: 2 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions docs/en/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/en/guide/video-snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/video-snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-响应头)。

## 故障排查

### 快照请求返回视频流而不是图片
Expand Down
2 changes: 2 additions & 0 deletions e2e/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
get_upstream_path,
http_get,
http_request,
raw_http_request,
stream_get,
unix_http_get,
unix_http_request,
Expand Down Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions e2e/helpers/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
84 changes: 76 additions & 8 deletions e2e/helpers/mock_rtsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand All @@ -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",
):
"""
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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__(
Expand All @@ -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__(
Expand All @@ -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:
Expand All @@ -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
Expand Down
10 changes: 6 additions & 4 deletions e2e/helpers/rtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion e2e/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,15 @@ 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,
timeout=10.0,
)
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"
Expand Down
Loading
Loading