Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- 快速换台 -> fast channel change / channel switching
- 无缝换台 -> seamless channel switching / Seamless switch (player setting label)
- 时移回看 -> time-shifted playback / time-shift / catch-up
- 媒体直链 -> media link / direct media URL
- 电子节目单 -> EPG (Electronic Program Guide)
- 频道 -> channel
- 线路/源 -> source
Expand Down
14 changes: 14 additions & 0 deletions docs/en/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ http://192.168.1.1:5140/rtp/239.253.64.120:5140?fcc=10.255.14.152:15970&r2h-ifna
- **fec** (optional): FEC (Forward Error Correction) port number, used to receive FEC redundant packets for packet loss recovery
- **r2h-ifname** (optional): Specify the upstream network interface to use (overrides global configuration)
- **r2h-ifname-fcc** (optional): Specify the upstream network interface for FCC (overrides global configuration)
- **r2h-filename** (optional): Download filename. See [Download Filename](#download-filename)

### Use Cases

Expand Down Expand Up @@ -106,6 +107,19 @@ RTSP, standard multicast, and FCC streams carry the following `R2H-*` metadata i
| `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. |

## Download Filename

Multicast RTP and RTSP MPEG-TS responses honor the `r2h-filename` query parameter. When it is non-empty, rtp2httpd sends `Content-Disposition: attachment` so browsers save the download under that name. The server strips path separators and control characters, and appends `.ts` when the suffix is missing.

This parameter is local to rtp2httpd and is not forwarded upstream. HTTP/HLS reverse proxy requests strip it before the upstream fetch and do not set `Content-Disposition`.

```url
http://192.168.1.1:5140/rtp/239.253.64.120:5140?r2h-filename=CCTV-1.ts
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?playseek=20240101120000-20240101130000&r2h-filename=CCTV-1_News_20240101-120000_20240101-130000.ts
```

The built-in web player adds this parameter automatically when you copy a media link.

## HTTP Reverse Proxy

```url
Expand Down
33 changes: 22 additions & 11 deletions docs/en/guide/web-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

rtp2httpd includes a modern web-based player that allows you to watch configured M3U channel lists directly in your browser without installing any client software.

## Features

- **Channel List**: Automatically loads configured M3U channel lists
- **Live Streaming**: Watch live broadcasts directly in the browser
- **Time-Shifted Playback**: Supports EPG (Electronic Program Guide) and time-shifted playback (requires catchup source)
- **Fast Startup**: Achieves millisecond-level channel switching with FCC
- **Seamless Channel Switching**: Preloads the new stream when switching channels to reduce black screens
- **Responsive Design**: UI adapts to both desktop and mobile devices
- **PWA Support**: Can be added to the home screen on phones, tablets, desktops, or LG webOS TVs for app-like quick access
- **Zero Overhead**: Pure web frontend implementation with virtually no resource overhead on rtp2httpd (no decoding/transcoding overhead)

> [!IMPORTANT]
> The player relies on the browser's native decoding capabilities. Some encoding formats (such as E-AC3) may not play in certain browsers (manifested as no audio or black screen). We recommend using the latest versions of Chrome, Edge, or Safari.

## Access

After configuring M3U playlists, access the player via your browser:
Expand Down Expand Up @@ -29,19 +43,16 @@ http://server:port/player#CCTV-1

The player automatically updates the address bar when you switch channels, so you can copy the link at any time to share the channel currently playing.

## Features
## Copy Media Links

- **Channel List**: Automatically loads configured M3U channel lists
- **Live Streaming**: Watch live broadcasts directly in the browser
- **Time-Shifted Playback**: Supports EPG (Electronic Program Guide) and time-shifted playback (requires catchup source)
- **Fast Startup**: Achieves millisecond-level channel switching with FCC
- **Seamless Channel Switching**: Preloads the new stream when switching channels to reduce black screens
- **Responsive Design**: UI adapts to both desktop and mobile devices
- **PWA Support**: Can be added to the home screen on phones, tablets, desktops, or LG webOS TVs for app-like quick access
- **Zero Overhead**: Pure web frontend implementation with virtually no resource overhead on rtp2httpd (no decoding/transcoding overhead)
Middle-click a channel in the channel list to copy that channel's live URL for the current source. Middle-click a program in the program guide to copy that program's catch-up URL (the channel must have `catchup-source` configured). A toast appears after a successful copy.

> [!IMPORTANT]
> The player relies on the browser's native decoding capabilities. Some encoding formats (such as E-AC3) may not play in certain browsers (manifested as no audio or black screen). We recommend using the latest versions of Chrome, Edge, or Safari.
The copied value is a directly requestable HTTP media URL, which you can download with FFmpeg, N_m3u8DL-RE, IDM, or open in a third-party player. For multi-source channels, the most recently used source is copied. If the current page includes `r2h-token`, the copied link includes that parameter as well.

Multicast and RTSP links also include an `r2h-filename` query parameter. Its value is a `.ts` filename built from the channel name, source label, program title, and time range, so a browser download uses that name. See [URL Formats](/en/guide/url-formats#download-filename).

> [!NOTE]
> An on-air program with no catch-up source copies the live URL. A program that has not started and has no catch-up source cannot be copied.

## PWA Support and Add to Home Screen

Expand Down
14 changes: 14 additions & 0 deletions docs/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ http://192.168.1.1:5140/rtp/239.253.64.120:5140?fcc=10.255.14.152:15970&r2h-ifna
- **fec**(可选):FEC 前向纠错端口号,用于接收 FEC 冗余数据包来恢复丢包
- **r2h-ifname**(可选):指定使用的上游网络接口(覆盖全局配置)
- **r2h-ifname-fcc**(可选):指定 FCC 使用的上游网络接口(覆盖全局配置)
- **r2h-filename**(可选):下载文件名,详见 [下载文件名](#下载文件名)

### 使用场景

Expand Down Expand Up @@ -106,6 +107,19 @@ RTSP、普通组播和 FCC 流会在 HTTP Headers 带上以下 `R2H-*` Metadata
| `R2H-FCC-Type` | 已配置的 FCC 协议:`telecom` 或 `huawei`。 |
| `R2H-FCC-Status` | 从 FCC 单播起播为 `active`;从组播起播为 `fallback`。 |

## 下载文件名

组播 RTP 和 RTSP 的 MPEG-TS 响应支持 `r2h-filename` 查询参数。参数非空时,rtp2httpd 会输出 `Content-Disposition: attachment`,让浏览器按指定文件名保存下载。服务端会去掉路径分隔符和控制字符,并在缺少后缀时补上 `.ts`。

该参数只作用于 rtp2httpd 自身,不会转发给上游。HTTP/HLS 反向代理会在转发前剥离该参数,也不会设置 `Content-Disposition`。

```url
http://192.168.1.1:5140/rtp/239.253.64.120:5140?r2h-filename=CCTV-1.ts
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?playseek=20240101120000-20240101130000&r2h-filename=CCTV-1_新闻联播_20240101-120000_20240101-130000.ts
```

内置 Web 播放器复制媒体直链时会自动带上该参数。

## HTTP 反向代理

```url
Expand Down
33 changes: 22 additions & 11 deletions docs/guide/web-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

rtp2httpd 内置了一个基于 Web 的现代化播放器,可以直接在浏览器中观看已配置的 M3U 频道列表,无需安装任何客户端。

## 功能特性

- **频道列表**:自动加载配置的 M3U 频道列表
- **在线直播**:在浏览器中直接观看直播
- **时移回看**:支持 EPG 电子节目单和时移回看(需要有回看源)
- **快速起播**:搭配 FCC 可实现毫秒级换台速度
- **无缝换台**:换台时预加载新流,减少黑屏
- **响应式设计**:桌面和移动端 UI 自适应
- **PWA 支持**:可添加到手机、平板、桌面或 LG webOS 电视主屏幕,像原生应用一样快捷打开
- **零开销**:纯 Web 前端实现,对 rtp2httpd 运行几乎没有资源占用(无解码转码开销)

> [!IMPORTANT]
> 播放器依赖浏览器的原生解码能力,部分编码格式(如 E-AC3)可能在某些浏览器中无法播放(表现为无音频、画面黑屏)。推荐使用最新版本的 Chrome、Edge 或 Safari。

## 访问方式

配置好 M3U 播放列表后,通过浏览器访问:
Expand Down Expand Up @@ -29,19 +43,16 @@ http://服务器地址:端口/player#CCTV-1

播放器在切换频道时会自动更新地址栏,随时复制即可分享当前正在播放的频道。

## 功能特性
## 复制媒体直链

- **频道列表**:自动加载配置的 M3U 频道列表
- **在线直播**:在浏览器中直接观看直播
- **时移回看**:支持 EPG 电子节目单和时移回看(需要有回看源)
- **快速起播**:搭配 FCC 可实现毫秒级换台速度
- **无缝换台**:换台时预加载新流,减少黑屏
- **响应式设计**:桌面和移动端 UI 自适应
- **PWA 支持**:可添加到手机、平板、桌面或 LG webOS 电视主屏幕,像原生应用一样快捷打开
- **零开销**:纯 Web 前端实现,对 rtp2httpd 运行几乎没有资源占用(无解码转码开销)
在频道列表中键点击频道,可复制该频道当前线路的直播地址。在节目单中键点击节目,可复制该节目的回看地址(需要频道配置了 `catchup-source`)。复制成功后页面会弹出提示。

> [!IMPORTANT]
> 播放器依赖浏览器的原生解码能力,部分编码格式(如 E-AC3)可能在某些浏览器中无法播放(表现为无音频、画面黑屏)。推荐使用最新版本的 Chrome、Edge 或 Safari。
复制得到的是可直接请求的 HTTP 媒体地址,可用于 FFmpeg、N_m3u8DL-RE、IDM 等工具下载或在第三方播放器中打开。多线路频道会复制最近一次使用的线路;若当前页面带有 `r2h-token`,复制的链接也会带上该参数。

组播和 RTSP 直链还会带上 `r2h-filename` 查询参数,值为频道名、线路标签、节目名和时间范围组成的 `.ts` 文件名。用浏览器打开并触发下载时,会按该文件名保存。详见 [URL 格式说明](./url-formats.md#下载文件名)。

> [!NOTE]
> 正在播出且没有回看源的节目会复制直播地址。尚未开始且没有回看源的节目无法复制。

## PWA 支持与添加到主屏幕

Expand Down
25 changes: 25 additions & 0 deletions e2e/test_http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,31 @@ def test_r2h_token_not_forwarded(self, shared_r2h, token_param):
finally:
upstream.stop()

def test_r2h_filename_not_forwarded(self, shared_r2h):
"""r2h-filename is local download metadata and should be stripped before upstream HTTP."""
upstream = MockHTTPUpstream(
routes={
"/search": {"status": 200, "body": b"found"},
}
)
upstream.start()
try:
status, headers, body = http_get(
"127.0.0.1",
shared_r2h.port,
f"/http/127.0.0.1:{upstream.port}/search?r2h-filename=clip.ts&q=test",
timeout=5.0,
)
assert status == 200
assert body == b"found"
assert "Content-Disposition" not in {key.lower() for key in headers}
assert upstream.requests_log, "expected upstream HTTP request"
upstream_path = upstream.requests_log[0]["path"]
assert "r2h-filename" not in upstream_path.lower()
assert "q=test" in upstream_path
finally:
upstream.stop()


# ---------------------------------------------------------------------------
# Upstream unreachable
Expand Down
113 changes: 112 additions & 1 deletion e2e/test_multicast.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def test_head_rtp(self, r2h_binary):
)
try:
r2h.start()
from helpers import http_request
from helpers import get_header, http_request

status, hdrs, body = http_request(
"127.0.0.1",
Expand All @@ -257,6 +257,7 @@ def test_head_rtp(self, r2h_binary):
assert hdrs.get("R2H-Upstream-Protocol") == "multicast"
assert "R2H-Upstream-Payload" not in hdrs
assert "R2H-FCC-Status" not in hdrs
assert get_header(hdrs, "Content-Disposition") == ""
exposed = {name.strip() for name in hdrs["Access-Control-Expose-Headers"].split(",")}
assert exposed == {
"R2H-Upstream-Protocol",
Expand All @@ -271,6 +272,116 @@ def test_head_rtp(self, r2h_binary):
finally:
r2h.stop()

def test_head_rtp_sets_content_disposition_from_filename(self, r2h_binary):
port = find_free_port()
mcast_port = find_free_udp_port()
r2h = R2HProcess(
r2h_binary,
port,
extra_args=["-v", "4", "-m", "100"],
)
try:
r2h.start()
from helpers import get_header, http_request

status, hdrs, body = http_request(
"127.0.0.1",
port,
"HEAD",
f"/rtp/{MCAST_ADDR}:{mcast_port}?r2h-filename=CCTV-1.ts",
timeout=3.0,
)
assert status == 200
assert body == b""
disposition = get_header(hdrs, "Content-Disposition")
assert disposition.startswith("attachment;")
assert 'filename="CCTV-1.ts"' in disposition
finally:
r2h.stop()

def test_head_rtp_sanitizes_filename_header_injection(self, r2h_binary):
port = find_free_port()
mcast_port = find_free_udp_port()
r2h = R2HProcess(
r2h_binary,
port,
extra_args=["-v", "4", "-m", "100"],
)
try:
r2h.start()
from helpers import get_header, http_request

status, hdrs, body = http_request(
"127.0.0.1",
port,
"HEAD",
f"/rtp/{MCAST_ADDR}:{mcast_port}?r2h-filename=foo%0d%0aSet-Cookie:%20evil",
timeout=3.0,
)
assert status == 200
assert body == b""
disposition = get_header(hdrs, "Content-Disposition")
assert get_header(hdrs, "Set-Cookie") == ""
assert "\r" not in disposition
assert "\n" not in disposition
assert disposition.startswith("attachment;")
assert 'filename="foo_Set-Cookie_ evil.ts"' in disposition
finally:
r2h.stop()

def test_head_rtp_empty_filename_omits_content_disposition(self, r2h_binary):
port = find_free_port()
mcast_port = find_free_udp_port()
r2h = R2HProcess(
r2h_binary,
port,
extra_args=["-v", "4", "-m", "100"],
)
try:
r2h.start()
from helpers import get_header, http_request

status, hdrs, body = http_request(
"127.0.0.1",
port,
"HEAD",
f"/rtp/{MCAST_ADDR}:{mcast_port}?r2h-filename=",
timeout=3.0,
)
assert status == 200
assert body == b""
assert get_header(hdrs, "Content-Disposition") == ""
finally:
r2h.stop()

def test_head_rtp_utf8_filename_uses_rfc5987(self, r2h_binary):
port = find_free_port()
mcast_port = find_free_udp_port()
r2h = R2HProcess(
r2h_binary,
port,
extra_args=["-v", "4", "-m", "100"],
)
try:
r2h.start()
from helpers import get_header, http_request

status, hdrs, body = http_request(
"127.0.0.1",
port,
"HEAD",
f"/rtp/{MCAST_ADDR}:{mcast_port}?r2h-filename=%E6%B5%8B%E8%AF%95.ts",
timeout=3.0,
)
assert status == 200
assert body == b""
disposition = get_header(hdrs, "Content-Disposition")
assert disposition.startswith("attachment;")
assert "filename*=UTF-8''" in disposition
assert "%E6%B5%8B%E8%AF%95.ts" in disposition
finally:
r2h.stop()


class TestSnapshotFallbackMetadata:
"""A failed JPEG capture must fall back to one MPEG-TS response."""
Expand Down
45 changes: 45 additions & 0 deletions e2e/test_rtsp_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,51 @@ def test_head_stops_after_describe(self, shared_r2h):
finally:
rtsp.stop()

def test_head_sets_content_disposition_from_filename(self, shared_r2h):
rtsp = MockRTSPServer()
rtsp.start()
try:
status, headers, body = http_request(
"127.0.0.1",
shared_r2h.port,
"HEAD",
f"/rtsp/127.0.0.1:{rtsp.port}/stream?r2h-filename=News_1200.ts",
timeout=10.0,
)
assert status == 200
assert body == b""
disposition = get_header(headers, "Content-Disposition")
assert disposition.startswith("attachment;")
assert 'filename="News_1200.ts"' in disposition
for request in rtsp.requests_detailed:
assert "r2h-filename" not in request["uri"].lower(), (
f"r2h-filename leaked into upstream {request['method']} URI: {request['uri']}"
)
finally:
rtsp.stop()

def test_r2h_filename_does_not_leak_to_rtsp_upstream(self, shared_r2h):
rtsp = MockRTSPServer(num_packets=500)
rtsp.start()
try:
status, headers, body = stream_get(
"127.0.0.1",
shared_r2h.port,
f"/rtsp/127.0.0.1:{rtsp.port}/stream?r2h-filename=Catchup.ts",
read_bytes=4096,
timeout=_STREAM_TIMEOUT,
)
assert status == 200
assert body
assert 'filename="Catchup.ts"' in headers["content-disposition"]
assert rtsp.requests_detailed, "expected RTSP requests"
for request in rtsp.requests_detailed:
assert "r2h-filename" not in request["uri"].lower(), (
f"r2h-filename leaked into upstream {request['method']} URI: {request['uri']}"
)
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"
Expand Down
Loading
Loading