fix(timeshift): PATH-first catch-up + strict-UTC API surface with proxy-time provider timezone conversion

Two coupled fixes that make catch-up play the requested programme:

1. Try the PATH catch-up form (/timeshift/.../{start}/{id}.ts) BEFORE the
   timeshift.php query form. Empirical testing showed some XC providers
   return the LIVE stream on the query form (HTTP 200, silently ignoring
   `start`) — a valid MPEG-TS indistinguishable from a real archive, so the
   cascade accepted it and the user always got live content. The PATH form
   actually seeks. Candidate ordering now lives in
   build_timeshift_candidate_urls() with the full rationale.

2. Strict-UTC XC API surface + single proxy-time timezone conversion:
   - xc_get_epg start/end always UTC, server_info.timezone always "UTC",
     time_now UTC — the timezone triple is consistently UTC.
   - Removed the global default_timezone setting and the
     _convert_xmltv_to_local_timezone rewrite entirely.
   - The proxy converts the client's UTC timestamp to the SERVING provider's
     own zone (server_info.timezone captured on account refresh, read from
     the account's default profile) right before building the upstream URL —
     DST-correct via ZoneInfo, no-op for UTC/unknown zones. Verified against
     a real provider: it interprets the URL timestamp as its LOCAL wall
     clock, so pure UTC pass-through seeks 1-2h off.
   - EPG duration lookup keeps the ORIGINAL UTC timestamp (programmes are
     stored in UTC) — exactly one conversion in the whole chain.

Also per review feedback:
- Removed the debug_logging toggle — verbose timeshift logging now follows
  the standard logger DEBUG level (DISPATCHARR_LOG_LEVEL).
- Removed the dead default_language setting (never read anywhere; the EPG
  lang field is hardcoded). TIMESHIFT_DEFAULTS is down to
  xmltv_prev_days_override, and the Settings UI shows that single field
  with accurate help text.
- Validate the timestamp up front (400 on malformed input) instead of
  forwarding it verbatim into the upstream URL.
- Redact the upstream URL in the connection-error log (requests exceptions
  embed the full URL, which carries XC credentials).
- URL-encode XC credentials in both URL builders.
- Request identity encoding upstream (the TS-sync peek reads raw bytes).
- Poll the stop key on the 5-second heartbeat cadence instead of every 100
  chunks (~25 MB), so stream-limit terminations free the provider slot fast.
- New tests: proxy timestamp wiring (converted value reaches the URL
  builder, original UTC value reaches the duration lookup), _redact_url,
  decisive 3xx break (anti ban), invalid timestamp rejection; deflaked the
  format-cache promotion test (django cache persists across runs).
This commit is contained in:
Cédric Marcoux 2026-06-10 22:04:44 +02:00
parent 19acdcc29d
commit b6bbd39469
10 changed files with 424 additions and 269 deletions

View file

@ -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/<id>/lineup.json` - output profile only
- `/hdhr/<channel_profile>/output_profile/<id>/lineup.json` - channel profile + output profile

View file

@ -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())),

View file

@ -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
UTCprovider-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):
(< 56 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

View file

@ -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",
)

View file

@ -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()

View file

@ -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 (> ~56 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,
)

View file

@ -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):
"""

View file

@ -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 }) => {
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack>
<Text size="sm" c="dimmed">
XC catch-up uses these settings to convert UTC EPG timestamps into the
provider&apos;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&apos;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.
</Text>
<Select
label="Default timezone"
description="IANA timezone used for XC EPG strings (start/end) and provider timeshift URLs."
data={tzOptions}
searchable
allowDeselect={false}
{...form.getInputProps('timeshift_default_timezone')}
/>
<TextInput
label="Default language"
description="ISO 639-1 language code (2 letters) emitted in EPG entries."
maxLength={2}
{...form.getInputProps('timeshift_default_language')}
/>
<NumberInput
label="XMLTV prev_days override"
description="0 = auto-detect from provider tv_archive_duration (capped at 30). Greater than 0 forces that many days of past programmes."
@ -98,13 +63,6 @@ const TimeshiftSettingsForm = React.memo(({ active }) => {
step={1}
{...form.getInputProps('xmltv_prev_days_override')}
/>
<Switch
label="Verbose timeshift logging"
description="Log catch-up request details (channel, timestamp, URL) for debugging."
{...form.getInputProps('timeshift_debug_logging', {
type: 'checkbox',
})}
/>
<Button type="submit" variant="filled" mt="sm">
{saved ? 'Saved' : 'Save'}
</Button>

View file

@ -1,17 +1,9 @@
export const getTimeshiftSettingsFormInitialValues = () => {
return {
timeshift_default_timezone: 'UTC',
timeshift_default_language: 'en',
xmltv_prev_days_override: 0,
timeshift_debug_logging: false,
};
};
export const getTimeshiftSettingsFormValidation = () => {
return {
timeshift_default_language: (value) =>
value && !/^[a-z]{2}$/i.test(value)
? 'Must be a 2-letter ISO 639-1 code'
: null,
};
return {};
};

View file

@ -70,12 +70,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'auto_import_mapped_files',
'enable_ip_lookup',
];
const timeshiftFields = [
'timeshift_default_timezone',
'timeshift_default_language',
'xmltv_prev_days_override',
'timeshift_debug_logging',
];
const timeshiftFields = ['xmltv_prev_days_override'];
for (const formKey in changedSettings) {
let value = changedSettings[formKey];
@ -142,23 +137,12 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'comskip_enabled',
'schedule_enabled',
'auto_import_mapped_files',
'timeshift_debug_logging',
'enable_ip_lookup',
];
if (booleanFields.includes(formKey) && value != null) {
value = typeof value === 'boolean' ? value : Boolean(value);
}
// Map UI form keys (with timeshift_ prefix) to the storage subkeys used in
// CoreSettings.value. The prefix lets us share form keys across groups
// without ambiguity but the JSON storage uses short keys.
const timeshiftFormKeyToStorage = {
timeshift_default_timezone: 'default_timezone',
timeshift_default_language: 'default_language',
xmltv_prev_days_override: 'xmltv_prev_days_override',
timeshift_debug_logging: 'debug_logging',
};
// Route to appropriate group
if (streamFields.includes(formKey)) {
groupedChanges.stream_settings[formKey] = value;
@ -171,8 +155,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
} else if (systemFields.includes(formKey)) {
groupedChanges.system_settings[formKey] = value;
} else if (timeshiftFields.includes(formKey)) {
const storageKey = timeshiftFormKeyToStorage[formKey];
groupedChanges.timeshift_settings[storageKey] = value;
groupedChanges.timeshift_settings[formKey] = value;
}
}
@ -388,26 +371,16 @@ export const parseSettings = (settings) => {
: true;
}
// Timeshift settings - direct mapping with timeshift_ prefixed form keys
// Timeshift settings - direct key mapping.
// The only key is the XMLTV lookback override: the XC API surface is strictly
// UTC (no timezone setting) and verbose logging follows the standard logger level.
const timeshiftSettings = settings['timeshift_settings']?.value;
parsed.timeshift_default_timezone =
timeshiftSettings && timeshiftSettings.default_timezone
? String(timeshiftSettings.default_timezone)
: 'UTC';
parsed.timeshift_default_language =
timeshiftSettings && timeshiftSettings.default_language
? String(timeshiftSettings.default_language)
: 'en';
parsed.xmltv_prev_days_override =
timeshiftSettings && timeshiftSettings.xmltv_prev_days_override != null
? typeof timeshiftSettings.xmltv_prev_days_override === 'number'
? timeshiftSettings.xmltv_prev_days_override
: parseInt(timeshiftSettings.xmltv_prev_days_override, 10) || 0
: 0;
parsed.timeshift_debug_logging =
timeshiftSettings && timeshiftSettings.debug_logging != null
? Boolean(timeshiftSettings.debug_logging)
: false;
// Proxy and network access are already grouped objects
if (settings['proxy_settings']?.value) {