feat(timeshift): enhance catch-up session handling with optional duration support

- Introduced an optional `duration` parameter for catch-up sessions, allowing clients to specify the programme length in minutes. This value is preferred over EPG-derived durations and includes a buffer for provider lag.
- Updated the API views and serializers to accept and process the new `duration` field.
- Enhanced the catch-up proxy implementation to utilize the client-supplied duration, improving playback accuracy.
- Added tests to validate the new duration handling and ensure proper integration with existing functionality.
This commit is contained in:
SergeantPanda 2026-07-16 21:26:06 +00:00
parent d3d51780dd
commit 14bfd25d9a
9 changed files with 318 additions and 115 deletions

View file

@ -10,9 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Native XZ decompression for EPG sources and uploaded M3U playlists.** `.xz`-compressed XMLTV and M3U files are now decompressed using Python's stdlib `lzma` module. EPG ingestion detects XZ via magic bytes, file extension (including compound names like `.xml.xz`), or MIME type; uploaded M3U `.xz` playlists stream line-by-line like `.gz`. The `/data/epgs` auto-import scanner now includes `.xz` files alongside `.xml`, `.gz`, and `.zip`. (Closes #1414) — Thanks [@MotWakorb](https://github.com/MotWakorb)
- **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 the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
- **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Thanks [@dillardblom](https://github.com/dillardblom)
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{duration}/{timestamp}/{channel_id}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to IPTV clients. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
- **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Path and query requests now use client-supplied duration hints when present, add a short provider-lag buffer, and fall back to EPG duration, then 120 minutes. Thanks [@dillardblom](https://github.com/dillardblom)
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. Sessions accept an optional programme `duration` in minutes, using the same buffered duration handling as XC clients. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
- **Catch-up admin APIs.** `GET /proxy/catchup/stats/` lists active catch-up viewers; `POST /proxy/catchup/programs/` returns batch EPG metadata for those sessions; `POST /proxy/catchup/stop_client/` stops a viewer from the admin UI.
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
- **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated).

View file

@ -11,7 +11,7 @@ from apps.channels.models import Channel
from apps.channels.utils import get_channel_catchup_streams
from dispatcharr.utils import network_access_allowed
from .helpers import parse_catchup_timestamp
from .helpers import MAX_DURATION_MINUTES, parse_catchup_timestamp
from .sessions import (
HANDSHAKE_TTL_SECONDS,
SESSION_IDLE_TTL_SECONDS,
@ -32,6 +32,16 @@ class CatchupSessionCreateSerializer(serializers.Serializer):
"ISO-8601 (2026-07-09T14:00:00Z), Unix epoch, or XC wall-clock shapes."
),
)
duration = serializers.IntegerField(
required=False,
min_value=1,
max_value=MAX_DURATION_MINUTES,
help_text=(
"Optional programme length in minutes. Preferred over EPG when "
"supplied. A short buffer is added for provider archive lag. "
"Omit to derive the length from EPG."
),
)
class CatchupSessionResponseSerializer(serializers.Serializer):
@ -42,6 +52,11 @@ class CatchupSessionResponseSerializer(serializers.Serializer):
)
channel_uuid = serializers.UUIDField()
start = serializers.CharField()
duration = serializers.IntegerField(
required=False,
allow_null=True,
help_text="Programme length in minutes if supplied at creation, else null.",
)
class CatchupSessionCreateAPIView(APIView):
@ -120,7 +135,12 @@ class CatchupSessionCreateAPIView(APIView):
)
try:
payload = create_catchup_session(user=user, channel=channel, start=start)
payload = create_catchup_session(
user=user,
channel=channel,
start=start,
duration=body.validated_data.get("duration"),
)
except RuntimeError:
return Response(
{"error": "Session service unavailable"},

View file

@ -16,6 +16,9 @@ TimeshiftCredentials = namedtuple(
)
DEFAULT_DURATION_MINUTES = 120
# Extra minutes added to client/EPG programme length when asking the provider.
# IPTV archives commonly lag live by about 30 seconds to 2 minutes, so a bare
# programme length tends to include the previous show's tail and clip the end.
DURATION_BUFFER_MINUTES = 5
MAX_DURATION_MINUTES = 480
@ -159,8 +162,9 @@ def get_programme_duration(channel, timestamp_str):
timestamp_str: Programme start in UTC (same shape as the client URL).
Returns:
Programme length plus a small buffer, capped at ``MAX_DURATION_MINUTES``,
or ``DEFAULT_DURATION_MINUTES`` when EPG lookup fails.
Programme length plus ``DURATION_BUFFER_MINUTES`` (provider archive lag),
capped at ``MAX_DURATION_MINUTES``, or ``DEFAULT_DURATION_MINUTES`` when
EPG lookup fails.
"""
try:
dt = parse_catchup_timestamp(timestamp_str)
@ -184,6 +188,45 @@ def get_programme_duration(channel, timestamp_str):
return DEFAULT_DURATION_MINUTES
def client_duration_to_window(value):
"""Convert a client-supplied programme length (minutes) to an archive window.
Args:
value: Raw client hint (str/int). PATH XC duration segment, QUERY
``duration=``, or the native session's stored ``duration``.
Returns:
Minutes to request from the provider (client length +
``DURATION_BUFFER_MINUTES``, capped), or ``None`` when the hint is
missing or not a usable positive integer.
The buffer matches the EPG path: clients usually send exact guide length
(for example ``.../60/.../123.ts`` for a 60-minute show), but provider
archives lag live, so requesting that bare length clips the end.
"""
if value is None:
return None
try:
minutes = int(str(value).strip())
except (TypeError, ValueError):
return None
if minutes <= 0:
return None
return min(minutes + DURATION_BUFFER_MINUTES, MAX_DURATION_MINUTES)
def resolve_catchup_duration(channel, timestamp_str, client_hint=None):
"""Pick the catch-up archive window in minutes.
Preference order: a sane client-supplied hint, then the EPG programme
length, then ``DEFAULT_DURATION_MINUTES``.
"""
window = client_duration_to_window(client_hint)
if window is not None:
return window
return get_programme_duration(channel, timestamp_str)
def get_programme_info(channel, timestamp_str):
"""Return EPG metadata for the programme airing at *timestamp_str*."""
try:

View file

@ -38,8 +38,12 @@ def mint_catchup_session_id():
return mint_session_id()
def create_catchup_session(*, user, channel, start):
"""Persist a new playback session and return metadata for the API response."""
def create_catchup_session(*, user, channel, start, duration=None):
"""Persist a new playback session and return metadata for the API response.
``duration`` is an optional programme length in minutes. When supplied it is
preferred over EPG at playback time (see ``resolve_catchup_duration``).
"""
redis_client = RedisClient.get_client()
if redis_client is None:
raise RuntimeError("Redis unavailable")
@ -47,16 +51,16 @@ def create_catchup_session(*, user, channel, start):
session_id = mint_session_id()
now = int(time.time())
key = TimeshiftRedisKeys.api_session(session_id)
redis_client.hset(
key,
mapping={
"user_id": str(user.id),
"channel_uuid": str(channel.uuid),
"channel_id": str(channel.id),
"start": str(start),
"created_at": str(now),
},
)
mapping = {
"user_id": str(user.id),
"channel_uuid": str(channel.uuid),
"channel_id": str(channel.id),
"start": str(start),
"created_at": str(now),
}
if duration is not None:
mapping["duration"] = str(duration)
redis_client.hset(key, mapping=mapping)
redis_client.expire(key, HANDSHAKE_TTL_SECONDS)
handshake_expires_at = now + HANDSHAKE_TTL_SECONDS
@ -68,6 +72,7 @@ def create_catchup_session(*, user, channel, start):
"expires_at": handshake_expires_at,
"channel_uuid": str(channel.uuid),
"start": str(start),
"duration": duration,
}
@ -157,8 +162,9 @@ def resolve_catchup_playback(session_id, channel_uuid):
"""Resolve user and programme start for a tokenless playback request.
Returns:
``(user, start)`` on success, or ``None`` if the session is invalid,
expired, or bound to a different channel.
``(user, start, duration)`` on success, or ``None`` if the session is
invalid, expired, or bound to a different channel. ``duration`` is the
stored client programme length in minutes, or ``None`` when unset.
"""
record = get_catchup_session(session_id)
if not record:
@ -184,7 +190,7 @@ def resolve_catchup_playback(session_id, channel_uuid):
if not start:
return None
return user, str(start)
return user, str(start), record.get("duration")
def user_owns_catchup_session(session_id, user_id):

View file

@ -339,6 +339,44 @@ class GetProgrammeDurationTests(TestCase):
self.assertEqual(get_programme_duration(MagicMock(), "garbage"), 120)
class ClientDurationTests(TestCase):
"""Client-supplied programme length: sanitised, buffered for provider lag,
capped, and preferred over EPG when usable."""
def test_valid_hint_gets_buffer(self):
from apps.timeshift.helpers import client_duration_to_window
self.assertEqual(client_duration_to_window(30), 35)
self.assertEqual(client_duration_to_window("30"), 35)
def test_hint_capped_at_max(self):
from apps.timeshift.helpers import client_duration_to_window
self.assertEqual(client_duration_to_window(1000), 480)
def test_unusable_hint_returns_none(self):
from apps.timeshift.helpers import client_duration_to_window
for bad in (None, "", "abc", "0", "-5", 0, -10):
self.assertIsNone(client_duration_to_window(bad))
def test_resolve_prefers_client_hint(self):
from unittest.mock import MagicMock
from apps.timeshift.helpers import resolve_catchup_duration
# EPG would say 120 (no programme), but a valid hint wins.
channel = MagicMock(epg_data=None)
self.assertEqual(
resolve_catchup_duration(channel, "2026-06-08:17-00", client_hint="30"),
35,
)
def test_resolve_falls_back_to_epg_when_hint_missing(self):
from unittest.mock import MagicMock
from apps.timeshift.helpers import resolve_catchup_duration
channel = MagicMock(epg_data=None)
self.assertEqual(
resolve_catchup_duration(channel, "2026-06-08:17-00", client_hint=None),
120,
)
class ProgrammeAgeAndStreamOrderTests(TestCase):
"""Archive-age helpers used to prefer deep catch-up providers first."""

View file

@ -126,6 +126,38 @@ class CatchupSessionApiTests(TestCase):
self.assertEqual(data["channel_uuid"], str(self.channel.uuid))
self.assertEqual(data["start"], "2026-06-08T17:00:00Z")
self.assertGreater(data["expires_at"], int(time.time()))
self.assertIsNone(data["duration"])
@patch.object(sessions.RedisClient, "get_client")
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
def test_post_accepts_duration(self, _net, redis_mock):
redis_mock.return_value = self.redis
response = self.client.post(
self._create_url(),
{
"channel_uuid": str(self.channel.uuid),
"start": "2026-06-08T17:00:00Z",
"duration": 30,
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["duration"], 30)
@patch.object(sessions.RedisClient, "get_client")
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
def test_post_rejects_out_of_range_duration(self, _net, redis_mock):
redis_mock.return_value = self.redis
response = self.client.post(
self._create_url(),
{
"channel_uuid": str(self.channel.uuid),
"start": "2026-06-08T17:00:00Z",
"duration": 0,
},
format="json",
)
self.assertEqual(response.status_code, 400)
@patch.object(sessions.RedisClient, "get_client")
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
@ -235,6 +267,32 @@ class CatchupSessionResolveTests(TestCase):
sessions.resolve_catchup_playback(session_id, other_uuid),
)
@patch.object(sessions.RedisClient, "get_client")
def test_create_and_resolve_round_trips_duration(self, redis_mock):
redis_mock.return_value = self.redis
payload = sessions.create_catchup_session(
user=self.user, channel=self.channel, start="2026-06-08T17:00:00Z",
duration=30,
)
self.assertEqual(payload["duration"], 30)
resolved = sessions.resolve_catchup_playback(
payload["session_id"], self.channel.uuid,
)
self.assertIsNotNone(resolved)
self.assertEqual(resolved[2], "30")
@patch.object(sessions.RedisClient, "get_client")
def test_create_without_duration_resolves_none(self, redis_mock):
redis_mock.return_value = self.redis
payload = sessions.create_catchup_session(
user=self.user, channel=self.channel, start="2026-06-08T17:00:00Z",
)
self.assertIsNone(payload["duration"])
resolved = sessions.resolve_catchup_playback(
payload["session_id"], self.channel.uuid,
)
self.assertIsNone(resolved[2])
class CatchupProxySessionAuthTests(TestCase):
"""Playback via API session without JWT."""
@ -252,7 +310,7 @@ class CatchupProxySessionAuthTests(TestCase):
self, channel_cls, _access, serve, _net, resolve_mock,
):
user = MagicMock(id=42, is_authenticated=False)
resolve_mock.return_value = (user, "2026-06-08T17:00:00Z")
resolve_mock.return_value = (user, "2026-06-08T17:00:00Z", None)
channel_cls.objects.get.return_value = MagicMock(
id=8, uuid=self.channel_uuid,
)
@ -277,7 +335,7 @@ class CatchupProxySessionAuthTests(TestCase):
@patch.object(views, "resolve_catchup_playback")
@patch.object(views, "network_access_allowed", return_value=True)
def test_mismatched_jwt_and_session_returns_403(self, _net, resolve_mock):
resolve_mock.return_value = (MagicMock(id=1), "2026-06-08T17:00:00Z")
resolve_mock.return_value = (MagicMock(id=1), "2026-06-08T17:00:00Z", None)
request = self.factory.get(
f"/proxy/catchup/{self.channel_uuid}?session_id=test",
)
@ -312,7 +370,7 @@ class CatchupProxySessionAuthTests(TestCase):
patch.object(views, "_serve_catchup", return_value=HttpResponse("ok")) as serve:
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 200)
serve.assert_called_once()

View file

@ -20,7 +20,7 @@ TEST_MEDIA_ID = "8_2026-06-08-17-00"
def _proxy_url(session_id=TEST_SESSION_ID):
base = "/timeshift/u/p/8/2026-06-08:17-00/8.ts"
base = "/timeshift/u/p/40/2026-06-08:17-00/8.ts"
return f"{base}?session_id={session_id}" if session_id else base
@ -787,7 +787,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase):
self.factory = RequestFactory()
def _call(self, timestamp, provider_tz="Europe/Brussels"):
request = self.factory.get(f"/timeshift/u/p/8/{timestamp}/8.ts?session_id={TEST_SESSION_ID}")
request = self.factory.get(f"/timeshift/u/p/40/{timestamp}/8.ts?session_id={TEST_SESSION_ID}")
sentinel = MagicMock(status_code=200)
with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \
patch.object(views, "network_access_allowed", return_value=True), \
@ -795,7 +795,9 @@ class TimeshiftProxyTimestampWiringTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream(provider_tz)]), \
patch.object(views, "get_programme_duration", return_value=40) as duration_mock, \
patch(
"apps.timeshift.helpers.get_programme_duration", return_value=999,
) 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), \
@ -807,7 +809,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase):
patch.object(views, "_stream_from_provider", return_value=sentinel) as stream_mock:
redis_cls.get_client.return_value = _FakeRedis()
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")
response = views.timeshift_proxy(request, "u", "p", "40", timestamp, "8.ts")
return response, sentinel, build_mock, duration_mock, stream_mock
def test_candidates_get_provider_local_timestamp(self):
@ -816,11 +818,14 @@ class TimeshiftProxyTimestampWiringTests(TestCase):
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_path_duration_preferred_over_epg(self):
# PATH ``/timeshift/.../40/.../8.ts`` is programme minutes; +5 buffer
# becomes 45. EPG lookup must not run when the client supplied a
# usable duration.
_, _, build_mock, duration_mock, stream_mock = self._call("2026-06-08:17-00")
duration_mock.assert_not_called()
self.assertEqual(build_mock.call_args[0][3], 45)
self.assertEqual(stream_mock.call_args.kwargs["duration_minutes"], 45)
def test_utc_provider_passes_timestamp_unchanged(self):
_, _, build_mock, _, _ = self._call("2026-06-08:17-00", provider_tz="UTC")
@ -831,10 +836,11 @@ class TimeshiftProxyTimestampWiringTests(TestCase):
"2026-06-23:04:00:00"
)
self.assertIs(response, sentinel)
self.assertEqual(duration_mock.call_args[0][1], "2026-06-23:04:00:00")
duration_mock.assert_not_called()
self.assertEqual(build_mock.call_args[0][3], 45)
def test_invalid_timestamp_rejected_before_upstream(self):
request = self.factory.get("/timeshift/u/p/8/garbage/8.ts")
request = self.factory.get("/timeshift/u/p/40/garbage/8.ts")
with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \
patch.object(views, "network_access_allowed", return_value=True), \
patch.object(views, "Channel") as channel_cls, \
@ -842,7 +848,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase):
patch.object(views, "get_channel_catchup_streams") 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")
response = views.timeshift_proxy(request, "u", "p", "40", "garbage", "8.ts")
self.assertEqual(response.status_code, 400)
catchup_mock.assert_not_called()
stream_mock.assert_not_called()
@ -855,7 +861,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_stream_from_provider") as stream_mock:
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts"
request, "u", "p", "40", "2026-06-08:17-00", "8.ts"
)
self.assertEqual(response.status_code, 403)
self.assertEqual(gate.call_args[0][1], "XC_API")
@ -873,7 +879,7 @@ class TimeshiftProxyQueryRoutingTests(TestCase):
self.assertIs(match.func, views.timeshift_proxy_query)
def test_path_style_still_resolves_to_timeshift_proxy(self):
match = resolve("/timeshift/u/p/8/2026-06-08:17-00/8.ts")
match = resolve("/timeshift/u/p/40/2026-06-08:17-00/8.ts")
self.assertIs(match.func, views.timeshift_proxy)
@ -898,7 +904,10 @@ class TimeshiftProxyQueryParamMappingTests(TestCase):
)
with patch.object(views, "_timeshift_proxy_impl", return_value=HttpResponse()) as impl:
views.timeshift_proxy_query(request)
impl.assert_called_once_with(request, "u", "p", "2026-06-08:17-00", "8")
impl.assert_called_once_with(
request, "u", "p", "2026-06-08:17-00", "8",
client_duration_hint="40",
)
def test_missing_stream_param_returns_400_without_touching_impl(self):
request = self.factory.get(
@ -926,7 +935,7 @@ class TimeshiftProxyFailoverTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
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) as limits_mock, \
@ -941,7 +950,7 @@ class TimeshiftProxyFailoverTests(TestCase):
redis_cls.get_client.return_value = _FakeRedis()
channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts"
request, "u", "p", "40", "2026-06-08:17-00", "8.ts"
)
self.creds_mock = creds_mock
return response, stream_mock, build_mock, limits_mock
@ -1082,7 +1091,7 @@ class _ProxyLoopTestMixin:
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "build_timeshift_candidate_urls",
**build_kwargs) as build_mock, \
patch.object(views, "check_user_stream_limits", return_value=limits), \
@ -1102,7 +1111,7 @@ class _ProxyLoopTestMixin:
self.creds_mock = creds_mock
self.stream_mock = stream_mock
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts"
request, "u", "p", "40", "2026-06-08:17-00", "8.ts"
)
return response, stream_mock, build_mock
@ -1730,7 +1739,7 @@ class TimeshiftTakeoverTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "_terminate_previous_timeshift_sessions",
side_effect=lambda *a: call_order.append("takeover")) as takeover_mock, \
@ -1739,7 +1748,7 @@ class TimeshiftTakeoverTests(TestCase):
redis_cls.get_client.return_value = self.redis
channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts"
request, "u", "p", "40", "2026-06-08:17-00", "8.ts"
)
self.assertEqual(response.status_code, 403)
self.assertEqual(call_order, ["takeover", "limits"])
@ -1833,7 +1842,7 @@ class TimeshiftSessionReuseTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "parse_catchup_timestamp", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "_acquire_idle_pool_session") as acquire_mock, \
@ -1841,7 +1850,7 @@ class TimeshiftSessionReuseTests(TestCase):
redis_cls.get_client.return_value = self.redis
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 301)
self.assertIn("session_id=", response["Location"])
@ -1985,7 +1994,7 @@ class TimeshiftSessionReuseTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \
@ -1999,7 +2008,7 @@ class TimeshiftSessionReuseTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
acquire_mock.assert_not_called()
@ -2065,7 +2074,7 @@ class TimeshiftSessionReuseTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "_find_matching_pool_session", return_value=None), \
patch.object(views, "_attempt_timeshift_stream",
@ -2079,7 +2088,7 @@ class TimeshiftSessionReuseTests(TestCase):
channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None)
with patch.object(redis, "hgetall", wraps=redis.hgetall) as hgetall_mock:
views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
pool_key = "timeshift:pool:newsession1"
self.assertEqual(
@ -2108,7 +2117,7 @@ class TimeshiftSessionReuseTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot",
@ -2128,7 +2137,7 @@ class TimeshiftSessionReuseTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
self.assertEqual(stream_mock.call_count, 2)
@ -2673,7 +2682,7 @@ class TimeshiftSessionReuseTests(TestCase):
views._release_pool_session(self.redis, TEST_SESSION_ID, 31)
request = self.factory.get(
f"/timeshift/u/p/8/2026-06-08:17-30/8.ts?session_id={TEST_SESSION_ID}"
f"/timeshift/u/p/40/2026-06-08:17-30/8.ts?session_id={TEST_SESSION_ID}"
)
profile = MagicMock(id=31, custom_properties={})
tz_profile = MagicMock(
@ -2689,7 +2698,7 @@ class TimeshiftSessionReuseTests(TestCase):
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream(
account_id=1, stream_id="111", profile_id=31)]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot",
@ -2706,7 +2715,7 @@ class TimeshiftSessionReuseTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-30", "8.ts",
request, "u", "p", "40", "2026-06-08:17-30", "8.ts",
)
self.assertIs(response, ok)
attempt_mock.assert_called_once()
@ -2734,13 +2743,13 @@ class TimeshiftSessionRedirectTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "parse_catchup_timestamp", return_value=True), \
patch.object(views, "RedisClient") as redis_cls:
redis_cls.get_client.return_value = _FakeRedis()
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 301)
self.assertIn("session_id=", response["Location"])
@ -2770,7 +2779,7 @@ class TimeshiftSessionRedirectTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "parse_catchup_timestamp", return_value=True), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
@ -2782,7 +2791,7 @@ class TimeshiftSessionRedirectTests(TestCase):
redis_cls.get_client.return_value = redis
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
reacquire_mock.assert_called_once()
@ -2807,7 +2816,7 @@ class TimeshiftSessionRedirectTests(TestCase):
str(time.time() - 10.0),
)
request = self.factory.get(
"/timeshift/u/p/8/2026-06-08:17-30/8.ts",
"/timeshift/u/p/40/2026-06-08:17-30/8.ts",
HTTP_USER_AGENT="vlc-test",
REMOTE_ADDR="127.0.0.1",
)
@ -2820,7 +2829,7 @@ class TimeshiftSessionRedirectTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "parse_catchup_timestamp", return_value=True), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
@ -2832,7 +2841,7 @@ class TimeshiftSessionRedirectTests(TestCase):
redis_cls.get_client.return_value = redis
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-30", "8.ts",
request, "u", "p", "40", "2026-06-08:17-30", "8.ts",
)
self.assertIs(response, ok)
reacquire_mock.assert_called_once()
@ -2840,7 +2849,7 @@ class TimeshiftSessionRedirectTests(TestCase):
def test_redirect_preserves_existing_query_params(self):
request = self.factory.get(
"/timeshift/u/p/8/2026-06-08:17-00/8.ts?foo=bar&baz=1",
"/timeshift/u/p/40/2026-06-08:17-00/8.ts?foo=bar&baz=1",
)
with patch.object(views, "_authenticate_user", return_value=MagicMock(id=1)), \
patch.object(views, "network_access_allowed", return_value=True), \
@ -2848,13 +2857,13 @@ class TimeshiftSessionRedirectTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "parse_catchup_timestamp", return_value=True), \
patch.object(views, "RedisClient") as redis_cls:
redis_cls.get_client.return_value = _FakeRedis()
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 301)
location = response["Location"]
@ -2871,13 +2880,13 @@ class TimeshiftSessionRedirectTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "parse_catchup_timestamp", return_value=True), \
patch.object(views, "RedisClient") as redis_cls:
redis_cls.get_client.return_value = _FakeRedis()
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 301)
mock_close.assert_called_once()
@ -4606,7 +4615,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \
@ -4623,7 +4632,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
reacquire_mock.assert_called_once()
@ -4641,7 +4650,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \
@ -4659,7 +4668,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
reacquire_mock.assert_called_once()
@ -4682,7 +4691,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \
@ -4701,7 +4710,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
reacquire_mock.assert_called_once()
@ -4730,7 +4739,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)) as reserve_mock, \
@ -4748,7 +4757,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
reacquire_mock.assert_called_once()
@ -4772,7 +4781,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \
@ -4786,7 +4795,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 503)
preempt_mock.assert_not_called()
@ -4805,7 +4814,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \
@ -4821,7 +4830,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 503)
reacquire_mock.assert_not_called()
@ -4866,7 +4875,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot") as reserve_mock, \
@ -4884,7 +4893,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 206)
@ -4950,7 +4959,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot"), \
@ -4967,7 +4976,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 206)
@ -5015,7 +5024,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot"), \
@ -5030,7 +5039,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 416)
@ -5053,7 +5062,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \
@ -5067,7 +5076,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
reacquire_mock.assert_called_once()
@ -5195,7 +5204,7 @@ class TimeshiftScrubPreemptTests(TestCase):
patch.object(views, "Channel") as channel_cls, \
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams", return_value=streams), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "check_user_stream_limits", return_value=True), \
patch.object(views, "RedisClient") as redis_cls, \
patch.object(views, "reserve_profile_slot",
@ -5213,7 +5222,7 @@ class TimeshiftScrubPreemptTests(TestCase):
id=8, name="Test", logo_id=None,
)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertIs(response, ok)
preempt_mock.assert_not_called()
@ -5262,7 +5271,7 @@ class CatchupProxyTests(TestCase):
patch.object(views, "_user_can_access_channel", return_value=True), \
patch.object(views, "get_channel_catchup_streams",
return_value=[_make_catchup_stream()]), \
patch.object(views, "get_programme_duration", return_value=40), \
patch.object(views, "resolve_catchup_duration", return_value=40), \
patch.object(views, "parse_catchup_timestamp", return_value=True), \
patch.object(views, "RedisClient") as redis_cls:
redis_cls.get_client.return_value = _FakeRedis()
@ -5281,10 +5290,11 @@ class CatchupProxyTests(TestCase):
patch.object(views, "_serve_catchup", return_value=HttpResponse("ok")) as serve:
channel_cls.objects.get.return_value = MagicMock(id=8)
response = views.timeshift_proxy(
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
)
self.assertEqual(response.status_code, 200)
serve.assert_called_once()
self.assertEqual(serve.call_args.kwargs.get("client_duration_hint"), "40")
class TimeshiftProviderFaithfulPlainGetTests(_ProxyLoopTestMixin, TestCase):

View file

@ -58,9 +58,9 @@ from .helpers import (
TimeshiftCredentials,
build_timeshift_candidate_urls,
convert_timestamp_to_provider_tz,
get_programme_duration,
order_catchup_streams_for_timestamp,
parse_catchup_timestamp,
resolve_catchup_duration,
)
from .sessions import catchup_session_exists, delete_catchup_session, resolve_catchup_playback
from .stats import (
@ -100,14 +100,15 @@ def _finalize_timeshift_response(response):
return response
def timeshift_proxy(request, username, password, stream_id, timestamp, duration): # noqa: ARG001 stream_id
def timeshift_proxy(request, username, password, duration, timestamp, channel_id):
"""Proxy an XC catch-up request to the provider with multi-stream failover.
URL shape (XC catch-up clients):
``stream_id``: EPG channel number (ignored here).
``duration``: Dispatcharr ``Channel.id`` (XC API exposes channel.id as stream_id).
URL shape (XC catch-up clients, matches provider PATH form):
``/timeshift/{user}/{pass}/{duration}/{start}/{channel_id}.ts``
``duration``: programme length in minutes from the client's guide.
``timestamp``: UTC programme start (``YYYY-MM-DD:HH-MM`` or XC colon form
``YYYY-MM-DD:HH:MM:SS``).
``channel_id``: Dispatcharr ``Channel.id`` (often with a ``.ts`` suffix).
Session handling (``?session_id=``):
First request with no ``session_id`` and no matching pool entry ``301`` with a
@ -115,30 +116,37 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration)
an in-flight or idle pool entry for the same viewer are served immediately
(no redirect). Reuse ``session_id`` for all range/seek requests in a programme.
"""
return _timeshift_proxy_impl(request, username, password, timestamp, duration)
return _timeshift_proxy_impl(
request, username, password, timestamp, channel_id,
client_duration_hint=duration,
)
def timeshift_proxy_query(request):
"""Proxy an XC catch-up request submitted in QUERY layout.
URL shape (XC catch-up clients): ``/streaming/timeshift.php?username=...
&password=...&stream=<Channel.id>&start=<UTC programme start>&duration=...``
(``duration`` here is the EPG-minutes hint some clients send; unused, same
as ``stream_id`` in the PATH-layout ``timeshift_proxy`` above).
&password=...&stream=<Channel.id>&start=<UTC programme start>&duration=<minutes>``.
``duration`` is preferred over EPG when present (same as the PATH form).
"""
username = request.GET.get("username", "")
password = request.GET.get("password", "")
timestamp = request.GET.get("start", "")
duration = request.GET.get("stream", "")
if not (username and password and timestamp and duration):
channel_id = request.GET.get("stream", "")
if not (username and password and timestamp and channel_id):
return _finalize_timeshift_response(
HttpResponseBadRequest("Missing required parameters")
)
return _timeshift_proxy_impl(request, username, password, timestamp, duration)
return _timeshift_proxy_impl(
request, username, password, timestamp, channel_id,
client_duration_hint=request.GET.get("duration"),
)
def _timeshift_proxy_impl(request, username, password, timestamp, duration):
raw_id = duration[:-3] if duration.endswith(".ts") else duration
def _timeshift_proxy_impl(
request, username, password, timestamp, channel_id, client_duration_hint=None,
):
raw_id = channel_id[:-3] if channel_id.endswith(".ts") else channel_id
user = _authenticate_user(username, password)
if user is None:
@ -156,7 +164,10 @@ def _timeshift_proxy_impl(request, username, password, timestamp, duration):
if not _user_can_access_channel(user, channel):
return _finalize_timeshift_response(HttpResponseForbidden("Access denied"))
return _serve_catchup(request, user, channel, timestamp)
return _serve_catchup(
request, user, channel, timestamp,
client_duration_hint=client_duration_hint,
)
@extend_schema(
@ -254,6 +265,8 @@ def catchup_proxy(request, channel_id):
session_id = request.GET.get("session_id")
timestamp = request.GET.get("start")
user = auth_user
# Direct-auth clients may pass ?duration=; API sessions store their own.
client_duration_hint = request.GET.get("duration")
if session_id:
resolved = resolve_catchup_playback(session_id, channel_id)
@ -264,11 +277,13 @@ def catchup_proxy(request, channel_id):
status=401,
)
else:
session_user, bound_start = resolved
session_user, bound_start, bound_duration = resolved
if auth_user is not None and auth_user.id != session_user.id:
return _finalize_timeshift_response(HttpResponseForbidden("Access denied"))
user = session_user
timestamp = bound_start
if bound_duration is not None:
client_duration_hint = bound_duration
if user is None:
return JsonResponse({"error": "Authentication required"}, status=401)
@ -285,11 +300,21 @@ def catchup_proxy(request, channel_id):
if not timestamp:
return _finalize_timeshift_response(HttpResponseBadRequest("Missing start parameter"))
return _serve_catchup(request, user, channel, timestamp)
return _serve_catchup(
request, user, channel, timestamp,
client_duration_hint=client_duration_hint,
)
def _serve_catchup(request, user, channel, timestamp):
"""Shared catch-up proxy logic for XC and native API entry points."""
def _serve_catchup(request, user, channel, timestamp, client_duration_hint=None):
"""Shared catch-up proxy logic for XC and native API entry points.
``client_duration_hint`` is a programme length in minutes supplied by the
client (XC PATH duration segment, QUERY ``duration=``, or a native
session's stored duration). When usable it is preferred over EPG;
otherwise EPG is used, falling back to ``DEFAULT_DURATION_MINUTES``.
A ``DURATION_BUFFER_MINUTES`` pad is applied either way for provider lag.
"""
if parse_catchup_timestamp(timestamp) is None:
return _finalize_timeshift_response(HttpResponseBadRequest("Invalid timestamp"))
@ -303,8 +328,11 @@ def _serve_catchup(request, user, channel, timestamp):
debug = logger.isEnabledFor(logging.DEBUG)
# EPG duration lookup stays in UTC; provider TZ conversion is per-attempt below.
duration_minutes = get_programme_duration(channel, timestamp)
# Client hint preferred; else EPG (UTC); else DEFAULT_DURATION_MINUTES.
# Provider TZ conversion for the start timestamp is per-attempt below.
duration_minutes = resolve_catchup_duration(
channel, timestamp, client_hint=client_duration_hint,
)
safe_ts = timestamp.replace(":", "-").replace("/", "-")
client_ip = get_client_ip(request)

View file

@ -43,7 +43,7 @@ urlpatterns = [
name="xc_stream_endpoint",
),
path(
"timeshift/<str:username>/<str:password>/<str:stream_id>/<str:timestamp>/<str:duration>",
"timeshift/<str:username>/<str:password>/<str:duration>/<str:timestamp>/<str:channel_id>",
timeshift_proxy,
name="timeshift_proxy",
),