mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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:
parent
d3d51780dd
commit
14bfd25d9a
9 changed files with 318 additions and 115 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue