diff --git a/CHANGELOG.md b/CHANGELOG.md index f90e3faa..9bcb41f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,9 +152,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Intel Quick Sync (`--qsv`)**: requires an Intel iGPU or ARC GPU with the i915 driver exposed to the container. - **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on `/stats` with a violet `TIMESHIFT` badge alongside live sessions and respect per-channel access rules. - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries. - - **`Settings → Timeshift` panel** with four knobs (defaults: `UTC`, `en`, `0`, off): `default_timezone`, `default_language`, `xmltv_prev_days_override`, `debug_logging`. + - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. + - **`Settings → Timeshift` panel** with a single knob: `xmltv_prev_days_override` (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - - **Byte path uses a dedicated producer thread + `os.pipe`** matching the pattern in `apps/proxy/live_proxy/input/http_streamer.py:HTTPStreamReader`. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. + - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. - **HDHR output profile URL support.** HDHomeRun lineup URLs now support an `output_profile` path segment so HDHR clients (Plex, Channels DVR, Emby, etc.) can request a specific transcode profile without any query-parameter support. URL formats accepted: - `/hdhr/output_profile//lineup.json` - output profile only - `/hdhr//output_profile//lineup.json` - channel profile + output profile diff --git a/apps/output/views.py b/apps/output/views.py index edb1814b..0cc20e3f 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -12,11 +12,9 @@ from dispatcharr.utils import network_access_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 from datetime import datetime, timedelta, timezone as dt_timezone -from zoneinfo import ZoneInfo import html import time -from tzlocal import get_localzone -from urllib.parse import urlencode, urlparse +from urllib.parse import urlencode import base64 import logging from django.db.models.functions import Lower @@ -2018,24 +2016,22 @@ def _build_xc_server_info(request, hostname, port): The timezone, time_now, and xc_get_epg start/end fields form a "timezone triple" that MUST all use the same zone — XC clients (iPlayTV, TiviMate) use server_info.timezone to interpret start/end strings and calculate seek - offsets into catch-up archives. A mismatch makes the wrong programme play. - Learned from plugin v1.1.4 → v1.2.6 (6 iterations of timezone bugs). + offsets into catch-up archives. We keep the whole triple in **UTC**: the EPG + surface stays timezone-neutral and any provider-local conversion happens at + catch-up request time, against the serving provider's own zone (see + apps/timeshift/views.timeshift_proxy). This is what keeps multi-provider + setups consistent. Learned from plugin v1.1.4 → v1.2.6 (the earlier + per-instance timezone shifting caused 6 iterations of wrong-programme bugs). """ - tz_name = CoreSettings.get_timeshift_settings().get("default_timezone", "UTC") - try: - tz = ZoneInfo(tz_name) - except (KeyError, Exception): - # Invalid timezone in settings — fall back to UTC so XC clients - # can still connect instead of getting a 500 error. - tz = ZoneInfo("UTC") - tz_name = "UTC" + # datetime.timezone.utc, not ZoneInfo("UTC"): the latter can read a mis-set + # host /etc/timezone in some Docker setups. return { "url": hostname, "server_protocol": request.scheme, "port": port, - "timezone": tz_name, + "timezone": "UTC", "timestamp_now": int(time.time()), - "time_now": datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S"), + "time_now": datetime.now(dt_timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), "process": True, } @@ -2198,72 +2194,10 @@ def xc_xmltv(request): ) return JsonResponse({'error': 'Unauthorized'}, status=401) - response = generate_epg(request, None, user) - return _convert_xmltv_to_local_timezone(response) - - -# Pre-compiled pattern for XMLTV timestamp attributes: `20251128143000 +0000`. -_XMLTV_TS_PATTERN = regex.compile(rb'(\d{14}) ([+-]\d{4})') - - -def _convert_xmltv_to_local_timezone(response): - """Rewrite UTC timestamps in an XMLTV streaming response to the configured - catch-up local time zone. - - Catch-up clients such as TiviMate display the wall-clock part of XMLTV - timestamps verbatim and ignore the trailing offset, so a programme - emitted as `start="20260512170000 +0000"` (correct UTC) ends up shown - as 17:00 even when the user is in Brussels and expects 19:00. Rewriting - the same instant as `start="20260512190000 +0200"` keeps the wall-clock - in the EPG aligned with the user's perception while the timestamp still - points at the same moment. - - The conversion runs only on the `/xmltv.php` catch-up endpoint — the - plain `/output/...epg.xml` consumers keep the UTC representation. - """ - timeshift_settings = CoreSettings.get_timeshift_settings() - tz_name = timeshift_settings.get('default_timezone', 'UTC') - if tz_name == 'UTC': - return response # No rewrite needed — timestamps are already UTC - try: - local_tz = ZoneInfo(tz_name) - except Exception: - return response - - # Use the builtin UTC, not ZoneInfo('UTC'), because the latter can pick - # up the host's mis-set /etc/timezone in some Docker setups. - # dt_timezone imported at module level - utc = dt_timezone.utc - - def _convert(match): - ts_bytes, _zone = match.group(1), match.group(2) - try: - ts_str = ts_bytes.decode('ascii') - utc_time = datetime.strptime(ts_str, '%Y%m%d%H%M%S').replace(tzinfo=utc) - local_time = utc_time.astimezone(local_tz) - return local_time.strftime('%Y%m%d%H%M%S %z').encode('ascii') - except Exception: - return match.group(0) - - # generate_epg may return either StreamingHttpResponse (large EPGs) or a - # plain HttpResponse (small EPGs or some error paths) — handle both. - if hasattr(response, 'streaming_content'): - source = response.streaming_content - else: - body = response.content - if isinstance(body, str): - body = body.encode('utf-8') - source = iter([body]) - - def _rewrite(): - for chunk in source: - if isinstance(chunk, str): - chunk = chunk.encode('utf-8') - if b'start="' in chunk or b'stop="' in chunk: - chunk = _XMLTV_TS_PATTERN.sub(_convert, chunk) - yield chunk - - return StreamingHttpResponse(_rewrite(), content_type=response.get('Content-Type', 'application/xml')) + # XMLTV is emitted in UTC — the XC API surface is strictly UTC. Catch-up + # clients build their seek from the UTC wall-clock; the timeshift proxy + # applies any provider-local offset at request time. No per-instance rewrite. + return generate_epg(request, None, user) def xc_get_live_categories(user): @@ -2587,9 +2521,10 @@ def xc_get_epg(request, user, short=False): # prev_days and expect the response to already contain archived entries # marked with has_archive=1 — they use that list to populate the catch-up # programme menu. - # Use denormalized catch-up fields (zero DB queries). + # Use denormalized catch-up fields (zero DB queries). Clamp to the same + # 30-day ceiling applied to explicit prev_days values. _channel_is_catchup = getattr(channel, "is_catchup", False) - _channel_catchup_days = getattr(channel, "catchup_days", 0) + _channel_catchup_days = min(getattr(channel, "catchup_days", 0) or 0, 30) if _channel_is_catchup and prev_days == 0: prev_days = _channel_catchup_days @@ -2644,19 +2579,16 @@ def xc_get_epg(request, user, short=False): else: archive_window = None - # start/end must be in the SAME timezone reported by server_info.timezone. - # XC clients display these strings verbatim AND use server_info.timezone - # to calculate seek offsets into catch-up archives. If these don't match, - # the wrong programme plays. Learned from plugin v1.1.4 → v1.2.6 (6 - # iterations of timezone bugs all converged on this rule). - # start_timestamp/stop_timestamp (epoch) stay in UTC — they're inherently - # timezone-agnostic and clients use them for their own conversions. - _ts_settings = CoreSettings.get_timeshift_settings() - _epg_tz_name = _ts_settings.get("default_timezone", "UTC") - try: - _epg_tz = ZoneInfo(_epg_tz_name) - except Exception: - _epg_tz = None + # start/end are emitted in UTC — the whole XC API surface is strictly UTC, + # so the "timezone triple" (server_info.timezone, these start/end strings, + # and time_now) is consistently UTC. XC clients display these strings and + # use server_info.timezone (also UTC) to build the catch-up URL; the proxy + # then converts that UTC instant to the SERVING provider's own local zone at + # request time (see apps/timeshift/views.timeshift_proxy). Keeping the EPG + # UTC is what makes multi-provider setups consistent — we cannot know at EPG + # time which provider will serve a given catch-up. + # start_timestamp/stop_timestamp (epoch) are inherently timezone-agnostic. + _epg_utc = dt_timezone.utc for program in programs: title = program['title'] if isinstance(program, dict) else program.title @@ -2682,8 +2614,8 @@ def xc_get_epg(request, user, short=False): "epg_id": epg_id, "title": base64.b64encode((title or "").encode()).decode(), "lang": "", - "start": start.astimezone(_epg_tz).strftime("%Y-%m-%d %H:%M:%S") if _epg_tz else start.strftime("%Y-%m-%d %H:%M:%S"), - "end": end.astimezone(_epg_tz).strftime("%Y-%m-%d %H:%M:%S") if _epg_tz else end.strftime("%Y-%m-%d %H:%M:%S"), + "start": start.astimezone(_epg_utc).strftime("%Y-%m-%d %H:%M:%S"), + "end": end.astimezone(_epg_utc).strftime("%Y-%m-%d %H:%M:%S"), "description": base64.b64encode((description or "").encode()).decode(), "channel_id": str(channel_num_int), "start_timestamp": str(int(start.timestamp())), diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py index 9f9df798..041b6ce5 100644 --- a/apps/timeshift/helpers.py +++ b/apps/timeshift/helpers.py @@ -2,6 +2,8 @@ import logging from datetime import datetime, timezone +from urllib.parse import quote +from zoneinfo import ZoneInfo from django.core.cache import cache @@ -42,7 +44,7 @@ def compute_provider_archive_days_capped(): ) -def _parse_timestamp(timestamp_str): +def parse_catchup_timestamp(timestamp_str): """Parse a timestamp string into a datetime, accepting colon-dash or underscore shapes. Accepts: ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate native), @@ -57,20 +59,59 @@ def _parse_timestamp(timestamp_str): return None +def convert_timestamp_to_provider_tz(timestamp_str, provider_tz_name): + """Convert a UTC catch-up timestamp to the serving provider's local zone. + + The XC API surface is strictly UTC, so the client builds the catch-up URL + from a UTC wall-clock. Real XC providers, however, index their archive in + their OWN local zone (the ``server_info.timezone`` they report on auth). This + shifts the UTC instant into that zone so the upstream seek lands on the right + hour — empirically a provider reading ``17-00`` as its local time returns the + 17:00-local programme, not 17:00 UTC. + + ``timestamp_str`` is ``YYYY-MM-DD:HH-MM`` or ``YYYY-MM-DD_HH-MM`` (UTC). + ``provider_tz_name`` is an IANA name (e.g. ``Europe/Brussels``). When it is + falsy, ``"UTC"``, or unknown, the timestamp is returned unchanged — providers + that index in UTC (or whose zone we don't know) need no shift. Returns the + colon-dash shape ``YYYY-MM-DD:HH-MM`` (the canonical PATH shape). On any parse + failure the input is returned unchanged. + """ + if not provider_tz_name or provider_tz_name == "UTC": + return timestamp_str + dt = parse_catchup_timestamp(timestamp_str) + if dt is None: + return timestamp_str + try: + target = ZoneInfo(provider_tz_name) + except Exception: + logger.warning( + "Timeshift: unknown provider timezone %r — no conversion applied", + provider_tz_name, + ) + return timestamp_str + # datetime.timezone.utc (not ZoneInfo("UTC")) for the source side — immune to + # a mis-set host /etc/timezone. astimezone() is DST-correct for the date. + local_dt = dt.replace(tzinfo=timezone.utc).astimezone(target) + return local_dt.strftime("%Y-%m-%d:%H-%M") + + def get_programme_duration(channel, timestamp_str): """Duration in minutes of the EPG programme starting at `timestamp_str`. - `timestamp_str` is `YYYY-MM-DD:HH-MM` or `YYYY-MM-DD_HH-MM` in UTC - (passed through from the client URL unchanged — no timezone conversion). + `timestamp_str` is `YYYY-MM-DD:HH-MM` or `YYYY-MM-DD_HH-MM` in UTC — the + client's original value, built from the strictly-UTC EPG output. The + UTC→provider-zone conversion happens only for the upstream URL (in + ``timeshift_proxy``), never for this EPG lookup: programmes are stored in + UTC, so the lookup must stay in UTC too. Falls back to a 120-minute default if EPG lookup fails. """ try: - dt = _parse_timestamp(timestamp_str) + dt = parse_catchup_timestamp(timestamp_str) if dt is None: return DEFAULT_DURATION_MINUTES # EPG start_time/end_time are timezone-aware (USE_TZ=True), so the # parsed datetime must also be aware to avoid a TypeError in the ORM - # filter. The timestamp is already in UTC (derived from the EPG epoch). + # filter. dt = dt.replace(tzinfo=timezone.utc) if not channel.epg_data: return DEFAULT_DURATION_MINUTES @@ -90,10 +131,12 @@ def get_programme_duration(channel, timestamp_str): def build_timeshift_url_format_a(m3u_account, stream_id, timestamp, duration_minutes): """Format A: `/streaming/timeshift.php?username=&password=&stream=&start=&duration=`.""" + # Credentials are URL-encoded: a `&`, `/` or `#` in the password would + # otherwise corrupt the URL structure. return ( f"{m3u_account.server_url.rstrip('/')}/streaming/timeshift.php" - f"?username={m3u_account.username}" - f"&password={m3u_account.password}" + f"?username={quote(str(m3u_account.username), safe='')}" + f"&password={quote(str(m3u_account.password), safe='')}" f"&stream={stream_id}" f"&start={timestamp}" f"&duration={duration_minutes}" @@ -104,14 +147,48 @@ def build_timeshift_url_format_b(m3u_account, stream_id, timestamp, duration_min """Format B: `/timeshift/{user}/{pass}/{duration}/{timestamp}/{stream_id}.ts`.""" return ( f"{m3u_account.server_url.rstrip('/')}/timeshift" - f"/{m3u_account.username}" - f"/{m3u_account.password}" + f"/{quote(str(m3u_account.username), safe='')}" + f"/{quote(str(m3u_account.password), safe='')}" f"/{duration_minutes}" f"/{timestamp}" f"/{stream_id}.ts" ) +def build_timeshift_candidate_urls(m3u_account, stream_id, timestamp, duration_minutes): + """Ordered upstream catch-up candidates — PATH form first. + + Two URL layouts exist on XC servers and they do NOT behave the same: + + • Format B — PATH layout: ``/timeshift/{user}/{pass}/{dur}/{START}/{id}.ts`` + The canonical XC catch-up form (what TiviMate emits natively). It actually + SEEKS the requested archive instant. Tried FIRST. + • Format A — QUERY layout: ``/streaming/timeshift.php?...&start={START}`` + A non-standard variant. Some providers implement it incorrectly and return + the LIVE stream (HTTP 200, ignoring ``start``) — indistinguishable from a + real archive at the byte level, so it would masquerade as a successful + catch-up. Kept only as a fallback for providers that expose ONLY + timeshift.php, and therefore tried AFTER every PATH candidate. + + Within each layout we vary the timestamp shape, because different servers' + parsers accept different ones (colon-dash is the canonical PATH shape; + underscore and SQL-datetime cover other servers' parsers). No timezone + conversion happens here — the caller (``timeshift_proxy``) has already + converted the value to the serving provider's zone; only the *shape* varies. + """ + underscore_ts = format_timestamp_as_underscore(timestamp) + sql_ts = format_timestamp_as_sql_datetime(timestamp) + return [ + # PATH form first — it seeks the archive correctly. + build_timeshift_url_format_b(m3u_account, stream_id, timestamp, duration_minutes), + build_timeshift_url_format_b(m3u_account, stream_id, underscore_ts, duration_minutes), + # QUERY form fallback — may return LIVE on some providers (see above). + build_timeshift_url_format_a(m3u_account, stream_id, underscore_ts, duration_minutes), + build_timeshift_url_format_a(m3u_account, stream_id, sql_ts, duration_minutes), + build_timeshift_url_format_a(m3u_account, stream_id, timestamp, duration_minutes), + ] + + def format_timestamp_as_underscore(timestamp): """Reshape ``YYYY-MM-DD:HH-MM`` to ``YYYY-MM-DD_HH-MM`` without any timezone conversion. @@ -121,10 +198,11 @@ def format_timestamp_as_underscore(timestamp): (< 5–6 hours old). The colon-dash and SQL shapes only resolve against the legacy catch-up parser, which covers archives older than roughly half a day. - Do NOT add timezone conversion here — see ``format_timestamp_as_sql_datetime`` - docstring for the full history. + Shape-only, by design: the single timezone conversion in the chain happens + upstream in ``timeshift_proxy`` (UTC → serving provider's zone), so the + value received here is already in the provider's zone. """ - dt = _parse_timestamp(timestamp) + dt = parse_catchup_timestamp(timestamp) if dt is None: logger.error("Timeshift underscore reshape failed for %r: unrecognised format", timestamp) return timestamp @@ -136,15 +214,14 @@ def format_timestamp_as_sql_datetime(timestamp): without any timezone conversion. Some XC servers refuse the dash-only shape for archives whose recording - is still being finalised and only resolve the SQL-datetime shape. This - function changes only the format — the timestamp value stays in whatever - zone the caller supplied (typically UTC, since clients derive it from the - UTC epoch in the EPG data). + is still being finalised and only resolve the SQL-datetime shape. - Do NOT add timezone conversion here — that was the root cause of the - "wrong programme plays" bug (plugin v1.1.4 → v1.2.6 history). + Shape-only, by design: the single timezone conversion in the chain happens + upstream in ``timeshift_proxy`` (UTC → serving provider's zone via + ``convert_timestamp_to_provider_tz``), so the value received here is + already in the provider's zone — this function must not convert again. """ - dt = _parse_timestamp(timestamp) + dt = parse_catchup_timestamp(timestamp) if dt is None: logger.error("Timeshift SQL timestamp reshape failed for %r: unrecognised format", timestamp) return timestamp diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py index fe381485..3f4b10ea 100644 --- a/apps/timeshift/tests/test_helpers.py +++ b/apps/timeshift/tests/test_helpers.py @@ -3,8 +3,10 @@ from django.test import TestCase from apps.timeshift.helpers import ( + build_timeshift_candidate_urls, build_timeshift_url_format_a, build_timeshift_url_format_b, + convert_timestamp_to_provider_tz, format_timestamp_as_sql_datetime, format_timestamp_as_underscore, ) @@ -75,3 +77,97 @@ class BuildTimeshiftUrlTests(TestCase): self.account, "22372", "2026-05-12:19-00", 40 ) self.assertIn("/40/2026-05-12:19-00/22372.ts", url) + + +class CandidateOrderingTests(TestCase): + """`build_timeshift_candidate_urls` must try the PATH form (which seeks the + archive) before the QUERY form (which returns LIVE on some providers, + silently ignoring the requested timestamp). Regression guard for the + "catch-up plays the live stream instead of the requested programme" bug.""" + + def setUp(self): + self.account = _FakeAccount() + + def _is_path_form(self, url): + return "/timeshift/" in url and url.endswith(".ts") and "timeshift.php" not in url + + def _is_query_form(self, url): + return "timeshift.php?" in url + + def test_every_path_candidate_precedes_every_query_candidate(self): + urls = build_timeshift_candidate_urls(self.account, "22372", "2026-05-12:19-00", 40) + path_indices = [i for i, u in enumerate(urls) if self._is_path_form(u)] + query_indices = [i for i, u in enumerate(urls) if self._is_query_form(u)] + # Each URL is classified as exactly one form. + self.assertEqual(len(path_indices) + len(query_indices), len(urls)) + self.assertTrue(path_indices and query_indices) + # The last PATH candidate still comes before the first QUERY candidate. + self.assertLess(max(path_indices), min(query_indices)) + + def test_first_candidate_is_path_form_with_canonical_dash_timestamp(self): + urls = build_timeshift_candidate_urls(self.account, "22372", "2026-05-12:19-00", 40) + self.assertTrue(self._is_path_form(urls[0])) + # Canonical colon-dash timestamp, passed through unchanged. + self.assertIn("/40/2026-05-12:19-00/22372.ts", urls[0]) + + def test_accepts_underscore_input_timestamp(self): + # Client may send the underscore shape; PATH form still leads. + urls = build_timeshift_candidate_urls(self.account, "22372", "2026-05-12_19-00", 40) + self.assertTrue(self._is_path_form(urls[0])) + + +class ConvertTimestampToProviderTzTests(TestCase): + """`convert_timestamp_to_provider_tz` shifts a UTC catch-up timestamp into the + serving provider's local zone (XC providers index archives in their own zone), + DST-correct, and is a no-op when the zone is UTC/unknown/missing.""" + + def test_utc_to_brussels_summer_is_plus_two(self): + # June → CEST (+02:00): 17:00 UTC == 19:00 Brussels (the 19h JT case). + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:17-00", "Europe/Brussels"), + "2026-06-08:19-00", + ) + + def test_utc_to_brussels_winter_is_plus_one(self): + # January → CET (+01:00): 17:00 UTC == 18:00 Brussels (DST handled). + self.assertEqual( + convert_timestamp_to_provider_tz("2026-01-08:17-00", "Europe/Brussels"), + "2026-01-08:18-00", + ) + + def test_day_rollover(self): + # 23:30 UTC + 2h (CEST) crosses midnight into the next day. + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:23-30", "Europe/Brussels"), + "2026-06-09:01-30", + ) + + def test_underscore_input_returns_colon_dash(self): + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08_17-00", "Europe/Brussels"), + "2026-06-08:19-00", + ) + + def test_utc_zone_is_noop(self): + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:17-00", "UTC"), + "2026-06-08:17-00", + ) + + def test_none_zone_is_noop(self): + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:17-00", None), + "2026-06-08:17-00", + ) + + def test_unknown_zone_is_noop(self): + self.assertEqual( + convert_timestamp_to_provider_tz("2026-06-08:17-00", "Mars/Phobos"), + "2026-06-08:17-00", + ) + + def test_garbage_timestamp_passthrough(self): + self.assertEqual( + convert_timestamp_to_provider_tz("garbage", "Europe/Brussels"), + "garbage", + ) diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index e443547d..4db30a66 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from django.test import RequestFactory, TestCase from apps.timeshift import views -from apps.proxy.live_proxy.input.http_streamer import find_ts_sync as _find_ts_sync, _TS_PACKET_SIZE +from apps.proxy.live_proxy.input.http_streamer import find_ts_sync as _find_ts_sync class FindTsSyncTests(TestCase): @@ -97,6 +97,16 @@ class StreamFromProviderStatusMappingTests(TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(mocked_open.call_count, 1) + @patch.object(views, "_open_upstream") + def test_upstream_302_short_circuits_loop(self, mocked_open): + # Any 3xx is decisive: for XC providers a 302 is the first sign of + # an IP ban, so the cascade must STOP hammering immediately instead + # of retrying other URL shapes (which escalates the ban). + mocked_open.return_value = _fake_upstream(302) + response = views._stream_from_provider(**self.kwargs) + self.assertEqual(response.status_code, 400) + self.assertEqual(mocked_open.call_count, 1) + @patch.object(views, "_open_upstream") def test_upstream_500_continues_to_next_candidate(self, mocked_open): # A 5xx is format-specific on many XC servers (PHP fatal with @@ -166,6 +176,12 @@ class StreamFromProviderStatusMappingTests(TestCase): """Once a candidate succeeds for an account, the next request reorders the list so the cached winner is tried first — saving cascade overhead on fast-forward.""" + # The format cache lives in django.core.cache (Redis-backed), which + # persists across test runs — clear this account's key so the first + # request starts from the unordered candidate list. + from django.core.cache import cache as django_cache + django_cache.delete(views._FORMAT_CACHE_KEY.format(999)) + # First request: candidate index 1 wins after index 0 returns 404. mocked_open.side_effect = [ _fake_upstream(404), @@ -212,3 +228,93 @@ class StreamFromProviderStatusMappingTests(TestCase): self.assertEqual(response.status_code, 200) # PHP response was rejected, second candidate accepted self.assertEqual(mocked_open.call_count, 2) + + +class RedactUrlTests(TestCase): + """`_redact_url` is the guard that keeps XC credentials out of logs — + both URL forms carry them (query params in format A, path segments in + format B).""" + + def test_redacts_query_credentials(self): + url = "http://example.test/streaming/timeshift.php?username=u&password=p&stream=1" + self.assertEqual(views._redact_url(url), "http://example.test/...") + + def test_redacts_path_credentials(self): + url = "http://example.test/timeshift/user/pass/60/2026-05-12:17-00/1.ts" + self.assertEqual(views._redact_url(url), "http://example.test/...") + + def test_redacts_userinfo_credentials(self): + url = "http://user:pass@example.test/timeshift/1.ts" + self.assertEqual(views._redact_url(url), "http://example.test/...") + + def test_passes_through_non_urls(self): + self.assertEqual(views._redact_url("not a url"), "not a url") + self.assertIsNone(views._redact_url(None)) + + +class TimeshiftProxyTimestampWiringTests(TestCase): + """`timeshift_proxy` must convert the client's UTC timestamp to the + serving provider's zone for the upstream URL, while keeping the ORIGINAL + UTC timestamp for the EPG duration lookup — the only timezone conversion + in the chain.""" + + def setUp(self): + self.factory = RequestFactory() + + def _make_catchup(self, provider_tz): + profile = MagicMock() + profile.id = 31 + profile.custom_properties = {"server_info": {"timezone": provider_tz}} + m3u_account = MagicMock() + m3u_account.account_type = "XC" + m3u_account.id = 9 + m3u_account.profiles.filter.return_value.first.return_value = profile + stream = MagicMock() + stream.m3u_account = m3u_account + return {"stream": stream, "props": {"stream_id": "22372"}} + + def _call(self, timestamp, provider_tz="Europe/Brussels"): + request = self.factory.get(f"/timeshift/u/p/8/{timestamp}/8.ts") + sentinel = MagicMock() + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_info", + return_value=self._make_catchup(provider_tz)), \ + patch.object(views, "get_programme_duration", return_value=40) as duration_mock, \ + patch.object(views, "build_timeshift_candidate_urls", + return_value=["http://example.test/x.ts"]) as build_mock, \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "_stream_from_provider", return_value=sentinel) as stream_mock: + channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) + response = views.timeshift_proxy(request, "u", "p", "8", timestamp, "8.ts") + return response, sentinel, build_mock, duration_mock, stream_mock + + def test_candidates_get_provider_local_timestamp(self): + # June → CEST: 17:00 UTC must reach the URL builder as 19:00 Brussels. + response, sentinel, build_mock, duration_mock, _ = self._call("2026-06-08:17-00") + self.assertIs(response, sentinel) + self.assertEqual(build_mock.call_args[0][2], "2026-06-08:19-00") + + def test_duration_lookup_keeps_original_utc_timestamp(self): + # The EPG is stored in UTC — the duration lookup must NOT receive the + # provider-converted value. + _, _, _, duration_mock, _ = self._call("2026-06-08:17-00") + self.assertEqual(duration_mock.call_args[0][1], "2026-06-08:17-00") + + def test_utc_provider_passes_timestamp_unchanged(self): + _, _, build_mock, _, _ = self._call("2026-06-08:17-00", provider_tz="UTC") + self.assertEqual(build_mock.call_args[0][2], "2026-06-08:17-00") + + def test_invalid_timestamp_rejected_before_upstream(self): + request = self.factory.get("/timeshift/u/p/8/garbage/8.ts") + with patch.object(views, "_authenticate_user", return_value=MagicMock()), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_info") as catchup_mock, \ + patch.object(views, "_stream_from_provider") as stream_mock: + channel_cls.objects.get.return_value = MagicMock(id=8) + response = views.timeshift_proxy(request, "u", "p", "8", "garbage", "8.ts") + self.assertEqual(response.status_code, 400) + catchup_mock.assert_not_called() + stream_mock.assert_not_called() diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index f265783f..43e0e80a 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -32,15 +32,13 @@ from apps.proxy.live_proxy.redis_keys import RedisKeys from apps.proxy.live_proxy.utils import get_client_ip from apps.proxy.live_proxy.input.http_streamer import find_ts_sync from apps.proxy.utils import check_user_stream_limits -from core.models import CoreSettings from core.utils import RedisClient from .helpers import ( - build_timeshift_url_format_a, - build_timeshift_url_format_b, - format_timestamp_as_sql_datetime, - format_timestamp_as_underscore, + build_timeshift_candidate_urls, + convert_timestamp_to_provider_tz, get_programme_duration, + parse_catchup_timestamp, ) logger = logging.getLogger(__name__) @@ -66,6 +64,13 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if not _user_can_access_channel(user, channel): return HttpResponseForbidden("Access denied") + # Reject malformed timestamps up front: every shape helper falls back to + # pass-through on parse failure, so an unvalidated value would be forwarded + # verbatim into the upstream URL (query-injection vector + a pointless + # provider request). + if parse_catchup_timestamp(timestamp) is None: + return HttpResponseBadRequest("Invalid timestamp") + catchup = get_channel_catchup_info(channel) if catchup is None: return HttpResponseBadRequest("Timeshift not supported for this channel") @@ -76,44 +81,48 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if m3u_account is None or m3u_account.account_type != "XC": return HttpResponseBadRequest("Channel not from Xtream Codes provider") - timeshift_settings = CoreSettings.get_timeshift_settings() - debug = bool(timeshift_settings.get("debug_logging", False)) + # Verbose timeshift logging follows the standard logger level (set via + # DISPATCHARR_LOG_LEVEL / the logging config), not a per-feature toggle. + debug = logger.isEnabledFor(logging.DEBUG) - # The client-supplied timestamp is already in UTC (derived from the - # start_timestamp epoch in the EPG data). Pass it through to the - # provider as-is — no timezone conversion. Converting here was the - # root cause of the "wrong programme plays" bug: the provider indexes - # archives in UTC, so shifting to Brussels meant requesting a programme - # 1-2 hours later than intended. - # Learned from plugin history v1.1.4 → v1.2.6: 6 iterations of timezone - # bugs all converged on "never convert the provider URL timestamp". - underscore_timestamp = format_timestamp_as_underscore(timestamp) - sql_timestamp = format_timestamp_as_sql_datetime(timestamp) - duration_minutes = get_programme_duration(channel, timestamp) stream_id_value = (props or {}).get("stream_id") if stream_id_value is None: return HttpResponseBadRequest("Cannot build timeshift URL") - # Build a prioritised set of upstream candidates. Different XC servers (or - # even different code paths inside the same server) accept different - # timestamp shapes and URL layouts: - # - # • Underscore (YYYY-MM-DD_HH-MM): canonical format for many XC - # providers. Works for ALL archives — recent and old. Tried first. - # • Colon-dash (YYYY-MM-DD:HH-MM): legacy shape used by older servers / - # the lenient parser. Resolves only finalised archives (> ~5–6 h old). - # • SQL-datetime (YYYY-MM-DD HH:MM:SS): alternative modern shape accepted - # by some servers' strict parser for recently-indexed archives. - # • Format B (path layout): the only shape some Stalker-style portals expose. - # - # We try them in order until one returns a 2xx — see `_stream_from_provider`. - candidate_urls = [ - build_timeshift_url_format_a(m3u_account, stream_id_value, underscore_timestamp, duration_minutes), - build_timeshift_url_format_b(m3u_account, stream_id_value, underscore_timestamp, duration_minutes), - build_timeshift_url_format_a(m3u_account, stream_id_value, sql_timestamp, duration_minutes), - build_timeshift_url_format_a(m3u_account, stream_id_value, timestamp, duration_minutes), - build_timeshift_url_format_b(m3u_account, stream_id_value, timestamp, duration_minutes), - ] + # The client supplies the catch-up timestamp in UTC — Dispatcharr's XC API + # surface is strictly UTC (server_info.timezone="UTC", EPG start/end in UTC), + # and the client builds the URL from those UTC strings. Real XC providers, + # however, index their archive in their OWN local zone (the + # server_info.timezone they report on auth). So convert the UTC instant to + # the serving provider's zone before building the upstream URL — empirically + # a provider reads `17-00` as 17:00 LOCAL, not UTC, so skipping this would + # seek ~1-2h off. The EPG duration lookup stays on the original UTC timestamp + # (the EPG is UTC too). This is the ONLY timezone conversion in the chain. + duration_minutes = get_programme_duration(channel, timestamp) + + # Default profile carries the provider's server_info (its reported timezone) + # and is reused below for the Stats card's M3U profile name. + default_profile = m3u_account.profiles.filter(is_default=True).first() + provider_tz_name = None + if default_profile is not None: + _server_info = (default_profile.custom_properties or {}).get("server_info") or {} + if isinstance(_server_info, dict): + provider_tz_name = _server_info.get("timezone") + provider_timestamp = convert_timestamp_to_provider_tz(timestamp, provider_tz_name) + if debug and provider_timestamp != timestamp: + logger.debug( + "Timeshift tz convert: %s UTC -> %s (provider tz=%s)", + timestamp, provider_timestamp, provider_tz_name, + ) + + # Ordered upstream candidates, PATH form first — see + # build_timeshift_candidate_urls() for the full rationale (the QUERY form is + # a last-resort fallback because some providers return LIVE on it, ignoring + # the requested timestamp). We try them in order until one returns a + # streamable MPEG-TS response — see `_stream_from_provider`. + candidate_urls = build_timeshift_candidate_urls( + m3u_account, stream_id_value, provider_timestamp, duration_minutes + ) try: user_agent = m3u_account.get_user_agent().user_agent @@ -126,10 +135,9 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) client_ip = get_client_ip(request) range_header = request.META.get("HTTP_RANGE") - # Resolve channel logo + M3U default profile so the Stats card can render - # the same logo + M3U profile name as for live streams. + # Channel logo + M3U default-profile id for the Stats card (default_profile + # was already resolved above for the provider timezone). channel_logo_id = getattr(channel, "logo_id", None) - default_profile = m3u_account.profiles.filter(is_default=True).first() m3u_profile_id = default_profile.id if default_profile is not None else None # Free the provider slot before connecting upstream. @@ -147,7 +155,7 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) # streams on the same channel. if debug: - logger.info( + logger.debug( "Timeshift request: channel=%s ts=%s provider_sid=%s vid=%s client=%s range=%s", channel.name, timestamp, @@ -313,7 +321,9 @@ def _unregister_stats_client(redis_client, virtual_channel_id, client_id): def _open_upstream(url, user_agent, range_header): """Open the upstream HTTP request (status + headers known synchronously).""" - headers = {} + # identity: the TS-sync peek reads raw bytes (response.raw), which are NOT + # transparently decompressed — a gzip-encoded body would fail the sync check. + headers = {"Accept-Encoding": "identity"} if user_agent: headers["User-Agent"] = user_agent if range_header: @@ -396,12 +406,18 @@ def _stream_from_provider( try: response = _open_upstream(url, user_agent, range_header) except requests.exceptions.RequestException as exc: - logger.error("Timeshift provider unreachable: %s", exc) + # Log only the exception class and a redacted URL: requests + # exceptions embed the full URL, which carries the XC credentials + # (path segments in format B, query params in format A). + logger.error( + "Timeshift provider unreachable (%s): %s", + _redact_url(url), type(exc).__name__, + ) return HttpResponseBadRequest("Provider connection error") last_status = response.status_code last_url = url if debug: - logger.info( + logger.debug( "Timeshift cascade[%d]: status=%d type=%s url=%s", orig_idx, response.status_code, response.headers.get("Content-Type", "?"), @@ -505,15 +521,17 @@ def _stream_from_provider( total_yielded += len(data) chunk_count += 1 - # Check stop signal every 100 chunks (mirrors VOD pattern) - if chunk_count % 100 == 0: + # Stop-signal + stats heartbeat on the same 5-second cadence. + # Time-based (not chunk-modulo) so the provider slot is freed + # within seconds of a stream-limit termination regardless of + # bitrate — at 256 KB chunks a 100-chunk interval would mean + # ~25 MB (~27 s at typical FHD rates) before noticing the stop. + now = time.time() + if now - last_heartbeat >= 5: if redis_client and redis_client.exists(stop_key): logger.info("Timeshift client %s received stop signal", client_id) redis_client.delete(stop_key) break - - now = time.time() - if now - last_heartbeat >= 5: _heartbeat_stats_client( redis_client, virtual_channel_id, client_id, bytes_delta=bytes_since_heartbeat, @@ -533,7 +551,7 @@ def _stream_from_provider( ) if debug and total_yielded > 0: mbps = (total_yielded * 8) / elapsed / 1_000_000 if elapsed > 0 else 0 - logger.info( + logger.debug( "Timeshift disconnect: vid=%s client=%s yielded=%d bytes in %.1fs (%.2f Mbps avg)", virtual_channel_id, client_id, total_yielded, elapsed, mbps, ) diff --git a/core/models.py b/core/models.py index 66da1a26..922a1e3e 100644 --- a/core/models.py +++ b/core/models.py @@ -197,11 +197,13 @@ EPG_SETTINGS_KEY = "epg_settings" USER_LIMITS_SETTINGS_KEY = "user_limit_settings" TIMESHIFT_SETTINGS_KEY = "timeshift_settings" +# The XC API surface is strictly UTC, so there is no timezone setting here: +# server_info.timezone, the EPG start/end strings and time_now are all UTC, and +# any provider-local catch-up conversion happens at proxy time against the +# serving provider's own server_info.timezone (apps/timeshift/views). +# Verbose timeshift logging follows the standard logger DEBUG level, not a toggle. TIMESHIFT_DEFAULTS = { - "default_timezone": "UTC", - "default_language": "en", "xmltv_prev_days_override": 0, - "debug_logging": False, } @@ -448,7 +450,12 @@ class CoreSettings(models.Model): # Timeshift Settings (XC catch-up) @classmethod def get_timeshift_settings(cls): - """Return all timeshift-related settings, falling back to neutral defaults.""" + """Return all timeshift-related settings, falling back to neutral defaults. + + Keys not in TIMESHIFT_DEFAULTS are ignored, so stale stored values from + removed settings are filtered out automatically. Writes go through the + generic settings API (same path as every other settings group). + """ stored = cls._get_group(TIMESHIFT_SETTINGS_KEY, {}) or {} merged = dict(TIMESHIFT_DEFAULTS) for key, value in stored.items(): @@ -456,11 +463,6 @@ class CoreSettings(models.Model): merged[key] = value return merged - @classmethod - def update_timeshift_settings(cls, updates): - clean = {k: v for k, v in (updates or {}).items() if k in TIMESHIFT_DEFAULTS} - return cls._update_group(TIMESHIFT_SETTINGS_KEY, "Timeshift Settings", clean) - class SystemEvent(models.Model): """ diff --git a/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx b/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx index 59db1e62..93d0652c 100644 --- a/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx +++ b/frontend/src/components/forms/settings/TimeshiftSettingsForm.jsx @@ -1,21 +1,12 @@ import useSettingsStore from '../../../store/settings.jsx'; -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { getChangedSettings, parseSettings, saveChangedSettings, } from '../../../utils/pages/SettingsUtils.js'; -import { - Button, - NumberInput, - Select, - Stack, - Switch, - Text, - TextInput, -} from '@mantine/core'; +import { Button, NumberInput, Stack, Text } from '@mantine/core'; import { useForm } from '@mantine/form'; -import { buildTimeZoneOptions } from '../../../utils/dateTimeUtils.js'; import { getTimeshiftSettingsFormInitialValues, getTimeshiftSettingsFormValidation, @@ -31,14 +22,6 @@ const TimeshiftSettingsForm = React.memo(({ active }) => { validate: getTimeshiftSettingsFormValidation(), }); - const tzOptions = useMemo( - () => - buildTimeZoneOptions( - form.getValues().timeshift_default_timezone || 'UTC' - ), - [] - ); - useEffect(() => { if (!active) setSaved(false); }, [active]); @@ -46,13 +29,8 @@ const TimeshiftSettingsForm = React.memo(({ active }) => { useEffect(() => { if (settings) { const parsed = parseSettings(settings); - // Fall back explicitly to the neutral defaults so the Select shows - // "UTC (now UTC+00:00)" when no setting has been saved yet. form.setValues({ - timeshift_default_timezone: parsed.timeshift_default_timezone || 'UTC', - timeshift_default_language: parsed.timeshift_default_language || 'en', xmltv_prev_days_override: parsed.xmltv_prev_days_override ?? 0, - timeshift_debug_logging: !!parsed.timeshift_debug_logging, }); } }, [settings]); @@ -72,24 +50,11 @@ const TimeshiftSettingsForm = React.memo(({ active }) => {
- XC catch-up uses these settings to convert UTC EPG timestamps into the - provider's local time and to set the XMLTV lookback window. The - defaults are neutral (UTC, en, off) — adjust to match your provider. + XC catch-up serves EPG and archive timestamps in UTC. Each provider's + own timezone is applied automatically at request time, so there is no + timezone to configure here. The only setting is the XMLTV lookback + window. -