diff --git a/CHANGELOG.md b/CHANGELOG.md index adf6df28..b336b834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/?session_id=...` for headerless video players. `DELETE /api/catchup/sessions//` 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/?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//` 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). diff --git a/apps/timeshift/api_views.py b/apps/timeshift/api_views.py index 59954a30..4a90e64a 100644 --- a/apps/timeshift/api_views.py +++ b/apps/timeshift/api_views.py @@ -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"}, diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py index 30e34222..292ae2db 100644 --- a/apps/timeshift/helpers.py +++ b/apps/timeshift/helpers.py @@ -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: diff --git a/apps/timeshift/sessions.py b/apps/timeshift/sessions.py index 1836e29e..b97e2add 100644 --- a/apps/timeshift/sessions.py +++ b/apps/timeshift/sessions.py @@ -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): diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py index 5a4deed7..a9647df3 100644 --- a/apps/timeshift/tests/test_helpers.py +++ b/apps/timeshift/tests/test_helpers.py @@ -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.""" diff --git a/apps/timeshift/tests/test_sessions.py b/apps/timeshift/tests/test_sessions.py index 0a6e8060..385fe292 100644 --- a/apps/timeshift/tests/test_sessions.py +++ b/apps/timeshift/tests/test_sessions.py @@ -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() diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 408eedea..92ab6890 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -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): diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 120a168a..e56dd5a5 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -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=&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=&start=&duration=``. + ``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) diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 8e8831cf..d6395c29 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -43,7 +43,7 @@ urlpatterns = [ name="xc_stream_endpoint", ), path( - "timeshift/////", + "timeshift/////", timeshift_proxy, name="timeshift_proxy", ),