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 335d5446..c7e92518 100644 --- a/.agents/skills/translate-docs-zh-en/references/translation-memory.md +++ b/.agents/skills/translate-docs-zh-en/references/translation-memory.md @@ -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 diff --git a/docs/en/guide/url-formats.md b/docs/en/guide/url-formats.md index 34c0693a..7f499bac 100644 --- a/docs/en/guide/url-formats.md +++ b/docs/en/guide/url-formats.md @@ -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 @@ -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 diff --git a/docs/en/guide/web-player.md b/docs/en/guide/web-player.md index 66f1ec65..46b93a2f 100644 --- a/docs/en/guide/web-player.md +++ b/docs/en/guide/web-player.md @@ -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: @@ -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 diff --git a/docs/guide/url-formats.md b/docs/guide/url-formats.md index 07e3057a..6f8e1f6e 100644 --- a/docs/guide/url-formats.md +++ b/docs/guide/url-formats.md @@ -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**(可选):下载文件名,详见 [下载文件名](#下载文件名) ### 使用场景 @@ -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 diff --git a/docs/guide/web-player.md b/docs/guide/web-player.md index 94f721eb..742e6c44 100644 --- a/docs/guide/web-player.md +++ b/docs/guide/web-player.md @@ -2,6 +2,20 @@ rtp2httpd 内置了一个基于 Web 的现代化播放器,可以直接在浏览器中观看已配置的 M3U 频道列表,无需安装任何客户端。 +## 功能特性 + +- **频道列表**:自动加载配置的 M3U 频道列表 +- **在线直播**:在浏览器中直接观看直播 +- **时移回看**:支持 EPG 电子节目单和时移回看(需要有回看源) +- **快速起播**:搭配 FCC 可实现毫秒级换台速度 +- **无缝换台**:换台时预加载新流,减少黑屏 +- **响应式设计**:桌面和移动端 UI 自适应 +- **PWA 支持**:可添加到手机、平板、桌面或 LG webOS 电视主屏幕,像原生应用一样快捷打开 +- **零开销**:纯 Web 前端实现,对 rtp2httpd 运行几乎没有资源占用(无解码转码开销) + +> [!IMPORTANT] +> 播放器依赖浏览器的原生解码能力,部分编码格式(如 E-AC3)可能在某些浏览器中无法播放(表现为无音频、画面黑屏)。推荐使用最新版本的 Chrome、Edge 或 Safari。 + ## 访问方式 配置好 M3U 播放列表后,通过浏览器访问: @@ -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 支持与添加到主屏幕 diff --git a/e2e/test_http_proxy.py b/e2e/test_http_proxy.py index 92d4a3a4..18540144 100644 --- a/e2e/test_http_proxy.py +++ b/e2e/test_http_proxy.py @@ -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 diff --git a/e2e/test_multicast.py b/e2e/test_multicast.py index b02db99f..8cf617be 100644 --- a/e2e/test_multicast.py +++ b/e2e/test_multicast.py @@ -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", @@ -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", @@ -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.""" diff --git a/e2e/test_rtsp_transport.py b/e2e/test_rtsp_transport.py index fdfaf9f8..e14c3a3a 100644 --- a/e2e/test_rtsp_transport.py +++ b/e2e/test_rtsp_transport.py @@ -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" diff --git a/src/http.c b/src/http.c index 377b3421..f8a76f53 100644 --- a/src/http.c +++ b/src/http.c @@ -157,6 +157,159 @@ char *http_url_encode(const char *str) { return encoded; } +static int http_filename_forbidden_char(unsigned char c) { + return c < 0x20 || c == 0x7f || c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || + c == '>' || c == '|'; +} + +static size_t http_utf8_char_len(unsigned char c) { + if ((c & 0x80) == 0) + return 1; + if ((c & 0xE0) == 0xC0) + return 2; + if ((c & 0xF0) == 0xE0) + return 3; + if ((c & 0xF8) == 0xF0) + return 4; + return 0; +} + +static int http_filename_has_ts_suffix(const char *name, size_t len) { + return len >= 3 && strcasecmp(name + len - 3, ".ts") == 0; +} + +int http_sanitize_download_filename(const char *input, char *output, size_t output_size) { + char tmp[HTTP_DOWNLOAD_FILENAME_MAX + 8]; + size_t out_len = 0; + size_t i = 0; + int pending_sep = 1; + size_t name_len; + + if (!input || !output || output_size < 5) + return -1; + + while (input[i] && out_len < HTTP_DOWNLOAD_FILENAME_MAX) { + unsigned char c = (unsigned char)input[i]; + size_t char_len; + + if (http_filename_forbidden_char(c)) { + if (!pending_sep && out_len + 1 <= HTTP_DOWNLOAD_FILENAME_MAX) { + tmp[out_len++] = '_'; + pending_sep = 1; + } + i++; + continue; + } + + char_len = http_utf8_char_len(c); + if (char_len == 0) { + i++; + continue; + } + if (out_len + char_len > HTTP_DOWNLOAD_FILENAME_MAX) + break; + + memcpy(tmp + out_len, input + i, char_len); + out_len += char_len; + i += char_len; + pending_sep = (c == '_' || c == ' ' || c == '.'); + } + + while (out_len > 0) { + char tail = tmp[out_len - 1]; + if (tail == '_' || tail == '.' || tail == ' ') + out_len--; + else + break; + } + + if (out_len == 0) + return -1; + + tmp[out_len] = '\0'; + + if (!http_filename_has_ts_suffix(tmp, out_len)) { + if (out_len + 3 >= sizeof(tmp)) + out_len = HTTP_DOWNLOAD_FILENAME_MAX - 3; + tmp[out_len++] = '.'; + tmp[out_len++] = 't'; + tmp[out_len++] = 's'; + tmp[out_len] = '\0'; + } + + name_len = strlen(tmp); + if (name_len + 1 > output_size) + return -1; + memcpy(output, tmp, name_len + 1); + return 0; +} + +static int http_is_rfc5987_attr_char(unsigned char c) { + return isalnum((int)c) || c == '!' || c == '#' || c == '$' || c == '&' || c == '+' || c == '-' || c == '.' || + c == '^' || c == '_' || c == '`' || c == '|' || c == '~'; +} + +int http_build_content_disposition_header(const char *filename, char *output, size_t output_size) { + char ascii[HTTP_DOWNLOAD_FILENAME_MAX + 8]; + char encoded[(HTTP_DOWNLOAD_FILENAME_MAX + 8) * 3]; + static const char hex_chars[] = "0123456789ABCDEF"; + size_t i; + size_t ascii_len = 0; + size_t encoded_len = 0; + int has_non_ascii = 0; + int ascii_has_alnum = 0; + int written; + + if (!filename || !filename[0] || !output || output_size == 0) + return -1; + + for (i = 0; filename[i]; i++) { + unsigned char c = (unsigned char)filename[i]; + if (c >= 0x80) { + has_non_ascii = 1; + if (ascii_len + 1 < sizeof(ascii)) + ascii[ascii_len++] = '_'; + } else if (c != '"') { + if (ascii_len + 1 < sizeof(ascii)) + ascii[ascii_len++] = (char)c; + if (isalnum((int)c)) + ascii_has_alnum = 1; + } + } + ascii[ascii_len] = '\0'; + if (!ascii_has_alnum) + snprintf(ascii, sizeof(ascii), "download.ts"); + + if (!has_non_ascii) { + written = snprintf(output, output_size, "Content-Disposition: attachment; filename=\"%s\"\r\n", filename); + if (written < 0 || (size_t)written >= output_size) + return -1; + return written; + } + + for (i = 0; filename[i]; i++) { + unsigned char c = (unsigned char)filename[i]; + if (http_is_rfc5987_attr_char(c)) { + if (encoded_len + 1 >= sizeof(encoded)) + return -1; + encoded[encoded_len++] = (char)c; + } else { + if (encoded_len + 3 >= sizeof(encoded)) + return -1; + encoded[encoded_len++] = '%'; + encoded[encoded_len++] = hex_chars[c >> 4]; + encoded[encoded_len++] = hex_chars[c & 0x0F]; + } + } + encoded[encoded_len] = '\0'; + + written = snprintf(output, output_size, "Content-Disposition: attachment; filename=\"%s\"; filename*=UTF-8''%s\r\n", + ascii, encoded); + if (written < 0 || (size_t)written >= output_size) + return -1; + return written; +} + int http_build_r2h_token_cookie_header(char *buffer, size_t buffer_size, const char *path) { char *encoded_token; int written; diff --git a/src/http.h b/src/http.h index 444f08e4..43dd17a4 100644 --- a/src/http.h +++ b/src/http.h @@ -117,6 +117,28 @@ int http_url_decode(char *str); */ char *http_url_encode(const char *str); +/* Max sanitized download filename (excluding NUL), including the .ts suffix. */ +#ifndef HTTP_DOWNLOAD_FILENAME_MAX +#define HTTP_DOWNLOAD_FILENAME_MAX 180 +#endif + +/** + * Sanitize a client-supplied download filename for Content-Disposition. + * Replaces path separators / control characters, truncates on a UTF-8 + * character boundary, and ensures a .ts suffix. + * + * @return 0 on success, -1 if input is empty after sanitizing or buffers are invalid + */ +int http_sanitize_download_filename(const char *input, char *output, size_t output_size); + +/** + * Build a Content-Disposition header line including the trailing CRLF. + * Uses RFC 6266 `filename` plus RFC 5987 `filename*` when the name is not ASCII. + * + * @return Bytes written, or -1 on error / truncation + */ +int http_build_content_disposition_header(const char *filename, char *output, size_t output_size); + /** * Build a Set-Cookie header for the configured r2h-token. * The cookie value is URL-encoded so token characters such as ';' remain safe diff --git a/src/service.c b/src/service.c index 5a92191a..702c5033 100644 --- a/src/service.c +++ b/src/service.c @@ -1235,6 +1235,7 @@ service_t *service_create_from_http_url(const char *http_url) { &result->seek_mode_tz_offset_seconds, &result->seek_mode_window_seconds); service_extract_ifname_params(query_start, &result->ifname, &result->ifname_fcc); service_strip_query_param(query_start, "r2h-token"); + service_strip_query_param(query_start, "r2h-filename"); } logger(LOG_DEBUG, "Created HTTP proxy service: %s -> %s", http_url, result->http_url); @@ -1325,6 +1326,7 @@ service_t *service_create_from_rtsp_url(const char *http_url) { } service_extract_ifname_params(query_start, &ifname, &ifname_fcc); service_strip_query_param(query_start, "r2h-token"); + service_strip_query_param(query_start, "r2h-filename"); } /* Allocate service structure */ @@ -1643,6 +1645,7 @@ service_t *service_create_from_rtp_url(const char *http_url) { char *qstart = strchr(url_part, '?'); service_extract_ifname_params(qstart, &result->ifname, &result->ifname_fcc); service_strip_query_param(qstart, "r2h-token"); + service_strip_query_param(qstart, "r2h-filename"); } /* Build and store full RTP URL (rtp://) - r2h-* auth/control params stripped */ diff --git a/src/stream.c b/src/stream.c index 7e653f2a..064e4166 100644 --- a/src/stream.c +++ b/src/stream.c @@ -23,7 +23,7 @@ #include #include -#define STREAM_METADATA_HEADERS_SIZE 1024 +#define STREAM_METADATA_HEADERS_SIZE 1536 #define TS_PACKET_SIZE 188 #define TS_SYNC_BYTE 0x47 @@ -200,6 +200,35 @@ static void stream_metadata_note_media(stream_context_t *ctx, int packet_type, c } } +static int stream_append_download_filename_header(connection_t *conn, char *headers, size_t headers_size, + size_t *length) { + const char *query; + char raw[768]; + char sanitized[HTTP_DOWNLOAD_FILENAME_MAX + 8]; + char line[768]; + + if (!conn) + return 0; + + query = strchr(conn->http_req.url, '?'); + if (!query) + return 0; + if (http_parse_query_param(query + 1, "r2h-filename", raw, sizeof(raw)) != 0) + return 0; + if (raw[0] == '\0') + return 0; + if (http_sanitize_download_filename(raw, sanitized, sizeof(sanitized)) != 0) + return 0; + if (http_build_content_disposition_header(sanitized, line, sizeof(line)) < 0) + return 0; + /* Omit the download name rather than dropping the rest of the metadata. */ + if (stream_metadata_append_raw(headers, headers_size, length, line) < 0) { + logger(LOG_DEBUG, "Stream: Content-Disposition header omitted (buffer full)"); + return 0; + } + return 0; +} + 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]; @@ -217,6 +246,9 @@ void stream_send_http_headers(connection_t *conn, const char *content_type, cons if (extra_headers && extra_headers[0]) failed |= stream_metadata_append_raw(headers, sizeof(headers), &length, extra_headers) < 0; + if (content_type && strcmp(content_type, "video/mp2t") == 0) + stream_append_download_filename_header(conn, headers, sizeof(headers), &length); + 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; diff --git a/web-ui/src/components/player/channel-list-item.tsx b/web-ui/src/components/player/channel-list-item.tsx index 804d4878..425c1115 100644 --- a/web-ui/src/components/player/channel-list-item.tsx +++ b/web-ui/src/components/player/channel-list-item.tsx @@ -1,8 +1,9 @@ import { clsx } from "clsx"; import { History } from "lucide-react"; -import { forwardRef, memo, useCallback } from "react"; +import { forwardRef, memo, type MouseEvent as ReactMouseEvent, useCallback } from "react"; import { usePlayerTranslation } from "../../hooks/use-player-translation"; import type { Locale } from "../../lib/locale"; +import { isMiddleMouseButton } from "../../lib/media-direct-link"; import type { Channel } from "../../types/player"; import { PLAYER_CHANNEL_LIST_ITEM_CLASS, @@ -17,12 +18,13 @@ interface ChannelListItemProps { channel: Channel; isCurrentChannel: boolean; handleChannelClick: (channel: Channel) => void; + onCopyMediaLink: (channel: Channel) => void; locale: Locale; currentProgram?: string; } const ChannelListItemComponent = forwardRef( - ({ channel, isCurrentChannel, handleChannelClick, locale, currentProgram }, ref) => { + ({ channel, isCurrentChannel, handleChannelClick, onCopyMediaLink, locale, currentProgram }, ref) => { const t = usePlayerTranslation(locale); const groupLabel = channel.groups.join(" / "); @@ -30,6 +32,20 @@ const ChannelListItemComponent = forwardRef) => { + if (isMiddleMouseButton(event)) event.preventDefault(); + }, []); + + const handleAuxClick = useCallback( + (event: ReactMouseEvent) => { + if (!isMiddleMouseButton(event)) return; + event.preventDefault(); + event.stopPropagation(); + onCopyMediaLink(channel); + }, + [channel, onCopyMediaLink], + ); + return (