mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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:
parent
19acdcc29d
commit
b6bbd39469
10 changed files with 424 additions and 269 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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'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.
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue