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
7 changes: 5 additions & 2 deletions docs/en/guide/time-processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,23 @@ If your upstream uses a different parameter name (e.g. `seek`, `timeshift`), spe

### r2h-seek-offset (optional)

Adds or subtracts a number of seconds to the recognized time-shift time, positive or negative. Commonly used to compensate for clock drift on the upstream server, or to shift the start time earlier/later as a whole.
Adds or subtracts a number of seconds to the recognized time-shift time, positive or negative. A single integer applies to both begin and end times; `a,b` applies `a` to the begin time and `b` to the end time. Commonly used to compensate for clock drift on the upstream server, or to shift playback begin/end boundaries independently.

```
# Shift the entire playseek range later by 1 hour (3600 seconds)
?playseek=20240101120000-20240101130000&r2h-seek-offset=3600

# Shift the begin time later by 12 seconds and the end time earlier by 12 seconds
?playseek=20240101120000-20240101130000&r2h-seek-offset=12,-12

# Shift earlier by 30 seconds
?playseek=20240101120000&r2h-seek-offset=-30
```

> [!IMPORTANT]
> `r2h-seek-offset` is a "manual time shift", not a timezone correction. It is **always** applied to the final result, even when the input time already carries its own timezone (e.g. ISO 8601 `Z` suffix, `yyyyMMddHHmmssGMT`).
>
> In Range Seek mode the offset also enters the window check — once the offset-adjusted time falls outside the window, it likewise falls back to passthrough.
> In Range Seek mode the offset also enters the window check — once the offset-adjusted begin time falls outside the window, it likewise falls back to passthrough. With the `a,b` form, Range Seek only uses the begin time, so only `a` affects the window check and the `Range: clock=` header.

### r2h-seek-mode (optional, RTSP only)

Expand Down
6 changes: 3 additions & 3 deletions docs/en/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?playseek=202401011200
# Time-shifted playback (using tvdr parameter)
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?tvdr=20240101120000GMT-20240101130000GMT

# Custom time-shift parameter name + time offset
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?seek=20240101120000&r2h-seek-name=seek&r2h-seek-offset=3600
# Custom time-shift parameter name + independent begin/end offsets
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?seek=20240101120000-20240101130000&r2h-seek-name=seek&r2h-seek-offset=12,-12

# Explicitly enable the RTSP near-realtime optimization (see r2h-seek-mode docs)
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?playseek=20240101120000&r2h-seek-mode=range(UTC%2B8/3600)
Expand All @@ -85,7 +85,7 @@ http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?r2h-ifname=eth0
This is caused by timezone mismatch. You need to perform timezone conversion. Try the following methods:

- Modify the player's User-Agent setting by adding `TZ/UTC+8` or `TZ/UTC-8`. For example, `AptvPlayer/1.3.3 TZ/UTC+8`.
- Modify the playback URL by adding the parameter `&r2h-seek-offset=28800` or `&r2h-seek-offset=-28800`
- Modify the playback URL by adding the parameter `&r2h-seek-offset=28800` or `&r2h-seek-offset=-28800`. If you only need to adjust the begin/end boundaries, use a form like `&r2h-seek-offset=12,-12` to offset them separately

For detailed information on time-shift parameter handling (timezone, offset), see [Time Processing Guide](/en/guide/time-processing).

Expand Down
7 changes: 5 additions & 2 deletions docs/guide/time-processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,23 @@ http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?playseek=202401011200

### r2h-seek-offset(可选)

对识别出的时移时间额外加 / 减若干秒,可正可负。常用于补偿上游服务器的时钟偏差,或整体提前 / 延后开始时间
对识别出的时移时间额外加 / 减若干秒,可正可负。传单个整数时同时作用于起始和结束时间;传 `a,b` 时分别作用于起始时间和结束时间。常用于补偿上游服务器的时钟偏差,或让起播/结束边界分别提前、延后

```
# playseek 范围整体后移 1 小时(3600 秒)
?playseek=20240101120000-20240101130000&r2h-seek-offset=3600

# 起始时间后移 12 秒,结束时间提前 12 秒
?playseek=20240101120000-20240101130000&r2h-seek-offset=12,-12

# 提前 30 秒
?playseek=20240101120000&r2h-seek-offset=-30
```

> [!IMPORTANT]
> `r2h-seek-offset` 是「人为时间平移」,不是时区修正。它**总是**叠加到最终结果上,即使输入时间已经自带时区(如 ISO 8601 `Z` 后缀、`yyyyMMddHHmmssGMT`),仍然生效。
>
> 在 Range Seek 模式下,offset 也会进入窗口判定——offset 后的时间一旦落出窗口,同样回退为透传。
> 在 Range Seek 模式下,offset 也会进入窗口判定——offset 后的起始时间一旦落出窗口,同样回退为透传。使用 `a,b` 形式时,Range Seek 只使用起始时间,因此只有 `a` 会影响窗口判定和 `Range: clock=` 头

### r2h-seek-mode(可选,仅 RTSP)

Expand Down
6 changes: 3 additions & 3 deletions docs/guide/url-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?playseek=202401011200
# 时移回看(使用 tvdr 参数)
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?tvdr=20240101120000GMT-20240101130000GMT

# 自定义时移参数名 + 时间偏移
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?seek=20240101120000&r2h-seek-name=seek&r2h-seek-offset=3600
# 自定义时移参数名 + 起止时间独立偏移
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?seek=20240101120000-20240101130000&r2h-seek-name=seek&r2h-seek-offset=12,-12

# 显式开启 RTSP 近实时优化(参见 r2h-seek-mode 文档)
http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?playseek=20240101120000&r2h-seek-mode=range(UTC%2B8/3600)
Expand All @@ -85,7 +85,7 @@ http://192.168.1.1:5140/rtsp/iptv.example.com:554/channel1?r2h-ifname=eth0
这是由于时区未能匹配。需要做时区转换。你可以尝试以下几种方式。

- 修改播放器 User Agent 设置,加上 `TZ/UTC+8` 或 `TZ/UTC-8`。例如 `AptvPlayer/1.3.3 TZ/UTC+8`。
- 修改播放链接,加上参数 `&r2h-seek-offset=28800` 或 `&r2h-seek-offset=-28800`
- 修改播放链接,加上参数 `&r2h-seek-offset=28800` 或 `&r2h-seek-offset=-28800`。如果只需要调整起始/结束边界,可以使用 `&r2h-seek-offset=12,-12` 这样的形式分别偏移起始和结束时间

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

Expand Down
50 changes: 50 additions & 0 deletions e2e/test_rtsp_seek_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,29 @@ def test_range_offset_propagates_into_clock(self, shared_r2h):
finally:
rtsp.stop()

def test_range_offset_pair_uses_begin_for_clock(self, shared_r2h):
"""r2h-seek-offset=a,b should use begin offset for the clock= header."""
rtsp = MockRTSPServer(num_packets=500)
rtsp.start()
try:
base_ts = int(time.time()) - 3000 # 50 min ago
begin_offset = 1800 # +30 min, still within 60min window relative to begin
end_offset = -1800
cst_str = _format_yyyyMMddHHmmss(base_ts + 8 * 3600)
url = "/rtsp/127.0.0.1:%d/stream?playseek=%s&r2h-seek-offset=%d,%d&r2h-seek-mode=range(UTC%%2B8/3600)" % (
rtsp.port,
cst_str,
begin_offset,
end_offset,
)

stream_get("127.0.0.1", shared_r2h.port, url, read_bytes=4096, timeout=_STREAM_TIMEOUT)

play_reqs = [r for r in rtsp.requests_detailed if r["method"] == "PLAY"]
assert play_reqs[0]["headers"].get("Range") == "clock=%s-" % _expected_clock_str(base_ts + begin_offset)
finally:
rtsp.stop()

def test_range_offset_pushes_outside_window_falls_back(self, shared_r2h):
"""If r2h-seek-offset shifts begin out of the window, fall back to passthrough."""
rtsp = MockRTSPServer(num_packets=500)
Expand Down Expand Up @@ -570,6 +593,33 @@ def test_request_seek_offset_overrides_configured(self, r2h_binary):
finally:
rtsp.stop()

def test_configured_seek_offset_pair_fallback(self, r2h_binary):
"""Configured r2h-seek-offset=a,b should be used when the request omits it."""
r2h_port = find_free_port()
rtsp = MockRTSPServer(num_packets=500)
rtsp.start()
try:
config = make_m3u_rtsp_config(r2h_port, rtsp.port, "OffsetPairMerge", "?r2h-seek-offset=30,-60")
r2h = R2HProcess(r2h_binary, r2h_port, config_content=config)
r2h.start()
try:
url = "/OffsetPairMerge?playseek=20240101120000-20240101130000"

stream_get("127.0.0.1", r2h_port, url, read_bytes=4096, timeout=_STREAM_TIMEOUT)

describe_reqs = [r for r in rtsp.requests_detailed if r["method"] == "DESCRIBE"]
assert describe_reqs, "expected at least one DESCRIBE"
assert "r2h-seek-offset" not in describe_reqs[0]["uri"], (
"r2h-seek-offset leaked into upstream URI: %s" % describe_reqs[0]["uri"]
)
assert "playseek=20240101120030-20240101125900" in describe_reqs[0]["uri"], (
"configured offset pair should have applied; got URI %s" % describe_reqs[0]["uri"]
)
finally:
r2h.stop()
finally:
rtsp.stop()

def test_duplicate_request_seek_mode_does_not_leak(self, shared_r2h):
"""A client that sends r2h-seek-mode twice must have BOTH copies stripped
from the upstream URI — the second copy could otherwise be re-parsed
Expand Down
116 changes: 116 additions & 0 deletions e2e/test_url_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,39 @@ def test_seek_offset_applied(self, shared_r2h):
finally:
upstream.stop()

def test_seek_offset_pair_applied(self, shared_r2h):
"""r2h-seek-offset=a,b should shift template begin and end independently."""
# begin + 30s = 12:00:30, end - 60s = 12:59:00
expected_path = "/path/20240101120030/20240101125900/file.m3u8"
upstream = _make_upstream(expected_path)
upstream.start()
try:
url = (
"/http/127.0.0.1:%d"
"/path/${(b)yyyyMMddHHmmss}/${(e)yyyyMMddHHmmss}/file.m3u8"
"?playseek=20240101120000-20240101130000&r2h-seek-offset=30,-60"
) % upstream.port
status, _, _ = http_get("127.0.0.1", shared_r2h.port, url, timeout=_TIMEOUT)
assert status == 200
assert _get_upstream_path(upstream) == expected_path
finally:
upstream.stop()

def test_seek_offset_pair_begin_only_template(self, shared_r2h):
"""When seek has no end time, r2h-seek-offset=a,b should use only begin offset."""
expected_path = "/path/20240101120030/file.m3u8"
upstream = _make_upstream(expected_path)
upstream.start()
try:
url = (
"/http/127.0.0.1:%d/path/${(b)yyyyMMddHHmmss}/file.m3u8?playseek=20240101120000&r2h-seek-offset=30,-60"
) % upstream.port
status, _, _ = http_get("127.0.0.1", shared_r2h.port, url, timeout=_TIMEOUT)
assert status == 200
assert _get_upstream_path(upstream) == expected_path
finally:
upstream.stop()

def test_tz_and_offset_combined(self, shared_r2h):
"""TZ/UTC+8 + r2h-seek-offset=3600: 12:00 CST+1h=13:00 CST, end 14:00 CST (local time)."""
expected_path = "/path/20240101130000/20240101140000/file.m3u8"
Expand Down Expand Up @@ -730,6 +763,23 @@ def test_seek_offset_with_unix_timestamp_seek(self, shared_r2h):
finally:
upstream.stop()

def test_seek_offset_pair_with_unix_timestamp_seek(self, shared_r2h):
"""r2h-seek-offset=a,b should shift Unix timestamp begin/end independently."""
expected_path = "/path/20240101120030/20240101125900/file"
upstream = _make_upstream(expected_path)
upstream.start()
try:
url = (
"/http/127.0.0.1:%d"
"/path/${(b)yyyyMMddHHmmss}/${(e)yyyyMMddHHmmss}/file"
"?playseek=%d-%d&r2h-seek-offset=30,-60"
) % (upstream.port, _BEGIN_EPOCH, _END_EPOCH)
status, _, _ = http_get("127.0.0.1", shared_r2h.port, url, timeout=_TIMEOUT)
assert status == 200
assert _get_upstream_path(upstream) == expected_path
finally:
upstream.stop()


# ===================================================================
# Edge cases
Expand Down Expand Up @@ -1189,6 +1239,21 @@ def test_positive_offset(self, shared_r2h):
finally:
upstream.stop()

def test_offset_pair(self, shared_r2h):
"""r2h-seek-offset=a,b should add separate begin/end offsets."""
upstream = _make_upstream("/stream")
upstream.start()
try:
url = (
"/http/127.0.0.1:%d/stream?playseek=20240101120000-20240101130000&r2h-seek-offset=30,-60"
) % upstream.port
http_get("127.0.0.1", shared_r2h.port, url, timeout=_TIMEOUT)

path = _get_upstream_path(upstream)
assert _extract_query_param(path, "playseek") == "20240101120030-20240101125900"
finally:
upstream.stop()

def test_negative_offset(self, shared_r2h):
"""r2h-seek-offset=-30 should subtract 30 seconds."""
upstream = _make_upstream("/stream")
Expand All @@ -1204,6 +1269,23 @@ def test_negative_offset(self, shared_r2h):
finally:
upstream.stop()

def test_overflow_offset_rejected(self, shared_r2h):
"""Overflowing r2h-seek-offset should be ignored."""
upstream = _make_upstream("/stream")
upstream.start()
try:
url = (
"/http/127.0.0.1:%d/stream?playseek=20240101120000-20240101130000"
"&r2h-seek-offset=999999999999999999999999999999"
) % upstream.port
http_get("127.0.0.1", shared_r2h.port, url, timeout=_TIMEOUT)

path = _get_upstream_path(upstream)
assert _extract_query_param(path, "playseek") == "20240101120000-20240101130000"
assert "r2h-seek-offset" not in path, "r2h-seek-offset should be stripped, got: %s" % path
finally:
upstream.stop()

def test_offset_stripped_from_upstream(self, shared_r2h):
"""r2h-seek-offset should be stripped from the upstream URL."""
upstream = _make_upstream("/stream")
Expand Down Expand Up @@ -1232,6 +1314,19 @@ def test_offset_with_unix_timestamp(self, shared_r2h):
finally:
upstream.stop()

def test_offset_pair_with_unix_timestamp(self, shared_r2h):
"""r2h-seek-offset=a,b should preserve Unix timestamp output format."""
upstream = _make_upstream("/stream")
upstream.start()
try:
url = ("/http/127.0.0.1:%d/stream?playseek=1704096000-1704099600&r2h-seek-offset=30,-60") % upstream.port
http_get("127.0.0.1", shared_r2h.port, url, timeout=_TIMEOUT)

path = _get_upstream_path(upstream)
assert _extract_query_param(path, "playseek") == "1704096030-1704099540"
finally:
upstream.stop()


@pytest.mark.http_proxy
class TestHTTPQueryAppendTimezoneAndFormat:
Expand Down Expand Up @@ -2336,6 +2431,27 @@ def test_positive_offset(self, shared_r2h):
finally:
rtsp.stop()

def test_offset_pair(self, shared_r2h):
"""r2h-seek-offset=a,b should add separate begin/end offsets in RTSP."""
rtsp = MockRTSPServer(num_packets=500)
rtsp.start()
try:
url = (
"/rtsp/127.0.0.1:%d/stream?playseek=20240101120000-20240101130000&r2h-seek-offset=30,-60"
) % rtsp.port
stream_get(
"127.0.0.1",
shared_r2h.port,
url,
read_bytes=4096,
timeout=_STREAM_TIMEOUT,
)

uri = _get_describe_uri(rtsp)
assert _extract_query_param(uri, "playseek") == "20240101120030-20240101125900"
finally:
rtsp.stop()

def test_negative_offset(self, shared_r2h):
"""r2h-seek-offset=-30 should subtract 30 seconds from RTSP playseek."""
rtsp = MockRTSPServer(num_packets=500)
Expand Down
Loading
Loading