Skip to content
Draft
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
10 changes: 7 additions & 3 deletions docs/guide/time-processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,18 @@ playseek=2024-01-01T12:00:00.123-2024-01-01T13:00:00.456

#### 默认行为

如果 User-Agent 中没有时区信息,则不进行任何时区转换,只应用 `r2h-seek-offset` 指定的秒数偏移。
如果 User-Agent 中没有时区信息,则按以下规则处理:

- 对 Unix 时间戳、带 `Z` / `±HH:MM` 的 ISO 8601、以及带 `GMT` 后缀的 `yyyyMMddHHmmssGMT`,继续使用时间字符串自身携带的时区语义
- 对不带时区信息的 `yyyyMMddHHmmss`、简化 ISO 8601、完整 ISO 8601,默认按 **rtp2httpd 所在系统时区** 解析,而不是按 UTC 解析
- 然后再应用 `r2h-seek-offset` 指定的额外秒数偏移

> [!NOTE]
> rtp2httpd 按以下步骤处理时间参数:
>
> 1. **解析时间格式** — 识别参数值属于哪种格式:Unix 时间戳(≤10 位数字)、`yyyyMMddHHmmss`(14 位数字)、`yyyyMMddHHmmssGMT`(14 位 + GMT 后缀)、简化 ISO 8601(`yyyyMMddTHHmmss`)、完整 ISO 8601(`yyyy-MM-ddTHH:mm:ss`)
> 2. **解析 User-Agent 时区** — 从 User-Agent 中查找 `TZ/` 标记,提取 UTC 偏移量(秒)。如果没有时区信息,默认为 0(UTC)
> 3. **时区转换** — Unix 时间戳和 ISO 8601 带时区的格式跳过转换;`yyyyMMddHHmmss` 和 ISO 8601 无时区的格式应用 User-Agent 时区转换
> 2. **解析 User-Agent 时区** — 从 User-Agent 中查找 `TZ/` 标记,提取 UTC 偏移量(秒)。如果没有时区信息,则对“不带时区的日期时间格式”回退到系统时区
> 3. **时区转换** — Unix 时间戳、`yyyyMMddHHmmssGMT` 和 ISO 8601 带时区的格式跳过该回退;`yyyyMMddHHmmss` 和 ISO 8601 无时区的格式应用 User-Agent 时区或系统时区转换
> 4. **应用 `r2h-seek-offset`** — 如果指定了该参数,对所有格式应用额外的秒数偏移(可正可负)
> 5. **格式化输出** — 保持原始格式,保留原有时区后缀(如有)
> 6. **附加到上游 URL** — 将处理后的时间参数作为查询参数附加到上游请求中(RTSP 发送 DESCRIBE 请求,HTTP 转发给上游服务器)
Expand Down
5 changes: 5 additions & 0 deletions e2e/helpers/r2h_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,27 @@ def __init__(
port: int,
extra_args: list[str] | None = None,
config_content: str | None = None,
env: dict[str, str] | None = None,
):
self.binary = str(binary)
self.port = port
self.extra_args = list(extra_args or [])
self.config_content = config_content
self.env = dict(env or {})
self.process: subprocess.Popen | None = None
self._config_path: str | None = None

# -- lifecycle -----------------------------------------------------------

def start(self, wait: bool = True) -> None:
args = self._build_args()
# Override only the keys provided by the test while preserving the rest
# of the parent environment for the child rtp2httpd process.
self.process = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env={**os.environ, **self.env},
)
if wait and not wait_for_port(self.port, timeout=6.0):
self.stop()
Expand Down
45 changes: 45 additions & 0 deletions e2e/test_http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"""

import time
from datetime import datetime
from zoneinfo import ZoneInfo

import pytest

Expand All @@ -19,6 +21,7 @@
)

pytestmark = pytest.mark.http_proxy
_SYSTEM_TZ = ZoneInfo("Asia/Shanghai")


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -171,6 +174,48 @@ def test_query_forwarded(self, shared_r2h):
finally:
upstream.stop()

def test_playseek_uses_system_timezone_without_client_timezone(self, r2h_binary):
upstream = MockHTTPUpstream(
routes={
"/vod": {"status": 200, "body": b"ok"},
}
)
upstream.start()
try:
r2h = R2HProcess(
r2h_binary,
find_free_port(),
extra_args=["-v", "4", "-m", "100"],
env={"TZ": "Asia/Shanghai"},
)
r2h.start()
try:
start_ts = int(time.time()) - 1800
end_ts = start_ts + 300
start_local = datetime.fromtimestamp(start_ts, _SYSTEM_TZ).strftime("%Y%m%d%H%M%S")
end_local = datetime.fromtimestamp(end_ts, _SYSTEM_TZ).strftime("%Y%m%d%H%M%S")
start_utc = time.strftime("%Y%m%d%H%M%S", time.gmtime(start_ts))
end_utc = time.strftime("%Y%m%d%H%M%S", time.gmtime(end_ts))
expected_playseek = "playseek=%s-%s" % (start_utc, end_utc)
local_playseek = "playseek=%s-%s" % (start_local, end_local)

status, _, body = http_get(
"127.0.0.1",
r2h.port,
"/http/127.0.0.1:%d/vod?playseek=%s-%s" % (upstream.port, start_local, end_local),
timeout=5.0,
)

assert status == 200
assert body == b"ok"
assert len(upstream.requests_log) > 0, "Expected upstream request"
assert expected_playseek in upstream.requests_log[0]["path"]
assert local_playseek not in upstream.requests_log[0]["path"]
finally:
r2h.stop()
finally:
upstream.stop()


# ---------------------------------------------------------------------------
# Upstream unreachable
Expand Down
51 changes: 51 additions & 0 deletions e2e/test_rtsp_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import json
import socket
import time
from datetime import datetime
from zoneinfo import ZoneInfo

import pytest

Expand All @@ -26,6 +28,7 @@
pytestmark = pytest.mark.rtsp

_STREAM_TIMEOUT = 20.0
_SYSTEM_TZ = ZoneInfo("Asia/Shanghai")

# Timeout constants matching C code (3s each)
_RTSP_HANDSHAKE_TIMEOUT = 3
Expand Down Expand Up @@ -82,6 +85,11 @@ def _format_basic_utc(ts: int) -> str:
return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(ts))


def _format_local_compact(ts: int) -> str:
"""Format a Unix timestamp as `yyyyMMddHHmmss` in Asia/Shanghai."""
return datetime.fromtimestamp(ts, _SYSTEM_TZ).strftime("%Y%m%d%H%M%S")


@pytest.fixture(scope="module")
def shared_r2h(r2h_binary):
"""A single rtp2httpd instance shared by all misc RTSP tests."""
Expand Down Expand Up @@ -495,6 +503,49 @@ def test_recent_playseek_ignores_r2h_start(self, shared_r2h):
finally:
rtsp.stop()

def test_playseek_uses_system_timezone_without_client_timezone(self, r2h_binary):
rtsp = MockRTSPServer(num_packets=500)
rtsp.start()
r2h = R2HProcess(
r2h_binary,
find_free_port(),
extra_args=["-v", "4", "-m", "100"],
env={"TZ": "Asia/Shanghai"},
)
r2h.start()
try:
start_ts = int(time.time()) - 1800
end_ts = start_ts + 300
start_local = _format_local_compact(start_ts)
end_local = _format_local_compact(end_ts)
expected_range = "clock=%s-" % _format_basic_utc(start_ts)
incorrect_utc_range = "clock=%sT%sZ-" % (start_local[:8], start_local[8:])
url = "/rtsp/127.0.0.1:%d/stream?playseek=%s-%s" % (
rtsp.port,
start_local,
end_local,
)

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 len(describe_reqs) > 0, "Expected DESCRIBE"
assert "playseek=" not in describe_reqs[0]["uri"]

play_reqs = [r for r in rtsp.requests_detailed if r["method"] == "PLAY"]
assert len(play_reqs) > 0, "Expected PLAY request"
assert play_reqs[0]["headers"].get("Range") == expected_range
assert expected_range != incorrect_utc_range
finally:
r2h.stop()
rtsp.stop()

@pytest.mark.parametrize("param_name", ["playseek", "Playseek", "tvdr", "custom_seek"])
def test_boundary_playseek_is_forwarded(self, shared_r2h, param_name):
rtsp = MockRTSPServer(num_packets=500)
Expand Down
22 changes: 16 additions & 6 deletions src/service.c
Original file line number Diff line number Diff line change
Expand Up @@ -592,9 +592,11 @@ int service_parse_seek_value(const char *seek_param_value, int seek_offset_secon
memset(parse_result, 0, sizeof(*parse_result));
parse_result->seek_offset_seconds = seek_offset_seconds;
parse_result->now_utc = time(NULL);
parse_result->tz_offset_seconds = TIMEZONE_USE_SYSTEM_LOCAL_OFFSET;

if (user_agent)
timezone_parse_from_user_agent(user_agent, &parse_result->tz_offset_seconds);
if (user_agent && timezone_parse_from_user_agent(user_agent, &parse_result->tz_offset_seconds) != 0) {
logger(LOG_DEBUG, "Timezone: Keeping system timezone fallback because User-Agent timezone parse failed");
}

if (!seek_param_value || seek_param_value[0] == '\0')
return 0;
Expand Down Expand Up @@ -638,8 +640,12 @@ int service_parse_seek_value(const char *seek_param_value, int seek_offset_secon
if (!tmp)
return -1;
parse_result->begin_tm_utc = *tmp;
time_t local_ts = parse_result->begin_utc + parse_result->tz_offset_seconds;
tmp = gmtime(&local_ts);
if (parse_result->tz_offset_seconds == TIMEZONE_USE_SYSTEM_LOCAL_OFFSET)
tmp = localtime(&parse_result->begin_utc);
else {
time_t local_ts = parse_result->begin_utc + parse_result->tz_offset_seconds;
tmp = gmtime(&local_ts);
}
if (!tmp)
return -1;
parse_result->begin_tm_local = *tmp;
Expand All @@ -651,8 +657,12 @@ int service_parse_seek_value(const char *seek_param_value, int seek_offset_secon
if (!tmp)
return -1;
parse_result->end_tm_utc = *tmp;
time_t local_ts = parse_result->end_utc + parse_result->tz_offset_seconds;
tmp = gmtime(&local_ts);
if (parse_result->tz_offset_seconds == TIMEZONE_USE_SYSTEM_LOCAL_OFFSET)
tmp = localtime(&parse_result->end_utc);
else {
time_t local_ts = parse_result->end_utc + parse_result->tz_offset_seconds;
tmp = gmtime(&local_ts);
}
if (!tmp)
return -1;
parse_result->end_tm_local = *tmp;
Expand Down
Loading