Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 2 additions & 0 deletions docs/en/guide/fcc-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ rtp://239.253.64.120:5140/?fcc=10.255.14.152:15970

The difference between the two protocols is very small. In most cases, `telecom` will work. In some specific network environments, the `huawei` protocol may be required.

FCC media responses report the configured protocol in `R2H-FCC-Type` and the startup result in `R2H-FCC-Status`. The status is `active` when FCC unicast first produces valid output, or `fallback` when FCC does not succeed and multicast produces the initial output. This value is a response-time snapshot; a later transition from FCC unicast to multicast does not change the header already sent. A `HEAD` request returns FCC Type only, sends no FCC request, and omits FCC Status. See [URL Formats](/en/guide/url-formats#upstream-stream-metadata-response-headers) for the complete field reference.

### Configure FCC in M3U

If you are using an M3U playlist, you can add FCC parameters to the URL of each channel:
Expand Down
25 changes: 25 additions & 0 deletions docs/en/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,31 @@ 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 return confirmed upstream metadata in `R2H-*` response headers. Fields that are unknown, invalid, or not yet confirmed when the response is sent are omitted.

| 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 FCC unicast produced the initial output; `fallback` when multicast produced it. |

These response headers are a snapshot taken when the HTTP headers are sent. Changes after stream startup do not update them. FEC status is not reported, and sensitive data such as RTSP sessions, authentication information, upstream addresses and ports, and raw SDP is never exposed.

MPEG-TS responses and successfully generated JPEG snapshots include all fields confirmed at that point. `HEAD` requests behave differently:

- RTSP `HEAD` connects upstream and performs only `OPTIONS` and `DESCRIBE`. It can therefore return Protocol and any Payload or Duration confirmed by SDP, but it never performs `SETUP` or `PLAY`.
- Multicast and FCC `HEAD` requests do not join multicast groups or send FCC requests. They return Protocol and, when configured, FCC Type only.
- HTTP reverse proxy `HEAD` requests continue to be handled by the upstream HTTP service.

When `cors-allow-origin` is configured, all eight fields are listed in `Access-Control-Expose-Headers` so browser JavaScript can read them through the Fetch API.

## 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 all `R2H-*` upstream stream metadata confirmed before conversion. If snapshot conversion fails and falls back to a media stream, the media response sends the metadata once; no JPEG headers are sent in advance. 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
2 changes: 2 additions & 0 deletions docs/guide/fcc-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ rtp://239.253.64.120:5140/?fcc=10.255.14.152:15970

两种协议区别很小,大多数情况使用 `telecom` 就可以工作。在一些特定网络环境下可能需要 `huawei` 协议。

FCC 媒体响应会通过 `R2H-FCC-Type` 返回实际配置的协议,并通过 `R2H-FCC-Status` 返回起播结果:FCC 单播首先产生有效输出时为 `active`,FCC 未成功且首先从组播输出时为 `fallback`。这个状态是响应发出时的快照;后续从 FCC 单播切换到组播不会改变已经发送的 Header。`HEAD` 请求只返回 FCC Type,不发送 FCC 请求,也不返回 FCC Status。完整字段说明见 [URL 格式说明](./url-formats.md#上游流-metadata-响应头)。

### 在 M3U 中配置 FCC

如果使用 M3U 播放列表,可以在每个频道的 URL 中添加 FCC 参数:
Expand Down
25 changes: 25 additions & 0 deletions docs/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,31 @@ http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?r2h-ifname=eth0

关于时移回看的参数处理(时区、偏移),详见 [时间处理说明](./time-processing.md)。

## 上游流 Metadata 响应头

RTSP、普通组播和 FCC 流会在媒体响应中返回已确认的 `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 Header 时的状态快照,流启动后的状态变化不会更新它们。不会返回 FEC 工作状态,也不会暴露 RTSP Session、认证信息、上游地址、端口或原始 SDP 等敏感信息。

MPEG-TS 响应和成功生成的 JPEG 快照都会携带当时已经确认的字段。`HEAD` 请求的行为有所不同:

- RTSP `HEAD` 只连接上游并执行 `OPTIONS` 和 `DESCRIBE`,因此只可能返回 Protocol,以及 SDP 能确认的 Payload 和 Duration;不会执行 `SETUP` 或 `PLAY`。
- 组播和 FCC `HEAD` 不加入组播、不发送 FCC 请求,只返回 Protocol,以及已配置时的 FCC Type。
- HTTP 反向代理的 `HEAD` 仍由上游 HTTP 服务处理。

配置 `cors-allow-origin` 后,以上八个字段也会列入 `Access-Control-Expose-Headers`,网页 JavaScript 可以通过 Fetch API 读取。

## 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。若快照转换失败并回退为媒体流,则由媒体流响应发送一次 Metadata,不会提前发送 JPEG Header。字段说明见 [URL 格式说明](./url-formats.md#上游流-metadata-响应头)。

## 故障排查

### 快照请求返回视频流而不是图片
Expand Down
27 changes: 26 additions & 1 deletion e2e/helpers/mock_rtsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ 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,
host: str = "127.0.0.1",
):
"""
Expand All @@ -44,6 +47,10 @@ 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).
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.
host: Address to listen on (use "::1" for IPv6 loopback).
"""
self.host = host
Expand All @@ -52,6 +59,9 @@ 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._server_sock: socket.socket | None = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
Expand Down Expand Up @@ -191,11 +201,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 @@ -233,6 +252,9 @@ 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,
host: str = "127.0.0.1",
):
super().__init__(
Expand All @@ -241,6 +263,9 @@ 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,
host=host,
)
self._num_packets = num_packets
Expand Down
4 changes: 3 additions & 1 deletion e2e/helpers/rtp.py
Original file line number Diff line number Diff line change
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,6 +65,7 @@ def __init__(
self.reorder_distance = reorder_distance
self.unique_payloads = unique_payloads
self.send_duplicates = send_duplicates
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
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
47 changes: 42 additions & 5 deletions e2e/test_fcc.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
find_free_port,
find_free_udp_port,
find_free_udp_port_pair,
get_header,
http_request,
stream_get,
)

Expand Down Expand Up @@ -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}",
Expand All @@ -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()

Expand All @@ -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}",
Expand All @@ -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()

Expand All @@ -118,7 +126,7 @@ def test_fcc_redirect_to_unicast_stream(self, shared_r2h):
target.start()
redirect.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:{redirect.port}",
Expand Down Expand Up @@ -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,
Expand All @@ -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()

Expand Down Expand Up @@ -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}",
Expand All @@ -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()
1 change: 1 addition & 0 deletions e2e/test_http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading