From dfb938f89a794796cb8effcb49c15e074640bff1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:10:28 +0000 Subject: [PATCH 1/4] Initial plan From 3c97e720e627cb24df0da3a5cd97e9b70f197dad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:19:20 +0000 Subject: [PATCH 2/4] fix: use system timezone for naive seek datetimes Co-authored-by: stackia <5107241+stackia@users.noreply.github.com> Agent-Logs-Url: https://github.com/stackia/rtp2httpd/sessions/a6f0da50-0571-4932-950c-159fb9c6a2b2 --- docs/guide/time-processing.md | 10 +- e2e/test_http_proxy.py | 41 ++++++++ e2e/test_rtsp_misc.py | 48 +++++++++ src/service.c | 23 +++-- src/timezone.c | 180 ++++++++++++++++++++++++++-------- src/timezone.h | 13 ++- 6 files changed, 262 insertions(+), 53 deletions(-) diff --git a/docs/guide/time-processing.md b/docs/guide/time-processing.md index 39bf727..3f4e802 100644 --- a/docs/guide/time-processing.md +++ b/docs/guide/time-processing.md @@ -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 转发给上游服务器) diff --git a/e2e/test_http_proxy.py b/e2e/test_http_proxy.py index 532887c..302eab1 100644 --- a/e2e/test_http_proxy.py +++ b/e2e/test_http_proxy.py @@ -6,6 +6,7 @@ """ import time +import os import pytest @@ -171,6 +172,46 @@ def test_query_forwarded(self, shared_r2h): finally: upstream.stop() + def test_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary): + upstream = MockHTTPUpstream( + routes={ + "/vod": {"status": 200, "body": b"ok"}, + } + ) + upstream.start() + old_tz = os.environ.get("TZ") + os.environ["TZ"] = "Asia/Shanghai" + time.tzset() + r2h = R2HProcess(r2h_binary, find_free_port(), extra_args=["-v", "4", "-m", "100"]) + r2h.start() + try: + start_ts = int(time.time()) - 1800 + end_ts = start_ts + 300 + start_local = time.strftime("%Y%m%d%H%M%S", time.localtime(start_ts)) + end_local = time.strftime("%Y%m%d%H%M%S", time.localtime(end_ts)) + 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)) + + 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 "playseek=%s-%s" % (start_utc, end_utc) in upstream.requests_log[0]["path"] + finally: + r2h.stop() + if old_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = old_tz + time.tzset() + upstream.stop() + # --------------------------------------------------------------------------- # Upstream unreachable diff --git a/e2e/test_rtsp_misc.py b/e2e/test_rtsp_misc.py index d36b9d1..305a59b 100644 --- a/e2e/test_rtsp_misc.py +++ b/e2e/test_rtsp_misc.py @@ -6,6 +6,7 @@ """ import json +import os import socket import time @@ -82,6 +83,10 @@ 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: + return time.strftime("%Y%m%d%H%M%S", time.localtime(ts)) + + @pytest.fixture(scope="module") def shared_r2h(r2h_binary): """A single rtp2httpd instance shared by all misc RTSP tests.""" @@ -495,6 +500,49 @@ def test_recent_playseek_ignores_r2h_start(self, shared_r2h): finally: rtsp.stop() + def test_recent_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary): + rtsp = MockRTSPServer(num_packets=500) + rtsp.start() + old_tz = os.environ.get("TZ") + os.environ["TZ"] = "Asia/Shanghai" + time.tzset() + r2h = R2HProcess(r2h_binary, find_free_port(), extra_args=["-v", "4", "-m", "100"]) + 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) + 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") == "clock=%s-" % _format_basic_utc(start_ts) + finally: + r2h.stop() + if old_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = old_tz + time.tzset() + 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) diff --git a/src/service.c b/src/service.c index 8b5d913..7c3960c 100644 --- a/src/service.c +++ b/src/service.c @@ -592,9 +592,12 @@ 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) { + parse_result->tz_offset_seconds = TIMEZONE_USE_SYSTEM_LOCAL_OFFSET; + logger(LOG_DEBUG, "Timezone: Falling back to system timezone for seek parsing"); + } if (!seek_param_value || seek_param_value[0] == '\0') return 0; @@ -638,8 +641,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; @@ -651,8 +658,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; diff --git a/src/timezone.c b/src/timezone.c index b0e1f2f..d6cf517 100644 --- a/src/timezone.c +++ b/src/timezone.c @@ -16,6 +16,42 @@ #define MAX_TIMEZONE_OFFSET_SECONDS (TIMEZONE_MAX_OFFSET_HOURS * SECONDS_PER_HOUR) #define MIN_TIMEZONE_OFFSET_SECONDS (TIMEZONE_MIN_OFFSET_HOURS * SECONDS_PER_HOUR) +static int timezone_use_system_local(int tz_offset_seconds) { + return tz_offset_seconds == TIMEZONE_USE_SYSTEM_LOCAL_OFFSET; +} + +static int timezone_validate_offset(int tz_offset_seconds) { + if (timezone_use_system_local(tz_offset_seconds)) + return 0; + + if (tz_offset_seconds < MIN_TIMEZONE_OFFSET_SECONDS || tz_offset_seconds > MAX_TIMEZONE_OFFSET_SECONDS) { + logger(LOG_ERROR, "Timezone: Invalid timezone offset %d seconds (range: [%d, %d])", tz_offset_seconds, + MIN_TIMEZONE_OFFSET_SECONDS, MAX_TIMEZONE_OFFSET_SECONDS); + return -1; + } + + return 0; +} + +static int timezone_local_tm_to_timestamp(struct tm *local_time, time_t *timestamp_out) { + time_t timestamp; + + if (!local_time || !timestamp_out) { + logger(LOG_ERROR, "Timezone: NULL pointer in timezone_local_tm_to_timestamp"); + return -1; + } + + local_time->tm_isdst = -1; + timestamp = mktime(local_time); + if (timestamp == (time_t)-1) { + logger(LOG_ERROR, "Timezone: Failed to convert local time to timestamp"); + return -1; + } + + *timestamp_out = timestamp; + return 0; +} + /* * Parse timezone information from User-Agent header * Supports patterns like: TZ/UTC+8, TZ/UTC-5, TZ/UTC @@ -249,9 +285,7 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco } /* Validate timezone offset range */ - if (tz_offset_seconds < MIN_TIMEZONE_OFFSET_SECONDS || tz_offset_seconds > MAX_TIMEZONE_OFFSET_SECONDS) { - logger(LOG_ERROR, "Timezone: Invalid timezone offset %d seconds (range: [%d, %d])", tz_offset_seconds, - MIN_TIMEZONE_OFFSET_SECONDS, MAX_TIMEZONE_OFFSET_SECONDS); + if (timezone_validate_offset(tz_offset_seconds) != 0) { return -1; } @@ -316,15 +350,21 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco local_time.tm_sec = sec; local_time.tm_isdst = 0; - timestamp = timegm(&local_time); + if (timezone_use_system_local(tz_offset_seconds) && !has_gmt_suffix) { + if (timezone_local_tm_to_timestamp(&local_time, ×tamp) != 0) + return -1; + } else { + timestamp = timegm(&local_time); - if (timestamp == -1) { - logger(LOG_ERROR, "Timezone: Failed to convert time to timestamp"); - return -1; + if (timestamp == -1) { + logger(LOG_ERROR, "Timezone: Failed to convert time to timestamp"); + return -1; + } + + timestamp -= tz_offset_seconds; } /* Apply timezone conversion and additional offset */ - timestamp -= tz_offset_seconds; timestamp += additional_offset_seconds; /* Convert back to yyyyMMddHHmmss format */ @@ -349,10 +389,15 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco } else { strncpy(output_time, temp_time, output_size - 1); output_time[output_size - 1] = '\0'; - logger(LOG_DEBUG, - "Timezone: yyyyMMddHHmmss '%s' (TZ offset %d) + seek offset %d = " - "'%s'", - input_time, -tz_offset_seconds, additional_offset_seconds, output_time); + if (timezone_use_system_local(tz_offset_seconds)) { + logger(LOG_DEBUG, "Timezone: yyyyMMddHHmmss '%s' (system TZ) + seek offset %d = '%s'", input_time, + additional_offset_seconds, output_time); + } else { + logger(LOG_DEBUG, + "Timezone: yyyyMMddHHmmss '%s' (TZ offset %d) + seek offset %d = " + "'%s'", + input_time, -tz_offset_seconds, additional_offset_seconds, output_time); + } } return 0; @@ -371,16 +416,26 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco return -1; } - timestamp = timegm(&tm); + if (has_timezone) { + timestamp = timegm(&tm); - if (timestamp == -1) { - logger(LOG_ERROR, "Timezone: Failed to convert basic ISO 8601 time to timestamp"); - return -1; - } + if (timestamp == -1) { + logger(LOG_ERROR, "Timezone: Failed to convert basic ISO 8601 time to timestamp"); + return -1; + } - if (has_timezone) { timestamp -= timezone_offset; + } else if (timezone_use_system_local(tz_offset_seconds)) { + if (timezone_local_tm_to_timestamp(&tm, ×tamp) != 0) + return -1; } else { + timestamp = timegm(&tm); + + if (timestamp == -1) { + logger(LOG_ERROR, "Timezone: Failed to convert basic ISO 8601 time to timestamp"); + return -1; + } + timestamp -= tz_offset_seconds; } timestamp += additional_offset_seconds; @@ -402,10 +457,15 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco return -1; } - logger(LOG_DEBUG, - "Timezone: basic ISO 8601 '%s' (TZ offset %d) + seek offset %d = " - "'%s'", - input_time, has_timezone ? timezone_offset : -tz_offset_seconds, additional_offset_seconds, output_time); + if (!has_timezone && timezone_use_system_local(tz_offset_seconds)) { + logger(LOG_DEBUG, "Timezone: basic ISO 8601 '%s' (system TZ) + seek offset %d = '%s'", input_time, + additional_offset_seconds, output_time); + } else { + logger(LOG_DEBUG, + "Timezone: basic ISO 8601 '%s' (TZ offset %d) + seek offset %d = " + "'%s'", + input_time, has_timezone ? timezone_offset : -tz_offset_seconds, additional_offset_seconds, output_time); + } return 0; } @@ -630,6 +690,9 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add return -1; } + if (timezone_validate_offset(tz_offset_seconds) != 0) + return -1; + input_len = strlen(input_time); digit_count = strspn(input_time, "0123456789"); @@ -657,12 +720,18 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add local_time.tm_sec = sec; local_time.tm_isdst = 0; - timestamp = timegm(&local_time); + if (timezone_use_system_local(tz_offset_seconds) && input_len == 14) { + if (timezone_local_tm_to_timestamp(&local_time, ×tamp) != 0) + return -1; + } else { + timestamp = timegm(&local_time); - if (timestamp == -1) - return -1; + if (timestamp == -1) + return -1; + + timestamp -= tz_offset_seconds; + } - timestamp -= tz_offset_seconds; timestamp += additional_offset_seconds; *out_utc = timestamp; return 0; @@ -680,14 +749,22 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add return -1; } - timestamp = timegm(&tm); + if (has_timezone) { + timestamp = timegm(&tm); - if (timestamp == -1) - return -1; + if (timestamp == -1) + return -1; - if (has_timezone) { timestamp -= timezone_offset; + } else if (timezone_use_system_local(tz_offset_seconds)) { + if (timezone_local_tm_to_timestamp(&tm, ×tamp) != 0) + return -1; } else { + timestamp = timegm(&tm); + + if (timestamp == -1) + return -1; + timestamp -= tz_offset_seconds; } timestamp += additional_offset_seconds; @@ -708,15 +785,24 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add return -1; } - timestamp = timegm(&tm); + if (has_timezone) { + timestamp = timegm(&tm); - if (timestamp == -1) - return -1; + if (timestamp == -1) + return -1; - if (has_timezone) { timestamp -= timezone_offset; timestamp += additional_offset_seconds; + } else if (timezone_use_system_local(tz_offset_seconds)) { + if (timezone_local_tm_to_timestamp(&tm, ×tamp) != 0) + return -1; + timestamp += additional_offset_seconds; } else { + timestamp = timegm(&tm); + + if (timestamp == -1) + return -1; + timestamp -= tz_offset_seconds; timestamp += additional_offset_seconds; } @@ -759,15 +845,15 @@ int timezone_convert_iso8601_with_offset(const char *iso_str, int external_tz_of return -1; } - timestamp = timegm(&tm); - - if (timestamp == -1) { - logger(LOG_ERROR, "Timezone: Failed to convert time to timestamp"); - return -1; - } - /* Apply timezone conversion and offset */ if (has_timezone) { + timestamp = timegm(&tm); + + if (timestamp == -1) { + logger(LOG_ERROR, "Timezone: Failed to convert time to timestamp"); + return -1; + } + /* Input has embedded timezone * The tm structure represents the time as parsed (without timezone) * We need to: @@ -779,7 +865,19 @@ int timezone_convert_iso8601_with_offset(const char *iso_str, int external_tz_of "Timezone: ISO 8601 has embedded timezone, only applying offset %d " "seconds", offset_seconds); + } else if (timezone_use_system_local(external_tz_offset)) { + if (timezone_local_tm_to_timestamp(&tm, ×tamp) != 0) + return -1; + timestamp += offset_seconds; + logger(LOG_DEBUG, "Timezone: ISO 8601 no timezone, using system TZ + offset %d seconds", offset_seconds); } else { + timestamp = timegm(&tm); + + if (timestamp == -1) { + logger(LOG_ERROR, "Timezone: Failed to convert time to timestamp"); + return -1; + } + /* No embedded timezone - apply timezone conversion then offset */ timestamp -= external_tz_offset; timestamp += offset_seconds; diff --git a/src/timezone.h b/src/timezone.h index b28181f..aeaab92 100644 --- a/src/timezone.h +++ b/src/timezone.h @@ -11,12 +11,14 @@ #ifndef TIMEZONE_H #define TIMEZONE_H +#include #include #include /* Constants */ #define TIMEZONE_MAX_OFFSET_HOURS 14 /* Maximum timezone offset (UTC+14) */ #define TIMEZONE_MIN_OFFSET_HOURS -12 /* Minimum timezone offset (UTC-12) */ +#define TIMEZONE_USE_SYSTEM_LOCAL_OFFSET INT_MAX /* * Parse timezone information from User-Agent header @@ -76,7 +78,9 @@ int timezone_format_time_yyyyMMddHHmmss(const struct tm *utc_time, char *output_ * @param input_time Input time string (must not be NULL) * @param tz_offset_seconds Timezone offset from User-Agent in seconds * (positive for east, negative for west) - * Range: [-43200, 50400] seconds + * Range: [-43200, 50400] seconds, or + * TIMEZONE_USE_SYSTEM_LOCAL_OFFSET to parse naive + * datetimes in the daemon's system timezone. * Used only for formats without embedded timezone * @param additional_offset_seconds Additional offset in seconds to apply * (can be positive or negative) @@ -177,7 +181,8 @@ int timezone_format_time_iso8601(const struct tm *tm, int milliseconds, const ch * * @param iso_str Input ISO 8601 string (must not be NULL) * @param external_tz_offset Timezone offset from User-Agent in seconds (used - * only if no embedded timezone) + * only if no embedded timezone), or TIMEZONE_USE_SYSTEM_LOCAL_OFFSET to use + * the daemon's system timezone * @param offset_seconds Additional offset to always apply in seconds * @param output Output buffer (must not be NULL) * @param output_size Size of output buffer (minimum 30 bytes) @@ -197,7 +202,9 @@ int timezone_convert_iso8601_with_offset(const char *iso_str, int external_tz_of * Thread Safety: NOT thread-safe (uses gmtime() which returns static buffer) * * @param input_time Input time string (must not be NULL) - * @param tz_offset_seconds Timezone offset in seconds from UTC + * @param tz_offset_seconds Timezone offset in seconds from UTC, or + * TIMEZONE_USE_SYSTEM_LOCAL_OFFSET to parse naive datetimes in the daemon's + * system timezone * @param additional_offset_seconds Additional offset in seconds * @param out_utc Output: resulting UTC epoch value (must not be NULL) * @return 0 on success, -1 on error From 9626ff68fa2e2774ba167230b1e0fc6ea4b2a7dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:22:17 +0000 Subject: [PATCH 3/4] test: isolate timezone fallback e2e coverage Co-authored-by: stackia <5107241+stackia@users.noreply.github.com> Agent-Logs-Url: https://github.com/stackia/rtp2httpd/sessions/a6f0da50-0571-4932-950c-159fb9c6a2b2 --- e2e/helpers/r2h_process.py | 3 +++ e2e/test_http_proxy.py | 23 +++++++++++------------ e2e/test_rtsp_misc.py | 23 +++++++++++------------ src/service.c | 1 - src/timezone.c | 2 ++ 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/e2e/helpers/r2h_process.py b/e2e/helpers/r2h_process.py index 1c213ac..17aac1e 100644 --- a/e2e/helpers/r2h_process.py +++ b/e2e/helpers/r2h_process.py @@ -19,11 +19,13 @@ 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 @@ -35,6 +37,7 @@ def start(self, wait: bool = True) -> None: 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() diff --git a/e2e/test_http_proxy.py b/e2e/test_http_proxy.py index 302eab1..177e8e5 100644 --- a/e2e/test_http_proxy.py +++ b/e2e/test_http_proxy.py @@ -6,7 +6,8 @@ """ import time -import os +from datetime import datetime +from zoneinfo import ZoneInfo import pytest @@ -20,6 +21,7 @@ ) pytestmark = pytest.mark.http_proxy +_SYSTEM_TZ = ZoneInfo("Asia/Shanghai") # --------------------------------------------------------------------------- @@ -179,16 +181,18 @@ def test_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary) } ) upstream.start() - old_tz = os.environ.get("TZ") - os.environ["TZ"] = "Asia/Shanghai" - time.tzset() - r2h = R2HProcess(r2h_binary, find_free_port(), extra_args=["-v", "4", "-m", "100"]) + 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 = time.strftime("%Y%m%d%H%M%S", time.localtime(start_ts)) - end_local = time.strftime("%Y%m%d%H%M%S", time.localtime(end_ts)) + 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)) @@ -205,11 +209,6 @@ def test_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary) assert "playseek=%s-%s" % (start_utc, end_utc) in upstream.requests_log[0]["path"] finally: r2h.stop() - if old_tz is None: - os.environ.pop("TZ", None) - else: - os.environ["TZ"] = old_tz - time.tzset() upstream.stop() diff --git a/e2e/test_rtsp_misc.py b/e2e/test_rtsp_misc.py index 305a59b..083cdc1 100644 --- a/e2e/test_rtsp_misc.py +++ b/e2e/test_rtsp_misc.py @@ -6,9 +6,10 @@ """ import json -import os import socket import time +from datetime import datetime +from zoneinfo import ZoneInfo import pytest @@ -27,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 @@ -84,7 +86,7 @@ def _format_basic_utc(ts: int) -> str: def _format_local_compact(ts: int) -> str: - return time.strftime("%Y%m%d%H%M%S", time.localtime(ts)) + return datetime.fromtimestamp(ts, _SYSTEM_TZ).strftime("%Y%m%d%H%M%S") @pytest.fixture(scope="module") @@ -500,13 +502,15 @@ def test_recent_playseek_ignores_r2h_start(self, shared_r2h): finally: rtsp.stop() - def test_recent_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary): + def test_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary): rtsp = MockRTSPServer(num_packets=500) rtsp.start() - old_tz = os.environ.get("TZ") - os.environ["TZ"] = "Asia/Shanghai" - time.tzset() - r2h = R2HProcess(r2h_binary, find_free_port(), extra_args=["-v", "4", "-m", "100"]) + 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 @@ -536,11 +540,6 @@ def test_recent_playseek_without_client_timezone_uses_system_timezone(self, r2h_ assert play_reqs[0]["headers"].get("Range") == "clock=%s-" % _format_basic_utc(start_ts) finally: r2h.stop() - if old_tz is None: - os.environ.pop("TZ", None) - else: - os.environ["TZ"] = old_tz - time.tzset() rtsp.stop() @pytest.mark.parametrize("param_name", ["playseek", "Playseek", "tvdr", "custom_seek"]) diff --git a/src/service.c b/src/service.c index 7c3960c..262cd60 100644 --- a/src/service.c +++ b/src/service.c @@ -595,7 +595,6 @@ int service_parse_seek_value(const char *seek_param_value, int seek_offset_secon 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) != 0) { - parse_result->tz_offset_seconds = TIMEZONE_USE_SYSTEM_LOCAL_OFFSET; logger(LOG_DEBUG, "Timezone: Falling back to system timezone for seek parsing"); } diff --git a/src/timezone.c b/src/timezone.c index d6cf517..ee3ab7a 100644 --- a/src/timezone.c +++ b/src/timezone.c @@ -720,6 +720,8 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add local_time.tm_sec = sec; local_time.tm_isdst = 0; + /* Only bare yyyyMMddHHmmss should fall back to the daemon's system + * timezone. yyyyMMddHHmmssGMT already carries explicit UTC semantics. */ if (timezone_use_system_local(tz_offset_seconds) && input_len == 14) { if (timezone_local_tm_to_timestamp(&local_time, ×tamp) != 0) return -1; From c0bdbe2799f3aeb375db980c3c5c819f0d8e419c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:29:30 +0000 Subject: [PATCH 4/4] chore: polish timezone fallback coverage Co-authored-by: stackia <5107241+stackia@users.noreply.github.com> Agent-Logs-Url: https://github.com/stackia/rtp2httpd/sessions/a6f0da50-0571-4932-950c-159fb9c6a2b2 --- e2e/helpers/r2h_process.py | 2 ++ e2e/test_http_proxy.py | 55 +++++++++++++++++++++----------------- e2e/test_rtsp_misc.py | 8 ++++-- src/service.c | 2 +- src/timezone.c | 34 ++++++++++++----------- 5 files changed, 58 insertions(+), 43 deletions(-) diff --git a/e2e/helpers/r2h_process.py b/e2e/helpers/r2h_process.py index 17aac1e..8d322fc 100644 --- a/e2e/helpers/r2h_process.py +++ b/e2e/helpers/r2h_process.py @@ -33,6 +33,8 @@ def __init__( 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, diff --git a/e2e/test_http_proxy.py b/e2e/test_http_proxy.py index 177e8e5..53d8320 100644 --- a/e2e/test_http_proxy.py +++ b/e2e/test_http_proxy.py @@ -174,41 +174,46 @@ def test_query_forwarded(self, shared_r2h): finally: upstream.stop() - def test_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary): + def test_playseek_uses_system_timezone_without_client_timezone(self, r2h_binary): upstream = MockHTTPUpstream( routes={ "/vod": {"status": 200, "body": b"ok"}, } ) upstream.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 = 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)) - - 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, + 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 "playseek=%s-%s" % (start_utc, end_utc) in upstream.requests_log[0]["path"] + 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: - r2h.stop() upstream.stop() diff --git a/e2e/test_rtsp_misc.py b/e2e/test_rtsp_misc.py index 083cdc1..ac3cd08 100644 --- a/e2e/test_rtsp_misc.py +++ b/e2e/test_rtsp_misc.py @@ -86,6 +86,7 @@ def _format_basic_utc(ts: int) -> str: 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") @@ -502,7 +503,7 @@ def test_recent_playseek_ignores_r2h_start(self, shared_r2h): finally: rtsp.stop() - def test_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary): + def test_playseek_uses_system_timezone_without_client_timezone(self, r2h_binary): rtsp = MockRTSPServer(num_packets=500) rtsp.start() r2h = R2HProcess( @@ -517,6 +518,8 @@ def test_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary) 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, @@ -537,7 +540,8 @@ def test_playseek_without_client_timezone_uses_system_timezone(self, r2h_binary) 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") == "clock=%s-" % _format_basic_utc(start_ts) + assert play_reqs[0]["headers"].get("Range") == expected_range + assert expected_range != incorrect_utc_range finally: r2h.stop() rtsp.stop() diff --git a/src/service.c b/src/service.c index 262cd60..991b25d 100644 --- a/src/service.c +++ b/src/service.c @@ -595,7 +595,7 @@ int service_parse_seek_value(const char *seek_param_value, int seek_offset_secon 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) != 0) { - logger(LOG_DEBUG, "Timezone: Falling back to system timezone for seek parsing"); + logger(LOG_DEBUG, "Timezone: Keeping system timezone fallback because User-Agent timezone parse failed"); } if (!seek_param_value || seek_param_value[0] == '\0') diff --git a/src/timezone.c b/src/timezone.c index ee3ab7a..425166a 100644 --- a/src/timezone.c +++ b/src/timezone.c @@ -15,13 +15,15 @@ #define SECONDS_PER_DAY 86400 #define MAX_TIMEZONE_OFFSET_SECONDS (TIMEZONE_MAX_OFFSET_HOURS * SECONDS_PER_HOUR) #define MIN_TIMEZONE_OFFSET_SECONDS (TIMEZONE_MIN_OFFSET_HOURS * SECONDS_PER_HOUR) +#define COMPACT_DATETIME_LENGTH 14 +#define COMPACT_DATETIME_GMT_LENGTH 17 -static int timezone_use_system_local(int tz_offset_seconds) { +static int timezone_is_using_system_local(int tz_offset_seconds) { return tz_offset_seconds == TIMEZONE_USE_SYSTEM_LOCAL_OFFSET; } static int timezone_validate_offset(int tz_offset_seconds) { - if (timezone_use_system_local(tz_offset_seconds)) + if (timezone_is_using_system_local(tz_offset_seconds)) return 0; if (tz_offset_seconds < MIN_TIMEZONE_OFFSET_SECONDS || tz_offset_seconds > MAX_TIMEZONE_OFFSET_SECONDS) { @@ -304,9 +306,10 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco /* Format 2: yyyyMMddHHmmss and yyyyMMddHHmmssGMT (14 digits, optionally * followed by "GMT") */ - if ((input_len == 14 && digit_count == 14) || - (input_len == 17 && digit_count == 14 && strcmp(input_time + 14, "GMT") == 0)) { - int has_gmt_suffix = (input_len == 17); + if ((input_len == COMPACT_DATETIME_LENGTH && digit_count == COMPACT_DATETIME_LENGTH) || + (input_len == COMPACT_DATETIME_GMT_LENGTH && digit_count == COMPACT_DATETIME_LENGTH && + strcmp(input_time + COMPACT_DATETIME_LENGTH, "GMT") == 0)) { + int has_gmt_suffix = (input_len == COMPACT_DATETIME_GMT_LENGTH); /* Parse the time string (first 14 digits) */ if (sscanf(input_time, "%4d%2d%2d%2d%2d%2d", &year, &month, &day, &hour, &min, &sec) != 6) { @@ -350,7 +353,7 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco local_time.tm_sec = sec; local_time.tm_isdst = 0; - if (timezone_use_system_local(tz_offset_seconds) && !has_gmt_suffix) { + if (timezone_is_using_system_local(tz_offset_seconds) && !has_gmt_suffix) { if (timezone_local_tm_to_timestamp(&local_time, ×tamp) != 0) return -1; } else { @@ -389,7 +392,7 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco } else { strncpy(output_time, temp_time, output_size - 1); output_time[output_size - 1] = '\0'; - if (timezone_use_system_local(tz_offset_seconds)) { + if (timezone_is_using_system_local(tz_offset_seconds)) { logger(LOG_DEBUG, "Timezone: yyyyMMddHHmmss '%s' (system TZ) + seek offset %d = '%s'", input_time, additional_offset_seconds, output_time); } else { @@ -425,7 +428,7 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco } timestamp -= timezone_offset; - } else if (timezone_use_system_local(tz_offset_seconds)) { + } else if (timezone_is_using_system_local(tz_offset_seconds)) { if (timezone_local_tm_to_timestamp(&tm, ×tamp) != 0) return -1; } else { @@ -457,7 +460,7 @@ int timezone_convert_time_with_offset(const char *input_time, int tz_offset_seco return -1; } - if (!has_timezone && timezone_use_system_local(tz_offset_seconds)) { + if (!has_timezone && timezone_is_using_system_local(tz_offset_seconds)) { logger(LOG_DEBUG, "Timezone: basic ISO 8601 '%s' (system TZ) + seek offset %d = '%s'", input_time, additional_offset_seconds, output_time); } else { @@ -705,8 +708,9 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add } /* Format 2: yyyyMMddHHmmss or yyyyMMddHHmmssGMT */ - if ((input_len == 14 && digit_count == 14) || - (input_len == 17 && digit_count == 14 && strcmp(input_time + 14, "GMT") == 0)) { + if ((input_len == COMPACT_DATETIME_LENGTH && digit_count == COMPACT_DATETIME_LENGTH) || + (input_len == COMPACT_DATETIME_GMT_LENGTH && digit_count == COMPACT_DATETIME_LENGTH && + strcmp(input_time + COMPACT_DATETIME_LENGTH, "GMT") == 0)) { if (sscanf(input_time, "%4d%2d%2d%2d%2d%2d", &year, &month, &day, &hour, &min, &sec) != 6) { return -1; } @@ -722,7 +726,7 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add /* Only bare yyyyMMddHHmmss should fall back to the daemon's system * timezone. yyyyMMddHHmmssGMT already carries explicit UTC semantics. */ - if (timezone_use_system_local(tz_offset_seconds) && input_len == 14) { + if (timezone_is_using_system_local(tz_offset_seconds) && input_len == COMPACT_DATETIME_LENGTH) { if (timezone_local_tm_to_timestamp(&local_time, ×tamp) != 0) return -1; } else { @@ -758,7 +762,7 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add return -1; timestamp -= timezone_offset; - } else if (timezone_use_system_local(tz_offset_seconds)) { + } else if (timezone_is_using_system_local(tz_offset_seconds)) { if (timezone_local_tm_to_timestamp(&tm, ×tamp) != 0) return -1; } else { @@ -795,7 +799,7 @@ int timezone_parse_to_utc(const char *input_time, int tz_offset_seconds, int add timestamp -= timezone_offset; timestamp += additional_offset_seconds; - } else if (timezone_use_system_local(tz_offset_seconds)) { + } else if (timezone_is_using_system_local(tz_offset_seconds)) { if (timezone_local_tm_to_timestamp(&tm, ×tamp) != 0) return -1; timestamp += additional_offset_seconds; @@ -867,7 +871,7 @@ int timezone_convert_iso8601_with_offset(const char *iso_str, int external_tz_of "Timezone: ISO 8601 has embedded timezone, only applying offset %d " "seconds", offset_seconds); - } else if (timezone_use_system_local(external_tz_offset)) { + } else if (timezone_is_using_system_local(external_tz_offset)) { if (timezone_local_tm_to_timestamp(&tm, ×tamp) != 0) return -1; timestamp += offset_seconds;