From a90872079b84170f8f697490101240ad57c12138 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 6 Jul 2026 19:49:19 -0500 Subject: [PATCH] Enhancement: Update session management details and improve stream limit handling in timeshift proxy. Enhance tests for decisive account failover and session probe logic. --- CHANGELOG.md | 2 +- apps/proxy/utils.py | 4 +- apps/timeshift/tests/test_views.py | 68 +++++++++++++++++++++++++++++- apps/timeshift/views.py | 18 ++++++-- 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5059efe6..2c015a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. - - **Per-client session pool.** The first request without `session_id` receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching (same user and programme, IP + user-agent score). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Stream-limit exemptions for `ignore_same_channel_connections` apply only to sibling requests from the same `session_id`. + - **Per-client session pool.** The first request without `session_id` receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching (same user and programme, IP + user-agent score). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Parallel HTTP probes from the same `session_id` for the same programme do not count as extra streams toward the user limit; distinct sessions still each consume a slot. - **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index fc157791..081658a4 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -185,7 +185,7 @@ def check_user_stream_limits(user, client_id, media_id=None): # Timeshift sibling range/probe requests share one provider slot per # session_id. Each distinct client/session still consumes its own slot. - if ignore_same_channel and media_id: + if media_id and client_id: media_id_str = str(media_id) for conn in active_connections: if conn.get('type') != 'timeshift': @@ -195,7 +195,7 @@ def check_user_stream_limits(user, client_id, media_id=None): conn_media_id = str(conn.get('media_id') or '') if conn_media_id == media_id_str or conn_media_id.startswith(f"{media_id_str}_"): logger.debug( - f"[stream limits][{client_id}] Same timeshift session probe for {media_id} allowed (ignore_same_channel=True)" + f"[stream limits][{client_id}] Same timeshift session probe for {media_id} allowed" ) return True diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 1e45f53b..91fb2161 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -1556,6 +1556,57 @@ class TimeshiftSessionReuseTests(TestCase): 1, ) + def test_reuse_decisive_failure_skips_same_account_in_failover(self): + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + with patch.object(views, "release_profile_slot"): + views._release_pool_session(self.redis, TEST_SESSION_ID, 31) + + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), + _make_catchup_stream(account_id=1, stream_id="112", profile_id=31), + _make_catchup_stream(account_id=2, stream_id="222", profile_id=41), + ] + profile = MagicMock(id=31, custom_properties={}) + account = MagicMock(id=1) + decisive = MagicMock(status_code=403) + decisive.timeshift_decisive = True + ok = MagicMock(status_code=200) + request = self.factory.get(_proxy_url(TEST_SESSION_ID)) + with patch.object(views, "_authenticate_user", return_value=self.user), \ + patch.object(views, "network_access_allowed", return_value=True), \ + 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, "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, \ + patch.object(views, "release_profile_slot"), \ + patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views.M3UAccountProfile.objects, "get", + return_value=profile), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "build_timeshift_candidate_urls", + return_value=["http://example.test/x.ts"]), \ + patch.object(views, "_stream_from_provider", + side_effect=[decisive, ok]) as stream_mock: + 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", + ) + self.assertIs(response, ok) + self.assertEqual(stream_mock.call_count, 2) + self.assertEqual( + [c.kwargs["account_id"] for c in stream_mock.call_args_list], [1, 2] + ) + # Reuse reserved once; failover reserved only for the other account. + self.assertEqual(reserve_mock.call_count, 2) + class TimeshiftSessionRedirectTests(TestCase): """First request must establish a session via 301 redirect (VOD-style).""" @@ -1637,6 +1688,22 @@ class TimeshiftStreamLimitExemptionTests(TestCase): ) self.assertTrue(allowed) + def test_same_session_probe_allowed_without_ignore_same_channel(self): + connections = [{ + "media_id": f"{self.MEDIA}_111", + "client_id": TEST_SESSION_ID, + "connected_at": 0.0, + "type": "timeshift", + }] + with patch("apps.proxy.utils.get_user_active_connections", + return_value=connections), \ + patch("apps.proxy.utils.CoreSettings.get_user_limits_settings", + return_value=self._limits_settings(ignore_same_channel=False)): + allowed = _check_user_stream_limits( + self.user, TEST_SESSION_ID, media_id=self.MEDIA, + ) + self.assertTrue(allowed) + def test_different_session_same_programme_counts_against_limit(self): connections = [{ "media_id": f"{self.MEDIA}_111", @@ -1943,7 +2010,6 @@ class TimeshiftScrubPreemptTests(TestCase): reserve_mock.assert_called_once_with(profile, self.redis) - class RollupSelfHealDbTests(TestCase): """Catch-up flag consistency after stream removal. diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 0ba3a5a7..a10c1a28 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -187,6 +187,9 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) redis_client, effective_session_id, user_id=user.id, ) + last_response = None + decisive_accounts = set() + if acquired is not None: descriptor, profile = acquired reuse_response = _stream_reused_session( @@ -206,7 +209,16 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) debug=debug, ) if reuse_response is not None: - return reuse_response + if reuse_response.status_code < 400: + return reuse_response + if getattr(reuse_response, "timeshift_passthrough", False) is True: + return reuse_response + last_response = reuse_response + if getattr(reuse_response, "timeshift_decisive", False): + try: + decisive_accounts.add(int(descriptor["account_id"])) + except (ValueError, TypeError): + pass if pool_exists and pool_busy and not _should_displace_busy_playback( range_header, pool_content_length, busy_serving_range, @@ -224,8 +236,6 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) ) return HttpResponse("Stream slot busy", status=503) - last_response = None - decisive_accounts = set() capacity_blocked = False for catchup_stream in catchup_streams: m3u_account = catchup_stream.m3u_account @@ -943,7 +953,7 @@ def _stream_reused_session( return response _discard_pool_session(redis_client, session_id, profile.id) - return None + return response class _SlotReleasingStream: